From 61edb4ee85e35f4cc967fa6b502b58d8e3bc6e4f Mon Sep 17 00:00:00 2001 From: matlabbe Date: Mon, 7 Sep 2026 21:23:22 -0700 Subject: [PATCH] rtabmap_util tests and doc (#1450) * 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 * rtabmap_util tests and doc * Added db_player tests * Added MapsManager tests * Added map_assembler tests * Documenting node first draft * relative links * Fixed british->usa english style. Reviewed all md files. * added link to install ros1 * updated badges * added Iron * added ubuntu * added codecov * updated coverage ci * fixing rosdep * updated ci cov job * ci bump * fixing cov ci * small doc cleanup --- .github/workflows/coverage.yml | 156 +++ .gitignore | 2 + README.md | 167 +-- codecov.yml | 77 ++ rtabmap_conversions/README.md | 4 +- .../rtabmap_conversions/MsgConversion.h | 16 +- rtabmap_conversions/src/MsgConversion.cpp | 14 +- .../test/test_msg_conversion.cpp | 67 +- rtabmap_odom/src/nodelets/icp_odometry.cpp | 2 +- rtabmap_util/CMakeLists.txt | 37 + rtabmap_util/README.md | 66 + rtabmap_util/doc/db_player.md | 117 ++ rtabmap_util/doc/disparity_to_depth.md | 55 + rtabmap_util/doc/imu_to_tf.md | 197 +++ rtabmap_util/doc/lidar_deskewing.md | 95 ++ rtabmap_util/doc/map_assembler.md | 109 ++ rtabmap_util/doc/obstacles_detection.md | 140 +++ rtabmap_util/doc/point_cloud_aggregator.md | 97 ++ rtabmap_util/doc/point_cloud_assembler.md | 162 +++ rtabmap_util/doc/point_cloud_xyz.md | 95 ++ rtabmap_util/doc/point_cloud_xyzrgb.md | 121 ++ rtabmap_util/doc/pointcloud_to_depthimage.md | 96 ++ rtabmap_util/doc/rgbd_relay.md | 75 ++ rtabmap_util/doc/rgbd_split.md | 76 ++ .../include/rtabmap_util/MapsManager.h | 133 +++ .../include/rtabmap_util/map_assembler.hpp | 4 + .../rtabmap_util/point_cloud_aggregator.hpp | 4 +- .../rtabmap_util/point_cloud_assembler.hpp | 4 +- .../include/rtabmap_util/rgbd_split.hpp | 3 + rtabmap_util/package.xml | 3 + rtabmap_util/rosdoc2.yaml | 35 + rtabmap_util/src/DbPlayerNode.cpp | 16 +- rtabmap_util/src/MapsManager.cpp | 7 + rtabmap_util/src/nodelets/db_player.cpp | 21 +- .../src/nodelets/disparity_to_depth.cpp | 24 +- rtabmap_util/src/nodelets/lidar_deskewing.cpp | 2 +- rtabmap_util/src/nodelets/map_assembler.cpp | 48 +- .../src/nodelets/point_cloud_aggregator.cpp | 39 +- .../src/nodelets/point_cloud_assembler.cpp | 38 +- .../src/nodelets/pointcloud_to_depthimage.cpp | 7 +- rtabmap_util/src/nodelets/rgbd_relay.cpp | 53 +- rtabmap_util/src/nodelets/rgbd_split.cpp | 113 +- rtabmap_util/test/db_builders.hpp | 366 ++++++ rtabmap_util/test/msg_builders.hpp | 217 ++++ rtabmap_util/test/node_test_utils.hpp | 320 +++++ rtabmap_util/test/test_db_player.cpp | 809 +++++++++++++ rtabmap_util/test/test_disparity_to_depth.cpp | 248 ++++ rtabmap_util/test/test_imu_to_tf.cpp | 205 ++++ rtabmap_util/test/test_lidar_deskewing.cpp | 204 ++++ rtabmap_util/test/test_map_assembler.cpp | 549 +++++++++ rtabmap_util/test/test_maps_manager.cpp | 1060 +++++++++++++++++ .../test/test_obstacles_detection.cpp | 423 +++++++ .../test/test_point_cloud_aggregator.cpp | 194 +++ .../test/test_point_cloud_assembler.cpp | 399 +++++++ rtabmap_util/test/test_point_cloud_xyz.cpp | 344 ++++++ rtabmap_util/test/test_point_cloud_xyzrgb.cpp | 531 +++++++++ .../test/test_pointcloud_to_depthimage.cpp | 370 ++++++ rtabmap_util/test/test_rgbd_relay.cpp | 350 ++++++ rtabmap_util/test/test_rgbd_split.cpp | 397 ++++++ tools/set_doc_distro.sh | 52 + 60 files changed, 9443 insertions(+), 192 deletions(-) create mode 100644 .github/workflows/coverage.yml create mode 100644 codecov.yml create mode 100644 rtabmap_util/README.md create mode 100644 rtabmap_util/doc/db_player.md create mode 100644 rtabmap_util/doc/disparity_to_depth.md create mode 100644 rtabmap_util/doc/imu_to_tf.md create mode 100644 rtabmap_util/doc/lidar_deskewing.md create mode 100644 rtabmap_util/doc/map_assembler.md create mode 100644 rtabmap_util/doc/obstacles_detection.md create mode 100644 rtabmap_util/doc/point_cloud_aggregator.md create mode 100644 rtabmap_util/doc/point_cloud_assembler.md create mode 100644 rtabmap_util/doc/point_cloud_xyz.md create mode 100644 rtabmap_util/doc/point_cloud_xyzrgb.md create mode 100644 rtabmap_util/doc/pointcloud_to_depthimage.md create mode 100644 rtabmap_util/doc/rgbd_relay.md create mode 100644 rtabmap_util/doc/rgbd_split.md create mode 100644 rtabmap_util/rosdoc2.yaml create mode 100644 rtabmap_util/test/db_builders.hpp create mode 100644 rtabmap_util/test/msg_builders.hpp create mode 100644 rtabmap_util/test/node_test_utils.hpp create mode 100644 rtabmap_util/test/test_db_player.cpp create mode 100644 rtabmap_util/test/test_disparity_to_depth.cpp create mode 100644 rtabmap_util/test/test_imu_to_tf.cpp create mode 100644 rtabmap_util/test/test_lidar_deskewing.cpp create mode 100644 rtabmap_util/test/test_map_assembler.cpp create mode 100644 rtabmap_util/test/test_maps_manager.cpp create mode 100644 rtabmap_util/test/test_obstacles_detection.cpp create mode 100644 rtabmap_util/test/test_point_cloud_aggregator.cpp create mode 100644 rtabmap_util/test/test_point_cloud_assembler.cpp create mode 100644 rtabmap_util/test/test_point_cloud_xyz.cpp create mode 100644 rtabmap_util/test/test_point_cloud_xyzrgb.cpp create mode 100644 rtabmap_util/test/test_pointcloud_to_depthimage.cpp create mode 100644 rtabmap_util/test/test_rgbd_relay.cpp create mode 100644 rtabmap_util/test/test_rgbd_split.cpp create mode 100755 tools/set_doc_distro.sh diff --git a/.github/workflows/coverage.yml b/.github/workflows/coverage.yml new file mode 100644 index 00000000..dd22c6ed --- /dev/null +++ b/.github/workflows/coverage.yml @@ -0,0 +1,156 @@ +name: Coverage + +on: + push: + branches: [ ros2 ] + pull_request: + branches: [ ros2 ] + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + id-token: write + +jobs: + coverage: + name: Coverage (humble) + runs-on: ubuntu-latest + container: + # RTAB-Map master, already built and installed on top of ROS Humble + # (jammy = 22.04) -- the same base the repo's own docker/humble image + # uses. Saves building the library here, and pins the coverage run to + # RTAB-Map master rather than whatever the released binaries carry. + image: introlab3it/rtabmap:jammy + env: + CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + steps: + - uses: actions/checkout@v4 + + # The container runs as root, so no sudo (it is not installed). + # + # colcon lcov-result is not a built-in verb -- it comes from the + # colcon-lcov-result package, which action-ros-ci calls but does not + # install. ros2.yml gets it for free because it runs ros-tooling/setup-ros + # first; this job calls action-ros-ci directly, so install it here. + # Without it action-ros-ci logs "invalid choice: 'lcov-result'", ignores + # the failure, and the job goes green having measured nothing. + # Versions pinned to the ones setup-ros uses. + - run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + # lcov for genhtml (the browsable artifact below); colcon-lcov-result + # shells out to it too. + apt-get install -y lcov python3-pip + pip3 install -U \ + colcon-lcov-result==0.5.0 \ + colcon-coveragepy-result==0.0.8 + colcon lcov-result --help > /dev/null + # The image ships rosdep but has never initialized it -- the repo's + # own docker/humble Dockerfile runs `rosdep init` for the same reason. + # action-ros-ci only runs `rosdep update`, which fails with "no + # sources directory exists" until this has happened once. + rosdep init || true + + # Only the packages that have tests. The others are still built when a + # tested package depends on them (--packages-up-to), just not measured. + - uses: ros-tooling/action-ros-ci@v0.4 + with: + package-name: rtabmap_conversions rtabmap_util + target-ros2-distro: humble + # RTAB-Map is installed in the image, not as an apt package, so rosdep + # cannot resolve the key and must not try. + rosdep-skip-keys: rtabmap + # The coverage-gcc mixin only adds --coverage; it sets no build type, + # and neither action-ros-ci nor this repo's CMakeLists do. Without + # this the build type is empty, which happens to mean -O0 but is not + # guaranteed to stay that way -- and at -O2 inlining and dead-code + # elimination make gcov's line attribution unreliable. + extra-cmake-args: -DCMAKE_BUILD_TYPE=Debug + colcon-defaults: | + { + "build": { + "mixin": ["coverage-gcc"] + } + } + # Pinned so a change in the mixin repository cannot break this job. + colcon-mixin-repository: https://raw.githubusercontent.com/colcon/colcon-mixin-repository/b8436aa16c0bdbc01081b12caa253cbf16e0fb82/index.yaml + # Run the coverage passes ourselves, in the step below. The action's + # own baseline pass is a bare `colcon lcov-result --initial` with no + # package selection, so it walks every package in the workspace -- + # including the ones --packages-up-to never built -- and fails with + # "cannot read .../build/rtabmap_viz". + coverage-result: false + + # Both passes restricted to the packages that were actually built. + # + # lcov captures each package's whole build directory, so more than this + # repo's sources land in it. Filtered: + # */test/* the test sources -- the instrument, not the subject. A line + # there is uncovered only when the test skipped it, which says + # nothing about the code under test, and they are near-fully + # covered by construction. + # /usr/*, /opt/* everything outside the workspace. RTAB-Map's own .cpp + # files are never captured (the library is installed in the + # image, not built here), but its inline and template code is + # emitted into the objects of the packages that include it, + # as are PCL, Eigen and the standard library. + # *CompilerId*, */CMakeFiles/* CMake's compiler-probe translation + # units. colcon-lcov-result tries to delete their .gcno files + # but misses these, and they are recorded against a path that + # does not exist -- which kills the genhtml pass colcon runs + # at the end ("cannot read .../CMakeCCompilerId.c", exit 2). + # Filters run before that pass, so dropping them here is + # enough. + # Quoting them matters: action-ros-ci injects filters unquoted, so the + # shell can glob them away before colcon ever sees them. + - name: Coverage report + working-directory: ros_ws + run: | + # `.` not `source`: steps in this container run under sh (dash), where + # `source` does not exist. GitHub picks sh whenever it cannot find + # bash in the image's PATH, and says so in the log ("shell: sh -e"). + . /opt/ros/humble/setup.sh + PKGS="rtabmap_conversions rtabmap_util" + # Baseline from the .gcno files. Without it a source file that no test + # ever loaded is missing from the report altogether rather than + # counted as 0%, which quietly inflates the result. + colcon lcov-result --initial --packages-select $PKGS + # The verb ends by running genhtml and returns *its* exit code, so a + # cosmetic HTML hiccup fails the whole job even though the report was + # written. The deliverable is total_coverage.info; the HTML is a + # convenience. Tolerate the former, then gate on the latter. + colcon lcov-result --packages-select $PKGS --verbose \ + --filter '*/test/*' '/usr/*' '/opt/*' \ + '*CompilerId*' '*/CMakeFiles/*' || true + test -s lcov/total_coverage.info + + # Fails the job if the step above produced nothing -- the failure mode + # this workflow has hit twice already is a green run that measured zero. + - name: Coverage summary + run: lcov --summary ros_ws/lcov/total_coverage.info + + # colcon lcov-result runs genhtml itself, into the same lcov/ directory. + - name: Upload HTML coverage artifact + uses: actions/upload-artifact@v4 + with: + name: coverage-html + path: ros_ws/lcov + retention-days: 14 + + - name: Upload to Codecov + if: ${{ env.CODECOV_TOKEN != '' }} + uses: codecov/codecov-action@v5 + with: + files: ros_ws/lcov/total_coverage.info + # Upload ONLY the aggregated lcov file. By default the CLI also walks + # the tree and runs gcov over every .gcno it finds, which re-adds the + # test sources the --filter above just dropped. + disable_search: true + plugins: noop + token: ${{ env.CODECOV_TOKEN }} + fail_ci_if_error: false diff --git a/.gitignore b/.gitignore index 8f56a6c7..5d2ec8fa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ .pydevproject .settings +.vscode __pycache__ # rosdoc2 build artifacts docs_build cross_reference +doc_output diff --git a/README.md b/README.md index de5a2176..eaee4560 100644 --- a/README.md +++ b/README.md @@ -1,89 +1,86 @@ rtabmap_ros =========== -RTAB-Map's ROS2 package (branch `ros2`). **ROS2 Humble minimum required**: currently most nodes are ported to ROS2. The interface is the same than on ROS1 (parameters and topic names should still match ROS1 documentation on [rtabmap_ros](http://wiki.ros.org/rtabmap_ros)). +ROS 2 wrapper for [RTAB-Map](https://github.com/introlab/rtabmap), a graph-based SLAM library with appearance-based loop closure detection. It builds and maintains a 3D map from RGB-D, stereo or lidar data, closes loops on revisited places and exports the result as an occupancy grid, a point cloud or an OctoMap. + +**ROS 2 Humble minimum required.** The interface matches ROS 1: parameters and topic names still follow the [ROS 1 documentation](http://wiki.ros.org/rtabmap_ros) for anything not yet covered by the package pages below. #### CI Latest - - - - - - - - - - - -
ROS 1Build Status
Build Status -
ROS 2Build Status -
- - #### ROS Binaries - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ROS 1NoeticBuild Status
ROS 2HumbleBuild Status
JazzyBuild Status
RollingBuild Status
Docker - rtabmap_ros - Docker Pulls
+| | Build | Docker | +|---|---|---| +| ROS 1 | [![ROS 1](https://github.com/introlab/rtabmap_ros/actions/workflows/ros1.yml/badge.svg)](https://github.com/introlab/rtabmap_ros/actions/workflows/ros1.yml) | [![Docker](https://github.com/introlab/rtabmap_ros/actions/workflows/docker.yml/badge.svg)](https://github.com/introlab/rtabmap_ros/actions/workflows/docker.yml) | +| ROS 2 | [![ROS 2](https://github.com/introlab/rtabmap_ros/actions/workflows/ros2.yml/badge.svg)](https://github.com/introlab/rtabmap_ros/actions/workflows/ros2.yml) | [![Docker ROS 2](https://github.com/introlab/rtabmap_ros/actions/workflows/docker-ros2.yml/badge.svg)](https://github.com/introlab/rtabmap_ros/actions/workflows/docker-ros2.yml) | -# Usage +#### ROS Binaries -* For sensor integration examples (stereo and RGB-D cameras, 3D LiDAR), see [rtabmap_examples](https://github.com/introlab/rtabmap_ros/tree/ros2/rtabmap_examples/launch) sub-folder. +| | Distro | Ubuntu | Released | In apt | Build | +|---|---|---|---|---|---| +| ROS 1 | Noetic (EOL) | 20.04 | [![released](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fros%2Frosdistro%2Fmaster%2Fnoetic%2Fdistribution.yaml&query=%24.repositories.rtabmap_ros.release.version&label=%20)](https://github.com/ros/rosdistro/blob/master/noetic/distribution.yaml) | [![apt](https://img.shields.io/ros/v/noetic/rtabmap_ros?label=%20)](https://index.ros.org/p/rtabmap_ros/#noetic) | | +| ROS 2 | Humble | 22.04 | [![released](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fros%2Frosdistro%2Fmaster%2Fhumble%2Fdistribution.yaml&query=%24.repositories.rtabmap_ros.release.version&label=%20)](https://github.com/ros/rosdistro/blob/master/humble/distribution.yaml) | [![apt](https://img.shields.io/ros/v/humble/rtabmap_ros?label=%20)](https://index.ros.org/p/rtabmap_ros/#humble) | [![build](http://build.ros2.org/buildStatus/icon?job=Hbin_uJ64__rtabmap_ros__ubuntu_jammy_amd64__binary)](http://build.ros2.org/job/Hbin_uJ64__rtabmap_ros__ubuntu_jammy_amd64__binary/) | +| ROS 2 | Iron (EOL) | 22.04 | [![released](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fros%2Frosdistro%2Fmaster%2Firon%2Fdistribution.yaml&query=%24.repositories.rtabmap_ros.release.version&label=%20)](https://github.com/ros/rosdistro/blob/master/iron/distribution.yaml) | [![apt](https://img.shields.io/ros/v/iron/rtabmap_ros?label=%20)](https://index.ros.org/p/rtabmap_ros/#iron) | | +| ROS 2 | Jazzy | 24.04 | [![released](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fros%2Frosdistro%2Fmaster%2Fjazzy%2Fdistribution.yaml&query=%24.repositories.rtabmap_ros.release.version&label=%20)](https://github.com/ros/rosdistro/blob/master/jazzy/distribution.yaml) | [![apt](https://img.shields.io/ros/v/jazzy/rtabmap_ros?label=%20)](https://index.ros.org/p/rtabmap_ros/#jazzy) | [![build](http://build.ros2.org/buildStatus/icon?job=Jbin_uN64__rtabmap_ros__ubuntu_noble_amd64__binary)](http://build.ros2.org/job/Jbin_uN64__rtabmap_ros__ubuntu_noble_amd64__binary/) | +| ROS 2 | Kilted | 24.04 | [![released](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fros%2Frosdistro%2Fmaster%2Fkilted%2Fdistribution.yaml&query=%24.repositories.rtabmap_ros.release.version&label=%20)](https://github.com/ros/rosdistro/blob/master/kilted/distribution.yaml) | [![apt](https://img.shields.io/ros/v/kilted/rtabmap_ros?label=%20)](https://index.ros.org/p/rtabmap_ros/#kilted) | [![build](http://build.ros2.org/buildStatus/icon?job=Kbin_uN64__rtabmap_ros__ubuntu_noble_amd64__binary)](http://build.ros2.org/job/Kbin_uN64__rtabmap_ros__ubuntu_noble_amd64__binary/) | +| ROS 2 | Lyrical | 26.04 | [![released](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fros%2Frosdistro%2Fmaster%2Flyrical%2Fdistribution.yaml&query=%24.repositories.rtabmap_ros.release.version&label=%20)](https://github.com/ros/rosdistro/blob/master/lyrical/distribution.yaml) | [![apt](https://img.shields.io/ros/v/lyrical/rtabmap_ros?label=%20)](https://index.ros.org/p/rtabmap_ros/#lyrical) | [![build](http://build.ros2.org/buildStatus/icon?job=Lbin_uR64__rtabmap_ros__ubuntu_resolute_amd64__binary)](http://build.ros2.org/job/Lbin_uR64__rtabmap_ros__ubuntu_resolute_amd64__binary/) | +| ROS 2 | Rolling | 26.04 | [![released](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2Fros%2Frosdistro%2Fmaster%2Frolling%2Fdistribution.yaml&query=%24.repositories.rtabmap_ros.release.version&label=%20)](https://github.com/ros/rosdistro/blob/master/rolling/distribution.yaml) | [![apt](https://img.shields.io/ros/v/rolling/rtabmap_ros?label=%20)](https://index.ros.org/p/rtabmap_ros/#rolling) | | +| Docker | [rtabmap_ros](https://hub.docker.com/r/introlab3it/rtabmap_ros) | | | ![Docker Pulls](https://img.shields.io/docker/pulls/introlab3it/rtabmap_ros.svg?label=pulls) | | -* For robot integration examples (turtlebot3 and turtlebot4, nav2 integration), see [rtabmap_demos](https://github.com/introlab/rtabmap_ros/tree/ros2/rtabmap_demos) sub-folder. +*Released* is the version bloomed into [rosdistro](https://github.com/ros/rosdistro); *In apt* is what `apt install` actually gives you today. They differ while a release is waiting on a buildfarm sync. -## Logging -To make RTAB-Map's logs appear ordered with RCLCPP's logs, set the following environment variables in your `.bashrc` (see official "[About Logging](https://docs.ros.org/en/humble/Concepts/Intermediate/About-Logging.html)" documentation for more info): -```bash -export RCUTILS_LOGGING_USE_STDOUT=1 -export RCUTILS_LOGGING_BUFFERED_STREAM=1 -# Optional, but if you like colored logs: -export RCUTILS_COLORIZED_OUTPUT=1 -``` +# Packages -## Recommended DDS -If RTAB-Map's GUI or topic frequency feel laggy (even if processing time looks fast enough), it may be caused by the DDS. I recommend to use [Cyclone DDS](https://docs.ros.org/en/foxy/Installation/DDS-Implementations/Working-with-Eclipse-CycloneDDS.html), you can try it by adding this before launching any nodes/launch files (or add to your `.bashrc`): -```bash -export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp -# Cyclone prefers multicast by default, if your router got too much spammed, -# disable multicast with (https://github.com/ros2/rmw_cyclonedds/issues/489): -export CYCLONEDDS_URI="0.0.0.0" -``` +The stack is split into small packages so a pipeline only pulls in what it uses. Package names link to their documentation where it exists; the rest are being written and will be linked as they land. -# Installation +### SLAM + +| Package | Description | +|---|---| +| `rtabmap_slam` | The `rtabmap` node itself: appearance-based loop closure detection, graph optimization, memory management and map assembly. | +| `rtabmap_odom` | Odometry nodes — `rgbd_odometry`, `stereo_odometry` and `icp_odometry`. Any external odometry can be used instead. | +| `rtabmap_sync` | Synchronizes camera and lidar topics into a single message so they reach the SLAM node together — `rgbd_sync`, `stereo_sync`, `rgbdx_sync`. | + +### Sensor processing + +| Package | Description | +|---|---| +| [`rtabmap_util`](rtabmap_util/README.md) | Utility nodes around the pipeline: format conversions, point cloud filtering and assembly, obstacle detection, map assembly, database replay. Most are useful on their own. | +| `rtabmap_costmap_plugins` | A variant of nav2's voxel layer that follows the robot along z, keeping the voxel grid centered on the base frame. For robots that change altitude, e.g. drones. | + +### Interfaces and libraries + +| Package | Description | +|---|---| +| `rtabmap_msgs` | Message, service and action definitions used across the stack. | +| [`rtabmap_conversions`](rtabmap_conversions/README.md) | C++ library converting between RTAB-Map library types and ROS 2 messages. | +| `rtabmap_python` | Python helpers, currently image compression matching RTAB-Map's own format. | + +### Visualization + +| Package | Description | +|---|---| +| `rtabmap_viz` | RTAB-Map's own GUI as a ROS 2 node: live graph, loop closures, feature matches and the parameter panel. | +| `rtabmap_rviz_plugins` | RViz displays for the map graph, the assembled cloud and the SLAM info. | + +### Launch files + +| Package | Description | +|---|---| +| `rtabmap_launch` | `rtabmap.launch.py`, the one-line way to bring up the whole stack. | +| [`rtabmap_examples`](https://github.com/introlab/rtabmap_ros/tree/ros2/rtabmap_examples/launch) | Sensor integration examples: stereo and RGB-D cameras, 3D lidar. | +| [`rtabmap_demos`](rtabmap_demos/README.md) | Full robot demos: turtlebot3 and turtlebot4, nav2 integration, multi-session mapping. | + +# Installation + +These instructions are for ROS 2. For ROS 1, follow the [installation instructions](https://github.com/introlab/rtabmap_ros/tree/master#installation) on the [`master`](https://github.com/introlab/rtabmap_ros/tree/master) branch, which also carries the latest version for Noetic. ### Binaries + ```bash sudo apt install ros-$ROS_DISTRO-rtabmap-ros ``` ### From Source + * Make sure to uninstall any rtabmap binaries: ``` sudo apt remove ros-$ROS_DISTRO-rtabmap* @@ -103,3 +100,39 @@ sudo apt install ros-$ROS_DISTRO-rtabmap-ros colcon build --symlink-install --cmake-args -DRTABMAP_SYNC_MULTI_RGBD=ON -DRTABMAP_SYNC_USER_DATA=ON -DCMAKE_BUILD_TYPE=Release ``` +# Usage + +* For sensor integration examples (stereo and RGB-D cameras, 3D LiDAR), see [rtabmap_examples](https://github.com/introlab/rtabmap_ros/tree/ros2/rtabmap_examples/launch) sub-folder. + +* For robot integration examples (turtlebot3 and turtlebot4, nav2 integration), see [rtabmap_demos](https://github.com/introlab/rtabmap_ros/tree/ros2/rtabmap_demos) sub-folder. + +## Logging +To make RTAB-Map's logs appear ordered with RCLCPP's logs, set the following environment variables in your `.bashrc` (see official "[About Logging](https://docs.ros.org/en/jazzy/Concepts/Intermediate/About-Logging.html)" documentation for more info): +```bash +export RCUTILS_LOGGING_USE_STDOUT=1 +export RCUTILS_LOGGING_BUFFERED_STREAM=1 +# Optional, but if you like colored logs: +export RCUTILS_COLORIZED_OUTPUT=1 +``` + +## Recommended DDS +If RTAB-Map's GUI or topic frequency feel laggy (even if processing time looks fast enough), it may be caused by the DDS. I recommend to use [Cyclone DDS](https://docs.ros.org/en/jazzy/Installation/RMW-Implementations/DDS-Implementations/Working-with-Eclipse-CycloneDDS.html), you can try it by adding this before launching any nodes/launch files (or add to your `.bashrc`): +```bash +export RMW_IMPLEMENTATION=rmw_cyclonedds_cpp +# Cyclone prefers multicast by default, if your router got too much spammed, +# disable multicast with (https://github.com/ros2/rmw_cyclonedds/issues/489): +export CYCLONEDDS_URI="0.0.0.0" +``` + +# Documentation + +* **Package documentation** — the tables above, and the [API reference on docs.ros.org](https://docs.ros.org/en/jazzy/p/rtabmap_ros/). +* **Examples** — [rtabmap_examples](https://github.com/introlab/rtabmap_ros/tree/ros2/rtabmap_examples/launch) for sensors, [rtabmap_demos](rtabmap_demos/README.md) for full robots. +* **Parameters** — every `Rtabmap/*`, `Grid/*`, `Odom/*` and other core parameter is listed in the [RTAB-Map parameter reference](https://introlab.github.io/rtabmap/api/latest/parameters.html). +* **Library API** — [RTAB-Map's own API documentation](https://introlab.github.io/rtabmap/api/latest/). +* **Papers and videos** — [introlab.github.io/rtabmap](https://introlab.github.io/rtabmap/). +* **Old tutorials** — the [ROS 1 wiki](http://wiki.ros.org/rtabmap_ros/Tutorials), for anything not covered above; parameters and topic names are unchanged. + +# License + +BSD-3-Clause, see [LICENSE](LICENSE). RTAB-Map itself may be built with components under other licenses; see the [rtabmap](https://github.com/introlab/rtabmap) repository. diff --git a/codecov.yml b/codecov.yml new file mode 100644 index 00000000..4fec39d4 --- /dev/null +++ b/codecov.yml @@ -0,0 +1,77 @@ +# Codecov configuration -- https://docs.codecov.com/docs/codecov-yaml +# +# Coverage data is produced by .github/workflows/coverage.yml (colcon's +# coverage-gcc mixin over the packages that have tests, aggregated by +# colcon-lcov-result) and uploaded by codecov/codecov-action; this file only +# controls what Codecov reports back on a pull request. Nothing is posted +# unless the Codecov GitHub App has access to the repository. + +# action-ros-ci checks the repository out into a colcon workspace, so every +# path in the lcov report is prefixed. Strip it, otherwise Codecov cannot match +# a file to the one in the diff and reports no coverage at all. +fixes: + - "ros_ws/src/rtabmap_ros/::" + +# Mark uncovered added lines inline in the "Files changed" tab. +github_checks: + annotations: true + +coverage: + precision: 2 + round: down + range: "10...90" # red/green scale: 10% is fully red, 90% fully green + + status: + # Catch a slow slide down without pinning an absolute number. + project: + default: + target: auto + threshold: 1% + + # Coverage of the lines this pull request touches. Advisory: reported, but + # does not block the merge -- drop "informational" to make it gate. + patch: + default: + informational: true + +# Per-package breakdown, computed from the same single upload -- no extra job +# and no separate flag upload per package. Each component gets its own line in +# the pull request comment and its own status check. +component_management: + default_rules: + statuses: + - type: project + target: auto + threshold: 1% + individual_components: + - component_id: rtabmap_conversions + name: rtabmap_conversions + paths: + - rtabmap_conversions/** + - component_id: rtabmap_util + name: rtabmap_util + paths: + - rtabmap_util/** + +comment: + layout: "condensed_header, diff, components, files" + behavior: default + require_changes: true # stay quiet when coverage doesn't move + +# Only rtabmap_conversions and rtabmap_util have tests today, so everything +# else would report as 0% and drag the total down to a number that says +# nothing. As a package gains tests, drop its line here and add it to +# individual_components above. +ignore: + - "rtabmap_costmap_plugins/**" + - "rtabmap_demos/**" + - "rtabmap_examples/**" + - "rtabmap_launch/**" + - "rtabmap_msgs/**" + - "rtabmap_odom/**" + - "rtabmap_python/**" + - "rtabmap_rviz_plugins/**" + - "rtabmap_slam/**" + - "rtabmap_sync/**" + - "rtabmap_viz/**" + - "**/test/**" diff --git a/rtabmap_conversions/README.md b/rtabmap_conversions/README.md index 9b187a02..98265a7b 100644 --- a/rtabmap_conversions/README.md +++ b/rtabmap_conversions/README.md @@ -46,7 +46,7 @@ The naming is uniform: `xxxFromROS()` converts a message into an RTAB-Map type, | Graph | `mapDataFromROS`, `mapGraphFromROS`, `nodeFromROS`, `linkFromROS`, `sensorDataFromROS` (+ `ToROS` variants) | | Misc | `infoFromROS`, `odomInfoFromROS`, `odomInfoToStatistics`, `imuFromROS`, `userDataFromROS`, `envSensorFromROS`, `landmarksFromROS`, `timestampFromROS`, `timestampToROS` | -Full signatures and per-function notes are in the [API documentation](https://docs.ros.org/en/rolling/p/rtabmap_conversions/) and in [`MsgConversion.h`](https://github.com/introlab/rtabmap_ros/blob/ros2/rtabmap_conversions/include/rtabmap_conversions/MsgConversion.h). +Full signatures and per-function notes are in the [API documentation](https://docs.ros.org/en/jazzy/p/rtabmap_conversions/) and in [`MsgConversion.h`](https://github.com/introlab/rtabmap_ros/blob/ros2/rtabmap_conversions/include/rtabmap_conversions/MsgConversion.h). ## Conventions worth knowing @@ -66,7 +66,7 @@ colcon test-result --verbose ## Documentation -API documentation is generated with [rosdoc2](https://github.com/ros-infrastructure/rosdoc2) from the Doxygen comments in the public header, and published to [docs.ros.org](https://docs.ros.org/en/rolling/p/rtabmap_conversions/). To build it locally: +API documentation is generated with [rosdoc2](https://github.com/ros-infrastructure/rosdoc2) from the Doxygen comments in the public header, and published to [docs.ros.org](https://docs.ros.org/en/jazzy/p/rtabmap_conversions/). To build it locally: ```bash rosdoc2 build --package-path rtabmap_conversions --output-directory doc_output diff --git a/rtabmap_conversions/include/rtabmap_conversions/MsgConversion.h b/rtabmap_conversions/include/rtabmap_conversions/MsgConversion.h index a87aa2a3..dbaf94df 100644 --- a/rtabmap_conversions/include/rtabmap_conversions/MsgConversion.h +++ b/rtabmap_conversions/include/rtabmap_conversions/MsgConversion.h @@ -176,7 +176,9 @@ void toCvCopy(const rtabmap_msgs::msg::RGBDImage & image, cv_bridge::CvImagePtr /** * @brief Extract the RGB and depth images of an RGBDImage message without copying. * - * The returned images alias the message's buffers, so @p image must outlive them. + * The returned images alias the message's buffers, so @p image must outlive them. Both + * output pointers are always valid; they hold an empty image when the corresponding + * field is not set. * * @param[in] image the message to read; its shared pointer keeps the buffers alive * @param[out] rgb the RGB image @@ -186,6 +188,10 @@ void toCvShare(const rtabmap_msgs::msg::RGBDImage::ConstSharedPtr & image, cv_br /** * @brief Extract the RGB and depth images of an RGBDImage message without copying. + * + * Both output pointers are always valid; they hold an empty image when the corresponding + * field is not set. + * * @param[in] image the message to read * @param[in] trackedObject object whose lifetime keeps the message buffers alive * @param[out] rgb the RGB image @@ -219,8 +225,12 @@ void rgbdImageToROS(const rtabmap::SensorData & data, rtabmap_msgs::msg::RGBDIma * The stamp is taken from the top-level `image->header`, and the camera's local * transform is not carried by the message (callers resolve it from TF). * + * The depth image is optional: a message carrying only the color image and its camera + * info gives a SensorData with no depth, which is valid. + * * @param image the message to convert - * @return the converted sensor data + * @return the converted sensor data, empty (SensorData::isValid() false) if the message + * carries no color image or an unsupported encoding * * @warning The returned SensorData does **not** copy the pixels: it points into the * message's own buffers. @p image must therefore outlive it and must not be @@ -772,7 +782,7 @@ bool convertRGBDMsgs( /** * @brief Convert a stereo pair into RTAB-Map inputs. * - * The left image keeps its colour; the right image is always reduced to mono. + * The left image keeps its color; the right image is always reduced to mono. * * @param leftImageMsg left image * @param rightImageMsg right image diff --git a/rtabmap_conversions/src/MsgConversion.cpp b/rtabmap_conversions/src/MsgConversion.cpp index e252a617..1f2c4685 100644 --- a/rtabmap_conversions/src/MsgConversion.cpp +++ b/rtabmap_conversions/src/MsgConversion.cpp @@ -255,6 +255,11 @@ void toCvShare(const rtabmap_msgs::msg::RGBDImage & image, const std::shared_ptr depth = ptr; } } + else + { + // empty + depth = std::make_shared(); + } } catch(cv::Exception& e) { UFATAL("Fatal error while converting rgbd image (do you have multiple opencv versions? if so, make sure cv_bridge is loading the right opencv libraries on runtime): %s", e.what()); @@ -432,7 +437,11 @@ rtabmap::SensorData rgbdImageFromROS(const rtabmap_msgs::msg::RGBDImage::ConstSh int depthWidth = depthMsg->image.cols; int depthHeight = depthMsg->image.rows; + // The depth image is optional: a message can legitimately carry only the color + // image and its camera info. Compare the resolutions only when there is a depth + // image, otherwise the ratios divide by zero. UASSERT_MSG( + depthMsg->image.empty() || imageWidth/depthWidth == imageHeight/depthHeight, uFormat("rgb=%dx%d depth=%dx%d", imageWidth, imageHeight, depthWidth, depthHeight).c_str()); @@ -448,7 +457,8 @@ rtabmap::SensorData rgbdImageFromROS(const rtabmap_msgs::msg::RGBDImage::ConstSh imageMsg->encoding.compare(sensor_msgs::image_encodings::BGRA8) == 0 || imageMsg->encoding.compare(sensor_msgs::image_encodings::RGBA8) == 0 || imageMsg->encoding.compare(sensor_msgs::image_encodings::BAYER_GRBG8) == 0) || - !(depthMsg->encoding.compare(sensor_msgs::image_encodings::TYPE_16UC1) == 0 || + !(depthMsg->image.empty() || + depthMsg->encoding.compare(sensor_msgs::image_encodings::TYPE_16UC1) == 0 || depthMsg->encoding.compare(sensor_msgs::image_encodings::TYPE_32FC1) == 0 || depthMsg->encoding.compare(sensor_msgs::image_encodings::MONO16) == 0)) { @@ -2707,7 +2717,7 @@ bool convertScanMsg( scan2dMsg.header.frame_id, odomFrameId.empty()?frameId:odomFrameId, rclcpp::Time(scan2dMsg.header.stamp.sec, scan2dMsg.header.stamp.nanosec), - rclcpp::Time(scan2dMsg.header.stamp.sec, scan2dMsg.header.stamp.nanosec) + rclcpp::Duration::from_seconds(scan2dMsg.ranges.size()*scan2dMsg.time_increment), + rclcpp::Time(scan2dMsg.header.stamp.sec, scan2dMsg.header.stamp.nanosec) + rclcpp::Duration::from_seconds((scan2dMsg.ranges.empty()?0:scan2dMsg.ranges.size()-1)*scan2dMsg.time_increment), tfBuffer, waitForTransform); if(tmpT.isNull()) diff --git a/rtabmap_conversions/test/test_msg_conversion.cpp b/rtabmap_conversions/test/test_msg_conversion.cpp index 0b911720..e9033389 100644 --- a/rtabmap_conversions/test/test_msg_conversion.cpp +++ b/rtabmap_conversions/test/test_msg_conversion.cpp @@ -1756,7 +1756,7 @@ TEST(MsgConversion, deskewConstantVelocityHeaderAtLastPoint) EXPECT_NEAR(readField(out, i, 0), expected, 1e-4) << "x of point " << i; } - // The line is flat to well under a millimetre: that is the deskewing working, + // The line is flat to well under a millimeter: that is the deskewing working, // independently of which end of the scan the frame is anchored to. float minX = readField(out, 0, 0); float maxX = minX; @@ -2011,7 +2011,7 @@ TEST(MsgConversion, deskewClampsSamplesOutsideTheSweep) ASSERT_TRUE(deskew(in, out, rtabmap::Transform(kSpeed, 0, 0, 0, 0, 0))); // Clamped to the last sample's correction, so it lands within the sweep's own range - // rather than metres away. Every other sample is unaffected. + // rather than meters away. Every other sample is unaffected. const float x = readWallX(out, corrupt, 0, kTimeOnColumns); EXPECT_GE(x, kWallDistance - 1e-3f); EXPECT_LE(x, kWallDistance + float(kSpeed * kScanSpan) + 1e-3f) @@ -2224,6 +2224,57 @@ TEST(MsgConversion, toCvShareReadsCompressedDepth) EXPECT_EQ(cv::countNonZero(depthPtr->image != depth), 0); } +TEST(MsgConversion, toCvShareOnAnEmptyMessageGivesEmptyImages) +{ + // Both pointers must be valid even when the message carries nothing: callers such as + // rgbdImageFromROS() dereference them unconditionally. + const rtabmap_msgs::msg::RGBDImage msg; + + cv_bridge::CvImageConstPtr rgbPtr, depthPtr; + toCvShare(msg, std::shared_ptr(), rgbPtr, depthPtr); + + ASSERT_TRUE(rgbPtr); + ASSERT_TRUE(depthPtr); + EXPECT_TRUE(rgbPtr->image.empty()); + EXPECT_TRUE(depthPtr->image.empty()); +} + +TEST(MsgConversion, rgbdImageFromROSOnAnEmptyMessageIsInvalid) +{ + rtabmap_msgs::msg::RGBDImage::SharedPtr msg = + std::make_shared(); + msg->header.frame_id = "camera_link"; + msg->header.stamp = rclcpp::Time(1000, 0, RCL_ROS_TIME); + + const rtabmap::SensorData data = rgbdImageFromROS(msg); + + EXPECT_FALSE(data.isValid()) << "an empty message must give empty data, not a crash"; +} + +TEST(MsgConversion, rgbdImageFromROSWithoutDepthKeepsTheColorImage) +{ + // The depth image is optional: color plus camera info is a valid message, and the + // resolution check must not divide by the zero depth width. + rtabmap_msgs::msg::RGBDImage::SharedPtr msg = + std::make_shared(); + msg->header.frame_id = "camera_link"; + msg->header.stamp = rclcpp::Time(1000, 0, RCL_ROS_TIME); + cv::Mat rgb(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); + cv_bridge::CvImage(std_msgs::msg::Header(), "bgr8", rgb).toImageMsg(msg->rgb); + msg->rgb_camera_info.width = 8; + msg->rgb_camera_info.height = 8; + msg->rgb_camera_info.k = {525.0, 0.0, 4.0, 0.0, 525.0, 4.0, 0.0, 0.0, 1.0}; + + const rtabmap::SensorData data = rgbdImageFromROS(msg); + + EXPECT_TRUE(data.isValid()); + ASSERT_FALSE(data.imageRaw().empty()); + EXPECT_EQ(data.imageRaw().at(0, 0), cv::Vec3b(10, 20, 30)); + EXPECT_TRUE(data.depthRaw().empty()); + ASSERT_EQ(data.cameraModels().size(), 1u); + EXPECT_NEAR(data.cameraModels()[0].fx(), 525.0, 1e-9); +} + TEST(MsgConversion, toCvCopyReadsCompressedRgb) { const cv::Mat rgb(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); @@ -2990,7 +3041,7 @@ void addOdomMotion(tf2_ros::Buffer & buffer) TEST(MsgConversion, convertRGBDMsgsSyncsToOdomStamp) { // The image is captured at t=1001 but must be expressed relative to the base frame - // at odomStamp=1000, one metre back. + // at odomStamp=1000, one meter back. const std::shared_ptr buffer = makeTfBuffer(); const rtabmap::Transform baseToCamera(0.1f, 0.0f, 0.2f, 0.0f, 0.0f, 0.0f); addTf(*buffer, "base_link", "camera_link", baseToCamera, 1000.0); @@ -3075,7 +3126,7 @@ TEST(MsgConversion, convertRGBDMsgsPrefersTheDepthStampWhenTheyDiffer) { // The RGB and depth stamps of a camera are assumed to be equal. This pins the // tie-break for when they are not: the depth stamp prevails, since it is the one the - // geometry is synchronized to. Not a behaviour to rely on -- a camera whose two + // geometry is synchronized to. Not a behavior to rely on -- a camera whose two // stamps disagree is already outside the contract. const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "camera_link", rtabmap::Transform(0.1f, 0, 0, 0, 0, 0), 1000.0); @@ -3350,25 +3401,25 @@ TEST(MsgConversion, convertStereoMsgProducesAStereoModel) EXPECT_EQ(right.at(0, 0), 50); } -TEST(MsgConversion, convertStereoMsgConvertsColourToMono) +TEST(MsgConversion, convertStereoMsgConvertsColorToMono) { const std::shared_ptr buffer = makeTfBuffer(); addTf(*buffer, "base_link", "left_link", rtabmap::Transform::getIdentity(), 1000.0); - const cv::Mat colour(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); + const cv::Mat color(8, 8, CV_8UC3, cv::Scalar(10, 20, 30)); const cv::Mat mono(8, 8, CV_8UC1, cv::Scalar(50)); cv::Mat left, right; rtabmap::StereoCameraModel model; ASSERT_TRUE(convertStereoMsg( - makeImage("left_link", 1000.0, colour, "bgr8"), + makeImage("left_link", 1000.0, color, "bgr8"), makeImage("right_link", 1000.0, mono, "mono8"), makeCameraInfo("left_link", 1000.0, 8, 8, 0.0), makeCameraInfo("right_link", 1000.0, 8, 8, -15.0), "base_link", "", timestampToROS(1000.0), left, right, model, *buffer, 0.0, true)); - // The left image is kept in colour; the right is always reduced to mono. + // The left image is kept in color; the right is always reduced to mono. EXPECT_EQ(left.type(), CV_8UC3); EXPECT_EQ(right.type(), CV_8UC1); } diff --git a/rtabmap_odom/src/nodelets/icp_odometry.cpp b/rtabmap_odom/src/nodelets/icp_odometry.cpp index 581b5316..2281e7fb 100644 --- a/rtabmap_odom/src/nodelets/icp_odometry.cpp +++ b/rtabmap_odom/src/nodelets/icp_odometry.cpp @@ -313,7 +313,7 @@ void ICPOdometry::callbackScan(const sensor_msgs::msg::LaserScan::SharedPtr scan scanMsg->header.frame_id, guessFrameId().empty()?frameId():guessFrameId(), scanMsg->header.stamp, - rclcpp::Time(scanMsg->header.stamp.sec, scanMsg->header.stamp.nanosec) + rclcpp::Duration::from_seconds(scanMsg->ranges.size()*scanMsg->time_increment), + rclcpp::Time(scanMsg->header.stamp.sec, scanMsg->header.stamp.nanosec) + rclcpp::Duration::from_seconds((scanMsg->ranges.empty()?0:scanMsg->ranges.size()-1)*scanMsg->time_increment), this->tfBuffer(), this->waitForTransform()); if(tmpT.isNull()) diff --git a/rtabmap_util/CMakeLists.txt b/rtabmap_util/CMakeLists.txt index 718e0456..9c287b4a 100644 --- a/rtabmap_util/CMakeLists.txt +++ b/rtabmap_util/CMakeLists.txt @@ -300,4 +300,41 @@ install(DIRECTORY include/ FILES_MATCHING PATTERN "*.h" ) +############# +## Testing ## +############# +if(BUILD_TESTING) + find_package(ament_cmake_gtest REQUIRED) + + # Each node gets its own test binary: a crash or a stuck executor in one node cannot + # take the others down, and every binary starts with a clean DDS graph. + macro(rtabmap_util_add_node_test test_name) + ament_add_gtest(${test_name} test/${test_name}.cpp) + if(TARGET ${test_name}) + target_include_directories(${test_name} PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}/test) + target_link_libraries(${test_name} rtabmap_util_plugins rtabmap_util) + if("$ENV{ROS_DISTRO}" STRLESS "lyrical") + ament_target_dependencies(${test_name} ${AmentLibraries}) + else() + target_link_libraries(${test_name} ${Libraries} ${PublicLibraries}) + endif() + endif() + endmacro() + + rtabmap_util_add_node_test(test_imu_to_tf) + rtabmap_util_add_node_test(test_disparity_to_depth) + rtabmap_util_add_node_test(test_rgbd_relay) + rtabmap_util_add_node_test(test_rgbd_split) + rtabmap_util_add_node_test(test_lidar_deskewing) + rtabmap_util_add_node_test(test_obstacles_detection) + rtabmap_util_add_node_test(test_point_cloud_aggregator) + rtabmap_util_add_node_test(test_point_cloud_assembler) + rtabmap_util_add_node_test(test_point_cloud_xyz) + rtabmap_util_add_node_test(test_pointcloud_to_depthimage) + rtabmap_util_add_node_test(test_point_cloud_xyzrgb) + rtabmap_util_add_node_test(test_db_player) + rtabmap_util_add_node_test(test_maps_manager) + rtabmap_util_add_node_test(test_map_assembler) +endif() + ament_package() diff --git a/rtabmap_util/README.md b/rtabmap_util/README.md new file mode 100644 index 00000000..8eccf4b5 --- /dev/null +++ b/rtabmap_util/README.md @@ -0,0 +1,66 @@ +# rtabmap_util + +Standalone utility nodes for [RTAB-Map](https://github.com/introlab/rtabmap) pipelines: converting between sensor representations, cleaning up point clouds, assembling maps and replaying recorded sessions. + +Every node is a [composable node](https://docs.ros.org/en/jazzy/Tutorials/Intermediate/Composition.html) as well as a standalone executable. Composing them into one process with their producer avoids copying images and clouds between processes, which is worth doing for anything on the sensor path. + +## Nodes + +One page per node. + +**Sensor conversion** + +| Node | Description | +|---|---| +| [disparity_to_depth](doc/disparity_to_depth.md) | Disparity image → depth image, in meters and in millimeters. | +| [pointcloud_to_depthimage](doc/pointcloud_to_depthimage.md) | Point cloud → depth image registered to an RGB camera. Lets a lidar feed an RGB-D pipeline. | +| [point_cloud_xyz](doc/point_cloud_xyz.md) | Depth or disparity image → point cloud, with filtering. | +| [point_cloud_xyzrgb](doc/point_cloud_xyzrgb.md) | RGB-D, stereo or disparity → colored point cloud. | +| [imu_to_tf](doc/imu_to_tf.md) | IMU orientation → TF. | + +**RGBDImage plumbing** + +| Node | Description | +|---|---| +| [rgbd_relay](doc/rgbd_relay.md) | Republishes an `RGBDImage`, compressing or decompressing on the way. | +| [rgbd_split](doc/rgbd_split.md) | Splits an `RGBDImage` back into standard `Image` and `CameraInfo` topics. | + +**Point cloud processing** + +| Node | Description | +|---|---| +| [lidar_deskewing](doc/lidar_deskewing.md) | Removes motion distortion from a lidar sweep. | +| [point_cloud_aggregator](doc/point_cloud_aggregator.md) | Merges one cloud from each of several sensors into one. | +| [point_cloud_assembler](doc/point_cloud_assembler.md) | Accumulates one sensor over time into a denser cloud. | +| [obstacles_detection](doc/obstacles_detection.md) | Segments a cloud into ground and obstacles. | + +**Maps and replay** + +| Node | Description | +|---|---| +| [map_assembler](doc/map_assembler.md) | Rebuilds the global maps from RTAB-Map's graph, off the SLAM node's critical path. | +| [db_player](doc/db_player.md) | Replays a recorded RTAB-Map database as live sensor topics. | + +## Library + +The package also installs a small C++ library, whose API is documented in the [C++ API reference](https://docs.ros.org/en/jazzy/p/rtabmap_util/generated/index.html) generated from the headers. + +`MapsManager` is the piece worth knowing about: it turns a pose graph plus per-node occupancy grids into the assembled clouds, occupancy grid, octomap and elevation map, and publishes them. Both [map_assembler](doc/map_assembler.md) and `rtabmap_slam`'s `rtabmap` node use it, which is why their map outputs and `Grid/*` parameters behave identically. + +## Conventions + +A few things recur across these nodes. + +**`qos` parameters.** Most nodes expose a `qos` integer selecting the reliability of their subscriptions: `0` system default, `1` reliable, `2` best effort. It has to be compatible with the publisher or **no messages arrive at all** and nothing says why. Sensor drivers commonly publish best effort. + +**`approx_sync`.** Nodes taking several inputs match them by nearest stamp by default. Set it to `false` when the inputs are hardware-synchronized and carry identical stamps: the exact policy is cheaper and cannot mismatch. With approximate sync, `approx_sync_max_interval` is worth setting as a guard against silently pairing stale data. + +**`fixed_frame_id`.** Where a node has to account for the robot moving between two stamps, it does so by asking TF how a frame moved relative to a fixed one — usually `odom`. Leaving it empty disables the compensation rather than erroring, so a moving robot then gets subtly misplaced data. + +**`Grid/*` parameters.** Nodes that segment or assemble maps use RTAB-Map's own [`LocalGridMaker`](https://introlab.github.io/rtabmap/api/latest/classrtabmap_1_1LocalGridMaker.html), and expose its parameters directly under their RTAB-Map names. Their meanings and defaults are in RTAB-Map's [parameter reference](https://introlab.github.io/rtabmap/api/latest/parameters.html), which is the source of truth for them. One to know about: `Grid/RangeMax` is not unlimited by default, so distant points are dropped before anything else happens. + +## Building the documentation + +```bash +rosdoc2 build --package-path rtabmap_util --output-directory doc_output +``` diff --git a/rtabmap_util/doc/db_player.md b/rtabmap_util/doc/db_player.md new file mode 100644 index 00000000..1dfae23d --- /dev/null +++ b/rtabmap_util/doc/db_player.md @@ -0,0 +1,117 @@ +# db_player + +Replays a recorded RTAB-Map database as live sensor topics. + +Point it at a `.db` file and it publishes the images, scans, odometry and transforms that were recorded into it, at the rate they were captured. Everything downstream sees a running robot. + +That makes it the tool for offline work: re-run SLAM with different parameters on the same data, debug a failure you cannot reproduce on the robot, or develop a node without hardware. Unlike a rosbag, the database is what RTAB-Map itself wrote, so it is always available after a mapping session. + +> **The executable is named `data_player`**, not `db_player`. The composable node is `rtabmap_util::DbPlayer`. + +## Usage + +```bash +ros2 run rtabmap_util data_player --ros-args \ + -p database:=~/.ros/rtabmap.db \ + -p rate:=1.0 \ + -p frame_id:=base_link +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::DbPlayer', + name='db_player', + parameters=[{'database': '/path/to/rtabmap.db', 'rate': 1.0, + 'frame_id': 'base_link'}]) +``` + +## Published Topics + +**Which topics exist depends on what the database contains.** The node inspects the first frame and only advertises what it can actually publish, so a lidar-only database has no image topics at all. + +| Topic | Type | Published when | +|---|---|---| +| `rgb/image`, `rgb/camera_info` | [`Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html), [`CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | Single RGB-D camera. | +| `depth/image`, `depth/camera_info` | `Image`, `CameraInfo` | Single RGB-D camera. | +| `left/image`, `left/camera_info` | `Image`, `CameraInfo` | Single stereo pair. | +| `right/image`, `right/camera_info` | `Image`, `CameraInfo` | Single stereo pair. | +| `image` | `Image` | Images with no calibration. | +| `rgbd_image0`, `rgbd_image1`, … | [`RGBDImage`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/RGBDImage.html) | Multiple RGB-D cameras, one topic each. | +| `stereo_image0`, `stereo_image1`, … | `RGBDImage` | Multiple stereo pairs, one topic each. | +| `scan` | [`LaserScan`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/LaserScan.html) | A 2D laser scan. | +| `scan_cloud` | [`PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | A 3D laser scan. | +| `odom` | [`Odometry`](https://docs.ros.org/en/jazzy/p/nav_msgs/msg/Odometry.html) | Odometry poses, with their covariance. | +| `imu` | [`Imu`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Imu.html) | Gravity was recorded. Orientation only. | +| `global_pose` | [`PoseWithCovarianceStamped`](https://docs.ros.org/en/jazzy/p/geometry_msgs/msg/PoseWithCovarianceStamped.html) | A prior pose was recorded. | +| `gps/fix` | [`NavSatFix`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/NavSatFix.html) | GPS was recorded. | +| `env_sensor` | [`EnvSensor`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/EnvSensor.html) | Environmental sensors were recorded. | +| `/clock` | [`Clock`](https://docs.ros.org/en/jazzy/p/rosgraph_msgs/msg/Clock.html) | `publish_clock` is set. See [Simulated time](#simulated-time). | + +Everything except `/tf` and `/clock` is published only when it has a subscriber. + +## Published Transforms + +Broadcast on every frame unless `publish_tf` is false. + +| Transform | Published when | +|---|---| +| `odom_frame_id` → `frame_id` | Odometry is available. | +| `frame_id` → `camera_frame_id` | A camera is calibrated. Multi-camera setups get a numeric suffix; stereo gets `left_`/`right_` prefixes, with the right frame offset by the baseline. | +| `frame_id` → `scan_frame_id` | A scan is present. | +| `frame_id` → `imu_frame_id` | An IMU is present. | +| `ground_truth_frame_id` → `ground_truth_base_frame_id` | Ground truth was recorded. | + +## Services + +| Service | Type | Description | +|---|---|---| +| `~/pause` | [`std_srvs/srv/Empty`](https://docs.ros.org/en/jazzy/p/std_srvs/srv/Empty.html) | Pause playback. | +| `~/resume` | `std_srvs/srv/Empty` | Resume it. | + +When run as the standalone executable, the **space bar** toggles pause as well. + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `database` | `string` | `""` | **Required.** Path to the `.db` file. `~` is expanded, relative paths resolve against the working directory. The node throws on start-up if it is unset or unreadable. | +| `rate` | `double` | `1.0` | Playback speed as a multiple of the recorded rate. `2.0` is twice as fast, `0.5` half. | +| `start_id` | `int` | `0` | Skip to this node id. `0` starts at the beginning. | +| `ignore_odom` | `bool` | `false` | Do not publish odometry or its transform, so you can run your own odometry against the raw sensor data. | +| `publish_tf` | `bool` | `true` | Broadcast the transforms above. Turn it off if a robot state publisher already provides them. | +| `publish_clock` | `bool` | `false` | Publish `/clock`. See [Simulated time](#simulated-time). | +| `frame_id` | `string` | `"base_link"` | Robot base frame. | +| `odom_frame_id` | `string` | `"odom"` | Odometry frame. | +| `camera_frame_id` | `string` | `"camera_optical_link"` | Camera optical frame. | +| `scan_frame_id` | `string` | `"base_laser_link"` | Lidar frame. | +| `imu_frame_id` | `string` | `"imu_link"` | IMU frame. | +| `ground_truth_frame_id` | `string` | `"world"` | Ground truth parent frame. | +| `ground_truth_base_frame_id` | `string` | `"base_link_gt"` | Ground truth child frame. | +| `qos` | `int` | `0` | Reliability of all publishers unless overridden below. | +| `qos_camera_info`, `qos_odom`, `qos_scan`, `qos_scan_cloud`, `qos_global_pose`, `qos_gps`, `qos_imu`, `qos_env_sensor` | `int` | value of `qos` | Per-topic overrides. | + +**2D scan geometry** — only used when the recorded scan has no angle metadata of its own, which happens for scans converted from a 3D lidar. + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `scan_angle_min` | `double` | `-π` | | +| `scan_angle_max` | `double` | `π` | | +| `scan_angle_increment` | `double` | `π/720` | | +| `scan_range_min` | `double` | `0.0` | | +| `scan_range_max` | `double` | `60.0` | | + +## Simulated time + +With `publish_clock` the node publishes `/clock` from the recorded stamps. Start every other node with `use_sim_time:=true` and the whole system runs on the database's timeline instead of the wall clock, so playback speed no longer affects behavior — a good idea when replaying faster than real time, and essential for reproducible runs. + +```bash +ros2 run rtabmap_util data_player --ros-args -p database:=map.db -p publish_clock:=true +ros2 launch rtabmap_launch rtabmap.launch.py use_sim_time:=true +``` + +## Notes + +Playback ends when the last node has been published, and the standalone executable exits at that point. + +The database is opened read-only as far as playback is concerned, so replaying the same file while RTAB-Map maps into another one is safe. diff --git a/rtabmap_util/doc/disparity_to_depth.md b/rtabmap_util/doc/disparity_to_depth.md new file mode 100644 index 00000000..f8cc9ea7 --- /dev/null +++ b/rtabmap_util/doc/disparity_to_depth.md @@ -0,0 +1,55 @@ +# disparity_to_depth + +Converts a disparity image into a depth image. + +Most of ROS handles depth, while a stereo pipeline produces disparity. This node bridges the two: for every pixel it computes `depth = baseline * focal / disparity`, taking the baseline and focal length from the incoming [`stereo_msgs/msg/DisparityImage`](https://docs.ros.org/en/jazzy/p/stereo_msgs/msg/DisparityImage.html) itself, so no camera info is needed. + +Pixels whose disparity falls outside the message's own `min_disparity`/`max_disparity` are written as zero, which is the ROS convention for "no reading". + +## Usage + +```bash +ros2 run rtabmap_util disparity_to_depth --ros-args \ + -r disparity:=/stereo/disparity \ + -r depth:=/stereo/depth +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::DisparityToDepth', + name='disparity_to_depth', + remappings=[('disparity', '/stereo/disparity'), + ('depth', '/stereo/depth')]) +``` + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `disparity` | [`stereo_msgs/msg/DisparityImage`](https://docs.ros.org/en/jazzy/p/stereo_msgs/msg/DisparityImage.html) | The disparity image must be `32FC1`; anything else is rejected with an error. | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `depth` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) (`32FC1`) | Depth in **meters**. | +| `depth_raw` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) (`16UC1`) | The same depth in **millimeters**, the compact form most RGB-D drivers publish. | + +Both are computed only if something is subscribed to them, so leaving one unused costs nothing. Both keep the header of the input disparity image. + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `qos` | `int` | `0` | Reliability of both sides: `0` system default, `1` reliable, `2` best effort. | +| `qos_sub` | `int` | value of `qos` | Reliability of the `disparity` subscription alone. | +| `qos_pub` | `int` | value of `qos` | Reliability of the `depth` and `depth_raw` publishers alone. | +| `queue_sub` | `int` | `1` | Queue depth of the `disparity` subscription. Must be at least 1. | +| `queue_pub` | `int` | `1` | Queue depth of both publishers. Must be at least 1. | + +## Notes + +Depth beyond 65.535 m cannot be represented in the `16UC1` output and wraps around; use the `32FC1` `depth` topic for long-range stereo. + +This node performs no filtering or hole-filling. A noisy disparity image gives a noisy depth image. diff --git a/rtabmap_util/doc/imu_to_tf.md b/rtabmap_util/doc/imu_to_tf.md new file mode 100644 index 00000000..8802e830 --- /dev/null +++ b/rtabmap_util/doc/imu_to_tf.md @@ -0,0 +1,197 @@ +# imu_to_tf + +Broadcasts the orientation of an IMU as a TF transform. + +The node subscribes to a `sensor_msgs/msg/Imu` topic, takes the `orientation` field and broadcasts it on `/tf` as the rotation of `fixed_frame_id` → the IMU frame. Set `base_frame_id` and that frame becomes the child instead, with the orientation re-expressed in it from the IMU's mounting, so the transform says how the *robot* is oriented rather than how the sensor is. Either way `fixed_frame_id` is the parent, and nothing else of the message is used: the transform's translation is always zero, and the angular velocity and linear acceleration are ignored. + +It exists so that a consumer that needs an oriented frame — a lidar deskewing node, a point cloud assembler, RViz — can get one from an IMU alone, without running odometry. + +## Usage + +As a standalone node: + +```bash +ros2 run rtabmap_util imu_to_tf --ros-args \ + -r imu/data:=/imu \ + -p fixed_frame_id:=odom +``` + +As a composable node, in the same process as its producer or consumer: + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::ImuToTF', + name='imu_to_tf', + parameters=[{'fixed_frame_id': 'odom'}], + remappings=[('imu/data', '/imu')]) +``` + +### When the IMU has no orientation + +The node reads `orientation` and nothing else, and many IMUs do not fill it in — they publish only angular velocity and linear acceleration. Fuse them into an orientation first, with a filter such as [`imu_filter_madgwick`](https://github.com/CCNYRoboticsLab/imu_tools), and point this node at the filter's output: + +```python +Node( + package='imu_filter_madgwick', executable='imu_filter_madgwick_node', + parameters=[{'use_mag': False, 'world_frame': 'enu', 'publish_tf': False}], + remappings=[('imu/data_raw', '/camera/imu')]), # publishes /imu/data + +Node( + package='rtabmap_util', executable='imu_to_tf', + parameters=[{'fixed_frame_id': 'odom'}], + remappings=[('imu/data', '/imu/data')]), +``` + +Set `publish_tf: False` on the filter. It can broadcast a transform of its own, and two nodes publishing orientation for the same frame is exactly the conflict described in [Notes](#notes). `use_mag: False` keeps it off the magnetometer, which is rarely trustworthy indoors or near motors. + +A quick way to tell whether you need the filter at all: + +```bash +ros2 topic echo /camera/imu --field orientation --once +``` + +All zeros, or an `orientation_covariance` whose first element is `-1`, means the driver is not estimating orientation and this node has nothing to publish. + +### A stabilized frame for lidar deskewing and odometry + +The way it is used in [`rtabmap_examples/launch/lidar3d.launch.py`](https://github.com/introlab/rtabmap_ros/blob/ros2/rtabmap_examples/launch/lidar3d.launch.py). A 3D lidar needs a fixed frame to deskew against and ICP odometry benefits from a motion guess, but before odometry is running there is no `odom` frame to use. An IMU can supply one — for rotation. + +Point `fixed_frame_id` at a frame that does not exist anywhere else, named after the base frame: + +```python +Node( + package='rtabmap_util', executable='imu_to_tf', + parameters=[{'fixed_frame_id': 'base_link_stabilized', + 'base_frame_id': 'base_link', + 'wait_for_transform_duration': 0.001}], + remappings=[('imu/data', '/imu/data')]) +``` + +This publishes `base_link_stabilized` → `base_link` carrying the robot's orientation and nothing else. Because the node never publishes a translation, `base_link_stabilized` stays glued to the robot and only its *orientation* is meaningful over time: it is a gravity-leveled version of the base frame rather than a world frame. That is exactly what the two consumers need. + +[lidar_deskewing](lidar_deskewing.md) then corrects the rotation of each sweep: + +```python +Node( + package='rtabmap_util', executable='lidar_deskewing', + parameters=[{'fixed_frame_id': 'base_link_stabilized'}], + remappings=[('input_cloud', '/lidar/points')]) +``` + +and ICP odometry takes the same frame as its motion guess, with its own deskewing turned off since it is already done: + +```python +Node( + package='rtabmap_odom', executable='icp_odometry', + parameters=[{'frame_id': 'base_link', + 'odom_frame_id': 'icp_odom', + 'guess_frame_id': 'base_link_stabilized', + 'deskewing': False}], + remappings=[('scan_cloud', '/lidar/points/deskewed')]) +``` + +The three nodes chain into a single TF tree: + +```text +map rtabmap +└── icp_odom icp_odometry + └── base_link_stabilized imu_to_tf + └── base_link + ├── lidar_link robot description (static) + └── imu_link +``` + +| Edge | Published by | +|---|---| +| `map` → `icp_odom` | `rtabmap` | +| `icp_odom` → `base_link_stabilized` | `icp_odometry` | +| `base_link_stabilized` → `base_link` | **this node**, from the IMU orientation | +| `base_link` → `lidar_link`, `imu_link` | your robot description, static | + +Note what `icp_odometry` publishes: because `guess_frame_id` is set it broadcasts the *correction* `icp_odom` → `base_link_stabilized` rather than `icp_odom` → `base_link`. That is what makes the two nodes compose — the stabilized frame slots into the chain and every frame keeps exactly one parent. Without `guess_frame_id` the odometry would publish straight to `base_link` and fight this node over it. + +Only rotation is compensated, and the two errors behave differently over a sweep: + +| Error | How it scales | Worst when | +|---|---|---| +| Rotation, corrected here | grows with range | turning fast, looking far | +| Translation, left over | same at every range, grows with speed | driving fast, looking close | + +Moving slowly, or looking far, the leftover translation stays under the lidar's own range noise and can be ignored. Fast and close it is the bigger of the two, and it shifts the cloud rather than blurring it, so it turns into odometry drift. + +Once something publishes a real `odom` → `base_link` — wheel or visual odometry, or an EKF such as [`robot_localization`](https://github.com/cra-ros-pkg/robot_localization) fusing that same IMU with wheel odometry — point both `fixed_frame_id` and `guess_frame_id` at `odom` instead and drop this node. Translation then gets compensated too. + +### A rotation guess for visual odometry + +The same stabilized frame is useful to a camera, for a different reason. Visual odometry predicts where each feature from the previous frame should land in the current one and searches around that prediction; on a fast rotation the prediction is far off, matches are lost and odometry breaks exactly when the motion is hardest. + +An IMU fixes the prediction. Run this node as above to publish `base_link_stabilized` → `base_link`, then hand that frame to the odometry as its guess: + +```python +Node( + package='rtabmap_odom', executable='rgbd_odometry', + parameters=[{'frame_id': 'base_link', + 'guess_frame_id': 'base_link_stabilized'}], + remappings=[('rgb/image', '/camera/color/image_raw'), + ('depth/image', '/camera/depth/image_rect_raw'), + ('rgb/camera_info', '/camera/color/camera_info')]) +``` + +With [`rtabmap_launch`](https://github.com/introlab/rtabmap_ros/tree/ros2/rtabmap_launch) the same thing is one argument: + +```bash +ros2 launch rtabmap_launch rtabmap.launch.py odom_guess_frame_id:=base_link_stabilized +``` + +The TF chain is the one from the previous section with `rgbd_odometry` in place of `icp_odometry`; it publishes the same `odom` → `base_link_stabilized` correction, so the frames still form one tree. + +A rotation-only guess is enough here, because feature matching cares about where things appear, not where they are. Turning the camera slides every feature across the image by the same amount, near or far. Moving it slides them too, but far less, and less the further away they are — generally little enough to stay inside the window the matcher searches. So rotation is the part a guess has to get right, and that is exactly what the IMU supplies. It is also why an IMU far too drifty to give you a *pose* still makes a good guess: only the rotation over a single frame interval is being used, long before drift has time to accumulate. + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `imu/data` | [`sensor_msgs/msg/Imu`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Imu.html) | Only `orientation` and `header` are read. The subscription has a queue depth of 1; its reliability comes from the `qos` parameter. | + +## Published Topics + +None. The node only broadcasts transforms. + +## Published Transforms + +| Transform | Description | +|---|---| +| `fixed_frame_id` → IMU frame | Broadcast when `base_frame_id` is empty. The child frame is the `header.frame_id` of the incoming message. | +| `fixed_frame_id` → `base_frame_id` | Broadcast when `base_frame_id` is set. The orientation is re-expressed in the base frame first, see [Mounting offset](#mounting-offset). | + +The transform carries a rotation only; its translation is always zero. It is stamped with the IMU message's stamp, not the current time. + +## Required Transforms + +| Transform | Description | +|---|---| +| `base_frame_id` → IMU frame | Only when `base_frame_id` is set and differs from the IMU's `header.frame_id`. This is the fixed mounting of the IMU on the robot, normally published by your robot description. | + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `fixed_frame_id` | `string` | `"odom"` | Parent frame of the broadcast transform. | +| `base_frame_id` | `string` | `""` | Frame to report the orientation in. Empty broadcasts the IMU frame itself, which is the cheapest option when nothing else needs the base frame oriented. | +| `qos` | `int` | `0` | Reliability of the `imu/data` subscription: `0` system default, `1` reliable, `2` best effort. Must match the publisher, or no message arrives. | +| `wait_for_transform_duration` | `double` | `0.1` | Seconds to wait for the `base_frame_id` → IMU transform before giving up on a message. Only used when `base_frame_id` is set. | + +## Mounting offset + +When `base_frame_id` is set, the node looks up the mounting transform `base_frame_id` → IMU frame and re-expresses the orientation in the base frame. The **yaw of the mounting is deliberately discarded**: only its roll and pitch are applied. + +That is what you want from an absolute orientation source. An IMU bolted on facing sideways still measures the same absolute heading as one facing forward, so its yaw must reach the base frame untouched; its roll and pitch, on the other hand, do have to be rotated into the base frame to be meaningful. + +A message is **dropped** — logged as an error, nothing broadcast — if that mounting transform is not available within `wait_for_transform_duration`. + +## Notes + +Only one node may publish a given TF edge. If odometry is already publishing `odom` → `base_link`, do not point this node at the same pair — give it a frame of its own, as in [the stabilized frame above](#a-stabilized-frame-for-lidar-deskewing-and-odometry), or leave `base_frame_id` empty. Two publishers on one edge make the transform flicker between them. + +The node does not integrate or filter anything — whatever orientation the message carries is what gets broadcast. See [When the IMU has no orientation](#when-the-imu-has-no-orientation) if your driver does not estimate one. diff --git a/rtabmap_util/doc/lidar_deskewing.md b/rtabmap_util/doc/lidar_deskewing.md new file mode 100644 index 00000000..c2ecea94 --- /dev/null +++ b/rtabmap_util/doc/lidar_deskewing.md @@ -0,0 +1,95 @@ +# lidar_deskewing + +Removes the motion distortion from a lidar scan. + +A spinning lidar takes tens of milliseconds to complete a sweep, and on a moving robot every point in that sweep is measured from a slightly different pose. The result is a *skewed* cloud: straight walls come out bent, and registration against it drifts. + +This node uses TF to find where the sensor actually was when each point was taken, and moves every point into the pose at the start of the sweep. A straight wall comes back straight. + +## Usage + +```bash +ros2 run rtabmap_util lidar_deskewing --ros-args \ + -p fixed_frame_id:=odom \ + -r input_cloud:=/velodyne_points +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::LidarDeskewing', + name='lidar_deskewing', + parameters=[{'fixed_frame_id': 'odom'}], + remappings=[('input_cloud', '/velodyne_points')]) +``` + +## Subscribed Topics + +Connect one of the two. + +| Topic | Type | Description | +|---|---|---| +| `input_cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | Must carry a **per-point time channel**, see [Requirements](#requirements). | +| `input_scan` | [`sensor_msgs/msg/LaserScan`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/LaserScan.html) | Per-point times come from `time_increment`. | + +## Published Topics + +Output names are derived from the **resolved** input names, so remapping the input moves the output with it. With `input_cloud` remapped to `/velodyne_points` the output is `/velodyne_points/deskewed`. + +| Topic | Type | Description | +|---|---|---| +| `/deskewed` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | The deskewed cloud, same frame and stamp as the input. | +| `/deskewed` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | A `LaserScan` cannot represent a deskewed sweep — the points no longer lie on a regular angular grid — so the scan input also produces a cloud. | + +## Required Transforms + +| Transform | Description | +|---|---| +| `fixed_frame_id` → sensor frame, across the sweep | Must be available for the whole span of the sweep, at both its first and last stamp. | + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `fixed_frame_id` | `string` | `""` | **Required.** Frame the motion is measured against, usually `odom`. | +| `wait_for_transform` | `double` | `0.01` | Seconds to wait for the transforms spanning the sweep. Raise it if odometry lags the lidar. | +| `slerp` | `bool` | `false` | Interpolate between the poses at the start and end of the sweep instead of looking up TF per point. Much cheaper, and accurate enough at constant velocity. | +| `queue_size` | `int` | `1` | Queue depth of the input subscriptions. | +| `qos` | `int` | `0` | Reliability of the input subscriptions: `0` system default, `1` reliable, `2` best effort. | + +## Requirements + +For `input_cloud`, the cloud **must have a per-point time field**. Without one the node cannot know when each point was taken and cannot deskew. + +The field has to be named `t`, `time`, `stamps` or `timestamp` — anything else is not recognized, whatever it contains. Its type decides how the value is read: + +| Type | Meaning | +|---|---| +| `uint32` | nanoseconds since the cloud's own stamp | +| `float32` | seconds since the cloud's own stamp | +| `float64` | an absolute timestamp; seconds, milliseconds, microseconds and nanoseconds are told apart by magnitude | + +Common drivers that satisfy this out of the box: **Ouster** (`t`), **Velodyne** (`time`), **RoboSense** (`timestamp`) and **Livox** (`timestamp`). Livox needs its PointCloud2 output rather than the default `CustomMsg` format, which this node cannot subscribe to at all. + +To check what your driver actually publishes: + +```bash +ros2 topic echo /your/points --field fields --once +``` + +If none of the four names is in that list, look for a driver option to add per-point timestamps before anything else. + +The `fixed_frame_id` → sensor transform must cover the whole sweep, which means **odometry has to be at least as recent as the lidar**. If it lags, raise `wait_for_transform`. + +## Behavior when TF is missing + +The two inputs deliberately differ: + +- A **cloud** is republished **unchanged** with a warning. Deskewing is an improvement, not a precondition, and dropping frames would break the pipeline behind it. +- A **scan** is **dropped**, because converting it to a cloud is only worth doing as part of deskewing. + +## Notes + +Deskewing matters most when rotating: at 1 rad/s a 100 ms sweep spans nearly 6°, and the far end of the scan is badly misplaced. Pure translation at walking speed is a few centimeters, which matters at close range. + +Put this node before ICP odometry or [point_cloud_assembler](point_cloud_assembler.md), not after. Anything registering against a skewed cloud has already paid for the distortion. diff --git a/rtabmap_util/doc/map_assembler.md b/rtabmap_util/doc/map_assembler.md new file mode 100644 index 00000000..fe0fb62b --- /dev/null +++ b/rtabmap_util/doc/map_assembler.md @@ -0,0 +1,109 @@ +# map_assembler + +Rebuilds the global maps from RTAB-Map's graph, in a separate process. + +RTAB-Map publishes its graph and the per-node sensor data on `mapData`; turning that into a point cloud, an occupancy grid or an octomap costs real CPU. This node does that work, so the SLAM node does not have to and the mapping loop stays responsive. + +It also lets you produce maps RTAB-Map is not currently configured to publish, or several differently-configured maps at once, without restarting SLAM. + +The assembling itself is done by `MapsManager`, which is shared with `rtabmap_slam` — the outputs and every `Grid/*` parameter behave identically in both. + +## Usage + +```bash +ros2 run rtabmap_util map_assembler --ros-args \ + -p Grid/CellSize:=0.05 -p Grid/RangeMax:=8.0 -p cloud_output_voxelized:=true +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::MapAssembler', + name='map_assembler', + parameters=[{'Grid/CellSize': '0.05', 'Grid/RangeMax': '8.0', + 'cloud_output_voxelized': True}]) +``` + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `mapData` | [`rtabmap_msgs/msg/MapData`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/MapData.html) | The graph, plus the sensor data of any newly added node. Published by `rtabmap`. | + +## Published Topics + +Everything is published only when subscribed, and — by default — **latched**, so a subscriber joining late immediately receives the current map. + +| Topic | Type | Description | +|---|---|---| +| `cloud_map` | [`PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | Ground and obstacles together. | +| `cloud_ground` | `PointCloud2` | Ground only, colored green. | +| `cloud_obstacles` | `PointCloud2` | Obstacles only, colored red. | +| `map` | [`OccupancyGrid`](https://docs.ros.org/en/jazzy/p/nav_msgs/msg/OccupancyGrid.html) | The 2D occupancy grid, the one navigation wants. | +| `grid_prob_map` | `OccupancyGrid` | The same grid as occupancy probabilities rather than free/occupied/unknown. | +| `octomap_occupied_space`, `octomap_obstacles`, `octomap_ground`, `octomap_empty_space`, `octomap_global_frontier_space` | `PointCloud2` | Octomap contents, one cloud per category. Requires RTAB-Map built with OctoMap. | +| `octomap_grid` | `OccupancyGrid` | The octomap projected to 2D. | +| `octomap_binary`, `octomap_full` | [`Octomap`](https://docs.ros.org/en/jazzy/p/octomap_msgs/msg/Octomap.html) | The tree itself, for `octovis` or other octomap consumers. Serialized as a **`ColorOcTree`**, see [Octomap tree type](#octomap-tree-type). | +| `elevation_map` | [`GridMap`](https://github.com/ANYbotics/grid_map/blob/master/grid_map_msgs/msg/GridMap.msg) | Elevation map. Requires RTAB-Map built with `grid_map`. | + +## Services + +| Service | Type | Description | +|---|---|---| +| `~/reset` | [`std_srvs/srv/Empty`](https://docs.ros.org/en/jazzy/p/std_srvs/srv/Empty.html) | Drop the cached nodes and every assembled map. | +| `~/octomap_binary` | [`octomap_msgs/srv/GetOctomap`](https://docs.ros.org/en/jazzy/p/octomap_msgs/srv/GetOctomap.html) | Build and return the octomap on demand. | +| `~/octomap_full` | `octomap_msgs/srv/GetOctomap` | The same, with occupancy probabilities. | + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `initialize_from_rtabmap_timeout` | `double` | `5.0` | Seconds to wait for rtabmap's `get_map_data` service on start-up, which is how the node catches up on a map that already exists. Set to `0` to skip the call and subscribe immediately, which is what you want when `map_assembler` starts *before* rtabmap. | +| `rtabmap` | `string` | `"rtabmap"` | Name of the rtabmap node whose `get_map_data` service to call. | +| `regenerate_local_grids` | `bool` | `false` | Discard the occupancy grids stored with each node and rebuild them from the raw sensor data. Use it to change `Grid/*` parameters on an existing map without re-running SLAM. Costs CPU per node. | +| `config_path` | `string` | `""` | An RTAB-Map `.ini` file to load parameters from, instead of listing them individually. | + +**Map assembly**, from `MapsManager` + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `latch` | `bool` | `true` | Publish with transient-local durability so late subscribers get the current map. | +| `map_filter_radius` | `double` | `0.0` | Skip nodes closer together than this, in meters. A cheap way to thin a dense graph. `0` disables. | +| `map_filter_angle` | `double` | `30.0` | With `map_filter_radius`, nodes are only merged if they also differ by less than this angle, in degrees. | +| `map_always_update` | `bool` | `false` | **No effect here**, see below. | +| `map_empty_ray_tracing` | `bool` | `true` | **No effect here**, see below. | +| `map_cleanup` | `bool` | `true` | Free the cached clouds when nobody is subscribed. | +| `cloud_output_voxelized` | `bool` | `true` | Voxelize the assembled clouds at `Grid/CellSize`. | +| `cloud_subtract_filtering` | `bool` | `false` | Drop points that duplicate ones already in the map. Slower, smaller output. | +| `cloud_subtract_filtering_min_neighbors` | `int` | `2` | Neighbors needed for a point to count as a duplicate. | +| `octomap_tree_depth` | `int` | `16` | Depth the octomap clouds are generated at. Lower means coarser and faster. Maximum 16. | + +`map_always_update` and `map_empty_ray_tracing` are declared because they come with `MapsManager`, but neither does anything in this node. Both only apply to the *current*, not-yet-committed node, which `MapsManager` identifies by the pose id `0`. That node is assembled inside `rtabmap_slam`'s `rtabmap` node from its live sensor data and is never published on `mapData`, so the graph reaching `map_assembler` only ever contains committed nodes. Set them on the `rtabmap` node instead, where they do apply. + +Every RTAB-Map **`Grid/*`**, **`GridGlobal/*`**, **`StereoBM/*`** and **`StereoSGBM/*`** parameter is also exposed, all documented in RTAB-Map's [parameter reference](https://introlab.github.io/rtabmap/api/latest/parameters.html). The split between the first two is worth knowing: **`Grid/*`** decides how each node's local grid is built from its sensor data — the same segmentation [obstacles_detection](obstacles_detection.md#parameters) does, and the parameters listed there apply here too — while **`GridGlobal/*`** decides how those local grids are merged into the global map, so it covers the map's minimum size, its occupancy threshold, and how far the graph must move before the whole map is rebuilt. + +## Octomap tree type + +RTAB-Map keeps a color per voxel, so the tree it publishes on `octomap_binary` and `octomap_full` reports its `id` as **`ColorOcTree`**, not the plain `OcTree` many examples assume. + +That is deliberate and interoperable: `octomap_msgs::binaryMsgToMap()` and `fullMsgToMap()` branch on that `id` and hand you back an `octomap::ColorOcTree`, and `octovis` opens it without complaint. What does break is code that assumes the other branch: + +```cpp +octomap::AbstractOcTree * tree = octomap_msgs::binaryMsgToMap(msg); +octomap::OcTree * octree = dynamic_cast(tree); // null +octomap::ColorOcTree * octree = dynamic_cast(tree); // ok +``` + +`ColorOcTree` does not derive from `OcTree` — both derive from `OccupancyOcTreeBase` — so cast to `ColorOcTree`, or to `octomap::OccupancyOcTreeBase<...>` if you only need occupancy and want to accept either. + +## Start-up + +`map_assembler` normally starts alongside rtabmap and builds its maps from the `mapData` messages that follow. If it starts **after** rtabmap it would miss everything already mapped, so on start-up it calls rtabmap's `get_map_data` service once to fetch the existing map. + +That call blocks the subscription to `mapData` until it returns or times out, which is wasted time when rtabmap is not running yet. Set `initialize_from_rtabmap_timeout` to `0` in that case. + +If rtabmap is started later in localization mode, call its `publish_maps` service with `graph_only=false` so `map_assembler` receives the data it missed. + +## Notes + +`regenerate_local_grids` is the parameter to reach for when a recorded map's grids were built with settings you now want to change. Without it, `Grid/*` changes only affect nodes added from then on, because each node's grid is stored with it. diff --git a/rtabmap_util/doc/obstacles_detection.md b/rtabmap_util/doc/obstacles_detection.md new file mode 100644 index 00000000..4fcadbfa --- /dev/null +++ b/rtabmap_util/doc/obstacles_detection.md @@ -0,0 +1,140 @@ +# obstacles_detection + +Segments a point cloud into ground and obstacles. + +The node takes a cloud, works out which points belong to the floor and which stick up from it, and publishes the two apart. Downstream that feeds navigation: obstacles into a costmap, ground into a traversability check. + +The segmentation is RTAB-Map's own [`LocalGridMaker`](https://introlab.github.io/rtabmap/api/latest/classrtabmap_1_1LocalGridMaker.html), so it is configured through the same `Grid/*` parameters as RTAB-Map itself and produces the same result the SLAM node would. + +## Usage + +```bash +ros2 run rtabmap_util obstacles_detection --ros-args \ + -r cloud:=/camera/cloud \ + -p frame_id:=base_link \ + -p Grid/MaxObstacleHeight:=2.0 +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::ObstaclesDetection', + name='obstacles_detection', + parameters=[{'frame_id': 'base_link', 'Grid/MaxObstacleHeight': '2.0'}], + remappings=[('cloud', '/camera/cloud')]) +``` + +### Feeding a nav2 costmap + +The usual reason to run this node: nav2's costmap wants to be told separately what is floor and what is in the way. A depth camera gives neither directly, so the chain is depth image → cloud → segmented cloud → costmap. + +[point_cloud_xyz](point_cloud_xyz.md) projects the depth image, with `decimation` and `voxel_size` set to keep the cost down, and this node splits the result: + +```python +Node( + package='rtabmap_util', executable='point_cloud_xyz', + parameters=[{'decimation': 2, 'max_depth': 3.0, 'voxel_size': 0.02}], + remappings=[('depth/image', '/camera/depth/image_raw'), + ('depth/camera_info', '/camera/camera_info'), + ('cloud', '/camera/cloud')]), + +Node( + package='rtabmap_util', executable='obstacles_detection', + parameters=[{'frame_id': 'base_link'}], + remappings=[('cloud', '/camera/cloud'), + ('ground', '/camera/ground'), + ('obstacles', '/camera/obstacles')]), +``` + +The two outputs then become two observation sources on the costmap's voxel layer: + +```yaml +local_costmap: + local_costmap: + ros__parameters: + plugins: ["voxel_layer", "inflation_layer"] + voxel_layer: + plugin: "nav2_costmap_2d::VoxelLayer" + enabled: True + publish_voxel_map: True + origin_z: 0.0 + z_resolution: 0.05 + z_voxels: 16 + max_obstacle_height: 2.0 + mark_threshold: 0 + observation_sources: ground obstacles + ground: + topic: /camera/ground + data_type: "PointCloud2" + max_obstacle_height: 0.4 + marking: False # the floor is not an obstacle... + clearing: True # ...but seeing it proves the space is free + raytrace_max_range: 3.0 + raytrace_min_range: 0.0 + obstacle_max_range: 2.5 + obstacle_min_range: 0.0 + obstacles: + topic: /camera/obstacles + data_type: "PointCloud2" + max_obstacle_height: 0.4 + marking: True + clearing: True + raytrace_max_range: 3.0 + raytrace_min_range: 0.0 + obstacle_max_range: 2.5 + obstacle_min_range: 0.0 +``` + +The `marking`/`clearing` split is the whole point. Ground points only clear: they tell the costmap that the space the camera looked through is free, without writing an obstacle at floor level. Obstacle points do both, so an obstacle that moves away is cleared by the next observation instead of lingering. + +Feeding the raw cloud in as a single source cannot do this — every floor point would mark an obstacle and the robot would refuse to move. Working from [`turtlebot3_rgbd.launch.py`](https://github.com/introlab/rtabmap_ros/blob/ros2/rtabmap_demos/launch/turtlebot3/turtlebot3_rgbd.launch.py) and its [nav2 parameters](https://github.com/introlab/rtabmap_ros/blob/ros2/rtabmap_demos/params/turtlebot3_rgbd_nav2_params.yaml) will save some time. + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | The cloud to segment, in any frame that TF can relate to `frame_id`. | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `ground` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | Points classified as floor. In the **input** cloud's frame. | +| `obstacles` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | Points classified as obstacles. In the **input** cloud's frame. | +| `proj_obstacles` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | The obstacles flattened onto the ground plane, in `frame_id`. This is the 2D footprint a planar costmap wants. | + +Each output is computed only if something is subscribed to it. + +## Required Transforms + +| Transform | Description | +|---|---| +| `frame_id` → cloud frame | Where the sensor sits on the robot. Segmentation happens in `frame_id`, so this is what makes "up" meaningful. | +| `map_frame_id` → `frame_id` | Only when `map_frame_id` is set. See [Levelling on a slope](#levelling-on-a-slope). | + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `frame_id` | `string` | `"base_link"` | The robot frame. Its xy plane is the ground plane the segmentation works against. | +| `map_frame_id` | `string` | `""` | See [Levelling on a slope](#levelling-on-a-slope). | +| `wait_for_transform` | `double` | `0.2` | Seconds to wait for a transform before dropping the cloud. | +| `qos` | `int` | `0` | Reliability of the subscription and the publishers: `0` system default, `1` reliable, `2` best effort. | + +Every RTAB-Map **`Grid/*`** parameter is also exposed as a ROS parameter of this node, and they are what actually control the segmentation. They are documented in RTAB-Map's [parameter reference](https://introlab.github.io/rtabmap/api/latest/parameters.html). + +The one to decide first is `Grid/NormalsSegmentation`, which picks between two ways of finding the ground. Left on, it is segmented from surface normals, which copes with slopes and steps. Turned off, it is a plain height threshold: much cheaper, and exact when the floor really is flat, but `Grid/MaxGroundHeight` then has to be set, since it *is* that threshold. + +## Levelling on a slope + +Segmentation is done in `frame_id`, so if the robot is pitched or rolled — on a ramp, or with a suspension that dips — the ground plane tilts with it and the floor ahead can be classified as an obstacle. + +Setting `map_frame_id` makes the node take the robot's pose in that frame and apply its **roll and pitch**, so segmentation happens against a level plane rather than the robot's own tilt. + +Height is a separate matter: the robot's **z** in the map frame is ignored unless `Grid/MapFrameProjection` is also set to `true`. That is usually what you want — a height threshold should be measured from the robot, not from an arbitrary map origin — but if you are mapping a multi-level building and want the thresholds relative to the map, enable it. + +## Notes + +If `obstacles` comes back empty on an obviously cluttered scene, check `Grid/RangeMax` first. It is **not unlimited by default**, and everything beyond it is discarded before segmentation even runs. + +The second thing to check is `Grid/MinClusterSize` against your cloud density. A sparse lidar can produce clusters smaller than the default, in which case every obstacle is thrown away as noise. Either lower it or raise `Grid/ClusterRadius`. diff --git a/rtabmap_util/doc/point_cloud_aggregator.md b/rtabmap_util/doc/point_cloud_aggregator.md new file mode 100644 index 00000000..a310c448 --- /dev/null +++ b/rtabmap_util/doc/point_cloud_aggregator.md @@ -0,0 +1,97 @@ +# point_cloud_aggregator + +Merges one cloud from each of several sensors into a single cloud. + +A robot with two or three lidars, or a ring of depth cameras, produces one cloud per sensor. This node waits for a matching set, transforms them all into a common frame and publishes a single cloud, so everything downstream sees the robot's full field of view as one measurement. + +The sensors do not have to fire together: the clouds are matched by nearest stamp, and setting `fixed_frame_id` compensates for the robot having moved between them. See [Sensors that do not fire together](#sensors-that-do-not-fire-together). + +It combines **several sensors into one frame**. To combine **one sensor over many frames**, use [point_cloud_assembler](point_cloud_assembler.md). + +## Usage + +```bash +ros2 run rtabmap_util point_cloud_aggregator --ros-args \ + -p count:=3 -p frame_id:=base_link -p fixed_frame_id:=odom \ + -r cloud1:=/lidar_front/points/deskewed \ + -r cloud2:=/lidar_left/points/deskewed \ + -r cloud3:=/lidar_right/points/deskewed +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::PointCloudAggregator', + name='point_cloud_aggregator', + parameters=[{'count': 3, 'frame_id': 'base_link', 'fixed_frame_id': 'odom'}], + remappings=[('cloud1', '/lidar_front/points/deskewed'), + ('cloud2', '/lidar_left/points/deskewed'), + ('cloud3', '/lidar_right/points/deskewed')]) +``` + +With 2D or 3D lidars, feed the aggregator **deskewed** clouds: run a [lidar_deskewing](lidar_deskewing.md) node per sensor first, which is where the `/deskewed` topics above come from. For a 2D lidar publishing `LaserScan` that node is needed regardless — this one only takes `PointCloud2`, and `lidar_deskewing` converts to one as it deskews. + +The two nodes correct different motions and you generally want both. Deskewing removes the distortion *within* each sweep, point by point, because a spinning lidar measures each point from a slightly different pose. `fixed_frame_id` here places whole clouds relative to each other, because the sensors did not fire at the same instant. Merging raw sweeps only merges their distortions. + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `cloud1` … `cloud4` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | Only the first `count` are subscribed. | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `combined_cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | In `frame_id`, or in `cloud1`'s frame if `frame_id` is empty. Stamped with `cloud1`. | + +Nothing is computed unless `combined_cloud` has a subscriber. + +## Required Transforms + +| Transform | Description | +|---|---| +| target frame → each cloud's frame | Where each sensor sits. The target is `frame_id`, or `cloud1`'s frame when that is empty. | +| `fixed_frame_id` → each cloud's frame, at each stamp | Only when `fixed_frame_id` is set. See [Sensors that do not fire together](#sensors-that-do-not-fire-together). | + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `count` | `int` | `2` | How many clouds to combine, 2 to 4. Determines how many `cloudN` topics are subscribed. | +| `frame_id` | `string` | `""` | Frame to express the combined cloud in. Empty uses `cloud1`'s frame, which is the cheapest option since that cloud then needs no transform, but see [Converting back to a LaserScan](#converting-back-to-a-laserscan). | +| `fixed_frame_id` | `string` | `""` | Frame to compensate motion against, usually `odom`. See below. | +| `approx_sync` | `bool` | `true` | Match the clouds by nearest stamp. Set false when the sensors are hardware-triggered and share exact stamps. | +| `approx_sync_max_interval` | `double` | `0.0` | Reject sets spanning more than this many seconds. `0` disables. A good guard against silently merging stale data. | +| `wait_for_transform` | `double` | `0.1` | Seconds to wait for a transform before dropping the set. | +| `xyz_output` | `bool` | `false` | Strip everything but XYZ from the output. Useful when the inputs disagree on their extra fields. | +| `topic_queue_size` | `int` | `1` | Queue depth of each input subscription. | +| `sync_queue_size` | `int` | `10` | Queue depth of the synchronizer. | +| `qos` | `int` | `0` | Reliability of the cloud subscriptions: `0` system default, `1` reliable, `2` best effort. | + +## Converting back to a LaserScan + +Some consumers still want a 2D `LaserScan` — `slam_toolbox`, `amcl`, or a costmap layer configured for one. [`pointcloud_to_laserscan`](https://docs.ros.org/en/jazzy/p/pointcloud_to_laserscan/) flattens the combined cloud into one: + +```python +Node( + package='pointcloud_to_laserscan', executable='pointcloud_to_laserscan_node', + parameters=[{'target_frame': 'base_link', 'min_height': -0.1, 'max_height': 0.5}], + remappings=[('cloud_in', '/combined_cloud')]) +``` + +**Set `frame_id` to the robot center when you do this.** A `LaserScan` is a set of ranges measured outward from one origin, so the conversion is only meaningful about a point the consumer thinks of as the robot. Leaving `frame_id` empty puts the combined cloud in `cloud1`'s frame — a sensor bolted somewhere on the edge of the robot — and every range then comes out measured from that corner. With three lidars merged, the result is a scan centerd on whichever one happened to be `cloud1`. + +One case where you should *not* combine first: if the clouds are only going into a nav2 costmap, give nav2 each sensor as its own observation source instead. A costmap clears free space by ray tracing outward from where the observation was made, and it takes that origin from the cloud's own frame. Merge everything into one cloud at `base_link` and every point looks as though it were seen from the robot center, so space gets cleared along lines no sensor ever looked down — including straight through whatever the other sensors can see. + +## Sensors that do not fire together + +With `approx_sync` the clouds carry different stamps, and on a moving robot each was captured from a different pose. Merging them by their static mounting transforms alone smears the result. + +Setting `fixed_frame_id` fixes that: the node asks TF where each sensor was at its own stamp, relative to that fixed frame, and places each cloud accordingly. Two lidars 30 ms apart on a robot turning at 1 rad/s are nearly 2° apart — clearly visible as a doubled wall. + +Leave it empty only when the sensors are genuinely synchronized, or when the robot is stationary. + +## Diagnostics + +The node publishes to `/diagnostics` and warns if no combined cloud has been produced for a while — usually a sign that one of the `cloudN` topics is silent, or that the stamps are too far apart to sync. diff --git a/rtabmap_util/doc/point_cloud_assembler.md b/rtabmap_util/doc/point_cloud_assembler.md new file mode 100644 index 00000000..55caefbf --- /dev/null +++ b/rtabmap_util/doc/point_cloud_assembler.md @@ -0,0 +1,162 @@ +# point_cloud_assembler + +Accumulates the clouds of one sensor over time into a denser cloud. + +A single sweep is sparse, or narrow, or both. This node keeps the recent ones, places each where the sensor was when it was captured, and publishes the union. + +That is used for two quite different things. With a **narrow field of view** — a depth camera reduced to a fake scan, say — accumulating a second's worth of sweeps as the robot moves is what makes the sensor usable for SLAM at all. With a 3D lidar it is about **enriching what already works**: each node gets a denser, less occluded cloud, which registers better and puts many more points in the database, so an offline export later has the resolution to be worth having. + +It combines **one sensor over many frames**. To combine **several sensors into one frame**, use [point_cloud_aggregator](point_cloud_aggregator.md) — or, if you want them merely accumulated rather than matched into sets, remap them all onto this node's `cloud` topic. Nothing stops several publishers sharing it, and each cloud is placed by its own stamp and frame like any other; the publish trigger then covers them together — `max_clouds` counts across all the sensors, and a given `assembling_time` gathers correspondingly more clouds. + +## Usage + +Assemble 10 sweeps, using TF for the poses: + +```bash +ros2 run rtabmap_util point_cloud_assembler --ros-args \ + -r cloud:=/velodyne_points/deskewed \ + -p max_clouds:=10 -p fixed_frame_id:=odom -p voxel_size:=0.05 +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::PointCloudAssembler', + name='point_cloud_assembler', + parameters=[{'max_clouds': 10, 'fixed_frame_id': 'odom', 'voxel_size': 0.05}], + remappings=[('cloud', '/velodyne_points/deskewed')]) +``` + +With a lidar, feed it **deskewed** clouds from a [lidar_deskewing](lidar_deskewing.md) node rather than the driver's raw output: accumulating skewed sweeps accumulates their distortion too. For a 2D lidar publishing `LaserScan` that node is needed regardless — this one only takes `PointCloud2`, and `lidar_deskewing` converts to one as it deskews. + +### Denser clouds for SLAM, and keeping every point + +From [`lidar3d_assemble.launch.py`](https://github.com/introlab/rtabmap_ros/blob/ros2/rtabmap_examples/launch/lidar3d_assemble.launch.py). Here the input is a real 3D lidar, and assembling buys both resolution and coverage: a node built from a second of sweeps is denser between the rings and sees around what a single sweep was occluded by, so it registers better. + +It also decides how much of the lidar survives. RTAB-Map stores one cloud per node and updates at around 1 Hz, while the lidar and odometry run at 10 — and running the mapping node at 10 Hz is not practical. Nine sweeps in ten therefore never reach the database. The `assembling_time: 1.0` below hands each node the whole second instead, so **nothing is thrown away**: the database keeps every point the lidar returned, which is what makes this the approach for survey scanning and a full-resolution offline export. + +```python +Node( + package='rtabmap_util', executable='point_cloud_assembler', + parameters=[{'assembling_time': 1.0, + 'fixed_frame_id': ''}], # '' selects the odom topic + remappings=[('cloud', '/lidar/points/deskewed'), + ('odom', 'icp_odom')]), +``` + +Note `fixed_frame_id: ''`. Clearing it switches the node from TF to the `odom` topic, pairing each cloud with the exact odometry message that goes with it rather than an interpolated TF lookup — see [Where the poses come from](#where-the-poses-come-from). Feeding the result to `rtabmap` as `scan_cloud` means the assembled cloud, not the raw sweep, is what gets stored. + +### Widening a narrow field of view + +From [`turtlebot3_rgbd_fake_scan.launch.py`](https://github.com/introlab/rtabmap_ros/blob/ros2/rtabmap_demos/launch/turtlebot3/turtlebot3_rgbd_fake_scan.launch.py). A depth camera sees perhaps 60° across, and [`depthimage_to_laserscan`](https://docs.ros.org/en/jazzy/p/depthimage_to_laserscan/) reduces that to a fake scan thinner still. One of those is too little to localize against; twenty of them, accumulated as the robot drives and turns, cover a useful arc. + +```python +Node( + package='rtabmap_util', executable='point_cloud_assembler', + parameters=[{'max_clouds': 20, + 'circular_buffer': True, + 'linear_update': 0.3, + 'angular_update': 0.5, + 'voxel_size': 0.05, + 'frame_id': 'base_link'}], + remappings=[('cloud', '/camera/scan/deskewed')]), +``` + +`circular_buffer` is what makes this work as a live input: the window rolls, so every incoming scan produces a full assembled cloud rather than one per twenty. `linear_update` and `angular_update` stop a stationary robot from filling the buffer with twenty copies of the same view, which would leave it with nothing but the current scan the moment it moved off again. + +The cloud goes to `rtabmap` as `scan_cloud`, with `scan_cloud_is_2d` set since the points all came from one row of pixels. + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | The sweeps to accumulate. Ideally deskewed, see [Usage](#usage). | +| `odom` | [`nav_msgs/msg/Odometry`](https://docs.ros.org/en/jazzy/p/nav_msgs/msg/Odometry.html) | Only when `fixed_frame_id` is empty. See [Where the poses come from](#where-the-poses-come-from). | +| `odom_info` | [`rtabmap_msgs/msg/OdomInfo`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/OdomInfo.html) | Only when `subscribe_odom_info` is true. | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `assembled_cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | In `frame_id` if set, otherwise the frame of the newest cloud. Stamped with the newest cloud. | + +Nothing is accumulated unless `assembled_cloud` has a subscriber. + +## Required Transforms + +| Transform | Description | +|---|---| +| `fixed_frame_id` → cloud frame, at each stamp | Only in TF mode, i.e. when `fixed_frame_id` is set. | +| `frame_id` → cloud frame | Only when `frame_id` is set. | + +## Parameters + +**What triggers a publish** — set exactly one of these + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `max_clouds` | `int` | `0` | Publish once this many clouds have been collected. | +| `assembling_time` | `double` | `0.0` | Publish once this many seconds have been collected. | + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `circular_buffer` | `bool` | `false` | Keep a rolling window instead of clearing after each publish, so a full assembled cloud is published for **every** input rather than one in `max_clouds`. Costs more, gives smooth output. | +| `skip_clouds` | `int` | `0` | Drop this many input clouds between the ones kept. | +| `linear_update` | `double` | `0.0` | Only accumulate a cloud if the sensor has moved this far, in meters, since the last one kept. `0` disables. | +| `angular_update` | `double` | `0.0` | Same for rotation, in radians. `0` disables. | + +**Poses** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `fixed_frame_id` | `string` | `"odom"` | Frame the sweeps are placed in, via TF. **Set it to `""` to use the `odom` topic instead.** | +| `frame_id` | `string` | `""` | Frame to express the output in. Empty uses the newest cloud's frame. | +| `wait_for_transform` | `double` | `0.1` | Seconds to wait for a transform before dropping a cloud. | +| `subscribe_odom_info` | `bool` | `false` | Keep only the clouds odometry marked as keyframes. Needs the `odom` topic mode, see [Following odometry's keyframes](#following-odometrys-keyframes). | + +**Filtering** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `range_min` | `double` | `0.0` | Drop points nearer than this to the sensor, in meters. Good for removing the robot itself. `0` disables. | +| `range_max` | `double` | `0.0` | Drop points further than this, in meters. `0` disables. | +| `voxel_size` | `double` | `0.0` | Downsample the assembled cloud to one point per voxel, in meters. `0` disables. Strongly recommended, otherwise the cloud grows linearly with `max_clouds`. | +| `noise_radius` | `double` | `0.0` | Radius outlier removal on the output, in meters. `0` disables. | +| `noise_min_neighbors` | `int` | `5` | Neighbors needed within `noise_radius`. | +| `remove_z` | `bool` | `false` | Flatten the output to 2D by zeroing z. | + +**Plumbing** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `topic_queue_size` | `int` | `1` | Queue depth of each input subscription. | +| `sync_queue_size` | `int` | `10` | Queue depth of the synchronizer, in odom-topic mode. | +| `qos` | `int` | `0` | Reliability of the cloud subscription. | +| `qos_odom` | `int` | value of `qos` | Reliability of the `odom` and `odom_info` subscriptions. | + +## Where the poses come from + +Each sweep has to be placed where the sensor was when it was captured, and there are two ways to get that pose: + +- **TF** (default). `fixed_frame_id` is set, and the node looks the pose up per cloud. Simple, and it works with any odometry source. +- **The `odom` topic**. Set `fixed_frame_id` to `""` and the node synchronizes each cloud with an `Odometry` message instead. Use this when odometry is not published to TF, or when you need the pose that exactly matches the cloud rather than an interpolated one. + +Because `fixed_frame_id` **defaults to `"odom"`**, the `odom` topic is not subscribed unless you clear it explicitly. Setting `subscribe_odom_info` alone is not enough. + +## Following odometry's keyframes + +With `subscribe_odom_info` the node also takes `odom_info` and keeps a cloud only when that message reports a keyframe was added; the ones in between are dropped. + +This is a better-informed version of `linear_update` and `angular_update`. Those are fixed distances you have to guess at, whereas odometry decides a keyframe from how much of the current scan still matches the last one — `Odom/ScanKeyFrameThr` for ICP, `Odom/KeyFrameThr` for visual odometry. It therefore adapts to the scene, keeping more clouds where the geometry changes quickly and fewer down a featureless corridor, and the assembled cloud ends up built from exactly the frames odometry itself considered distinct. + +It only has an effect in the `odom` topic mode. With `fixed_frame_id` set the node subscribes to the cloud on its own and never sees `odom_info`, so clear `fixed_frame_id` as well — see [Where the poses come from](#where-the-poses-come-from). + +## Notes + +Set `voxel_size`. Without it the assembled cloud is the plain union of every sweep, points and all, and both memory and downstream cost grow with `max_clouds`. A voxel size near the sensor's resolution costs almost no fidelity. + +`circular_buffer` changes the output rate, not just the contents: without it you get one assembled cloud per `max_clouds` inputs, with it you get one per input. + +## Diagnostics + +The node publishes to `/diagnostics` and warns if no assembled cloud has been produced for a while — typically a missing transform or a silent input topic. diff --git a/rtabmap_util/doc/point_cloud_xyz.md b/rtabmap_util/doc/point_cloud_xyz.md new file mode 100644 index 00000000..280d3d9c --- /dev/null +++ b/rtabmap_util/doc/point_cloud_xyz.md @@ -0,0 +1,95 @@ +# point_cloud_xyz + +Projects a depth or disparity image into a point cloud. + +The node takes a depth image and its calibration and produces a [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html), with optional decimation, range limits, voxel and radius filtering, and normal estimation — the same preprocessing RTAB-Map would do internally, done once and shared. + +[`depth_image_proc`](https://docs.ros.org/en/jazzy/p/depth_image_proc/)'s own `point_cloud_xyz` does the bare projection; this node exists for the filtering, and for accepting disparity directly. + +See [point_cloud_xyzrgb](point_cloud_xyzrgb.md) for the colored equivalent. + +## Usage + +```bash +ros2 run rtabmap_util point_cloud_xyz --ros-args \ + -r depth/image:=/camera/depth/image_raw \ + -r depth/camera_info:=/camera/depth/camera_info \ + -p decimation:=4 -p max_depth:=5.0 -p voxel_size:=0.05 +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::PointCloudXYZ', + name='point_cloud_xyz', + parameters=[{'decimation': 4, 'max_depth': 5.0, 'voxel_size': 0.05}], + remappings=[('depth/image', '/camera/depth/image_raw'), + ('depth/camera_info', '/camera/depth/camera_info')]) +``` + +## Subscribed Topics + +The node listens on two independent input sets and uses whichever one is being published. Only one of them should be connected. + +**Depth** + +| Topic | Type | Description | +|---|---|---| +| `depth/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | `32FC1` (meters), `16UC1` (millimeters) or `mono16`. Goes through [`image_transport`](https://docs.ros.org/en/jazzy/p/image_transport/), see `depth_transport` parameter below. | +| `depth/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | | + +**Disparity** + +| Topic | Type | Description | +|---|---|---| +| `disparity/image` | [`stereo_msgs/msg/DisparityImage`](https://docs.ros.org/en/jazzy/p/stereo_msgs/msg/DisparityImage.html) | `32FC1` or `16SC1`. The 16-bit form is fixed point, 16 units per pixel of disparity. | +| `disparity/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | In the frame of the input image, stamped with it. Carries `normal_*` fields when normals are enabled. | + +Nothing is computed unless `cloud` has a subscriber. + +## Parameters + +**Synchronization** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `approx_sync` | `bool` | `true` | Match image and camera info by nearest stamp. Set false when they are published with identical stamps, which is stricter and cheaper. | +| `approx_sync_max_interval` | `double` | `0.0` | With `approx_sync`, reject pairs further apart than this many seconds. `0` disables the check. | +| `topic_queue_size` | `int` | `1` | Queue depth of each input subscription. | +| `sync_queue_size` | `int` | `10` | Queue depth of the synchronizer. | +| `qos` | `int` | `0` | Reliability of the image and disparity subscriptions: `0` system default, `1` reliable, `2` best effort. | +| `qos_camera_info` | `int` | value of `qos` | Reliability of the camera info subscriptions. | +| `depth_transport` | `string` | `"raw"` | `image_transport` plugin for `depth/image`, e.g. `compressedDepth`. | + +**Projection and filtering**, applied in this order + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `decimation` | `int` | `1` | Keep one pixel in `decimation`, in each direction. `2` gives a quarter of the points. The image dimensions must divide by it. | +| `roi_ratios` | `string` | `""` | Crop before projecting, as four ratios `"left right top bottom"`, e.g. `"0.1 0.1 0 0.2"`. | +| `min_depth` | `double` | `0.0` | Discard points nearer than this, in meters. `0` disables. | +| `max_depth` | `double` | `0.0` | Discard points further than this, in meters. `0` disables. | +| `voxel_size` | `double` | `0.0` | Downsample to one point per voxel of this size, in meters. `0` disables. | +| `noise_filter_radius` | `double` | `0.0` | Radius outlier removal, in meters. `0` disables. | +| `noise_filter_min_neighbors` | `int` | `5` | Neighbors a point needs within `noise_filter_radius` to survive. | +| `normal_k` | `int` | `0` | Estimate normals from this many nearest neighbors. `0` disables. | +| `normal_radius` | `double` | `0.0` | Estimate normals from all neighbors within this radius, in meters. `0` disables. | +| `filter_nans` | `bool` | `false` | See [Organized output](#organized-output). | + +## Organized output + +By default the cloud stays **organized**: one point per pixel, in image order, with out-of-range points set to NaN rather than removed. That layout is what lets consumers treat the cloud as an image, and it is why a cloud with `max_depth` set still reports the full point count. + +Set `filter_nans` to `true` to drop the invalid points instead. The cloud becomes unorganized and its size reflects what is actually in range — including being empty when nothing is. + +Voxel and radius filtering also produce unorganized clouds, since both remove points. + +## Notes + +`decimation` is by far the cheapest way to cut the cost of everything downstream, and on a depth image it loses very little: neighboring pixels of a surface are nearly redundant. Reach for it before `voxel_size`. diff --git a/rtabmap_util/doc/point_cloud_xyzrgb.md b/rtabmap_util/doc/point_cloud_xyzrgb.md new file mode 100644 index 00000000..158905fb --- /dev/null +++ b/rtabmap_util/doc/point_cloud_xyzrgb.md @@ -0,0 +1,121 @@ +# point_cloud_xyzrgb + +Projects an RGB-D frame, a stereo pair or a disparity image into a colored point cloud. + +The colored counterpart of [point_cloud_xyz](point_cloud_xyz.md): same filtering, same parameters, but every point carries the color of the pixel it came from. It accepts four different input sets, so it can sit at the end of an RGB-D, stereo or disparity pipeline without anything in between. + +## Usage + +```bash +ros2 run rtabmap_util point_cloud_xyzrgb --ros-args \ + -r rgb/image:=/camera/color/image_raw \ + -r depth/image:=/camera/aligned_depth_to_color/image_raw \ + -r rgb/camera_info:=/camera/color/camera_info \ + -p decimation:=4 -p voxel_size:=0.05 +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::PointCloudXYZRGB', + name='point_cloud_xyzrgb', + parameters=[{'decimation': 4, 'voxel_size': 0.05}], + remappings=[('rgb/image', '/camera/color/image_raw'), + ('depth/image', '/camera/aligned_depth_to_color/image_raw'), + ('rgb/camera_info', '/camera/color/camera_info')]) +``` + +## Subscribed Topics + +Four independent input sets; connect exactly one. + +**RGB-D** + +| Topic | Type | Description | +|---|---|---| +| `rgb/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | `mono8`, `mono16`, `bgr8`, `rgb8`, `bgra8`, `rgba8` or `bayer_grbg8`. | +| `depth/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | `32FC1`, `16UC1` or `mono16`, **registered to the color camera**. | +| `rgb/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | | + +**Stereo** + +| Topic | Type | Description | +|---|---|---| +| `left/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | Rectified. | +| `right/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | Rectified. | +| `left/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | | +| `right/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | Its `P(0,3)` carries the baseline. | + +Dense matching is done on the fly with OpenCV's block matcher; see [Stereo matching](#stereo-matching). + +**Disparity** + +| Topic | Type | Description | +|---|---|---| +| `left/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | Supplies the color. | +| `disparity` | [`stereo_msgs/msg/DisparityImage`](https://docs.ros.org/en/jazzy/p/stereo_msgs/msg/DisparityImage.html) | `32FC1` or `16SC1`. | +| `left/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | | + +**Bundled** + +| Topic | Type | Description | +|---|---|---| +| `rgbd_image` | [`rtabmap_msgs/msg/RGBDImage`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/RGBDImage.html) | A whole frame in one message, RGB-D or stereo. No synchronization needed, so this is the most reliable input. | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | `XYZRGB`, or `XYZRGBNormal` when normals are enabled. | + +Nothing is computed unless `cloud` has a subscriber. + +## Parameters + +Identical to [point_cloud_xyz](point_cloud_xyz.md#parameters), with [`image_transport`](https://docs.ros.org/en/jazzy/p/image_transport/) added and the `Stereo*` family below. + +**Synchronization** + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `approx_sync` | `bool` | `true` | Match the inputs by nearest stamp. Set false when they share exact stamps. | +| `approx_sync_max_interval` | `double` | `0.0` | Reject sets spanning more than this many seconds. `0` disables. | +| `topic_queue_size` | `int` | `1` | Queue depth of each input subscription. | +| `sync_queue_size` | `int` | `10` | Queue depth of the synchronizer. | +| `qos` | `int` | `0` | Reliability of the image and disparity subscriptions. | +| `qos_camera_info` | `int` | value of `qos` | Reliability of the camera info subscriptions. | +| `image_transport` | `string` | `"raw"` | `image_transport` plugin for the color, left and right images. | +| `depth_transport` | `string` | `"raw"` | `image_transport` plugin for `depth/image`. | + +**Projection and filtering**, applied in this order + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `decimation` | `int` | `1` | Keep one pixel in `decimation`, in each direction. | +| `roi_ratios` | `string` | `""` | Crop before projecting, `"left right top bottom"`. **Ignored for stereo input**, which warns if you set it. | +| `min_depth` | `double` | `0.0` | Discard points nearer than this, in meters. `0` disables. | +| `max_depth` | `double` | `0.0` | Discard points further than this, in meters. `0` disables. | +| `voxel_size` | `double` | `0.0` | Downsample to one point per voxel, in meters. `0` disables. | +| `noise_filter_radius` | `double` | `0.0` | Radius outlier removal, in meters. `0` disables. | +| `noise_filter_min_neighbors` | `int` | `5` | Neighbors needed within `noise_filter_radius`. | +| `normal_k` | `int` | `0` | Estimate normals from this many neighbors. `0` disables. | +| `normal_radius` | `double` | `0.0` | Estimate normals within this radius. `0` disables. | +| `filter_nans` | `bool` | `false` | Drop invalid points instead of leaving them NaN, giving an unorganized cloud. See [point_cloud_xyz](point_cloud_xyz.md#organized-output). | + +## Stereo matching + +The stereo and `rgbd_image`-with-stereo inputs run OpenCV's block matcher, configured through RTAB-Map's `StereoBM/*` parameters, which are exposed as ROS parameters of this node: + +```bash +-p StereoBM/NumDisparities:=64 -p StereoBM/BlockSize:=15 +``` + +The full list is in RTAB-Map's [parameter reference](https://introlab.github.io/rtabmap/api/latest/parameters.html). The two that matter most are `StereoBM/NumDisparities` (must exceed the largest disparity you expect, and must not exceed the image width) and `StereoBM/BlockSize`. + +If you already have a disparity image, feed the disparity input instead — it skips the matching entirely. + +## Notes + +For RGB-D input the depth **must be registered to the color camera**: the node pairs pixel `(u,v)` of the color image with pixel `(u,v)` of the depth image and uses one calibration for both. Unregistered depth gives a cloud whose colors are offset from its geometry. Most drivers offer an aligned depth stream for this reason. + +An `rgbd_image` carrying only color and no depth is valid and yields an empty cloud rather than an error. diff --git a/rtabmap_util/doc/pointcloud_to_depthimage.md b/rtabmap_util/doc/pointcloud_to_depthimage.md new file mode 100644 index 00000000..106e0df3 --- /dev/null +++ b/rtabmap_util/doc/pointcloud_to_depthimage.md @@ -0,0 +1,96 @@ +# pointcloud_to_depthimage + +Projects a point cloud into a camera to make a depth image registered to it. + +Given a cloud (from a 3D lidar or a ToF camera) and the `camera_info` of an RGB camera, this node projects the points into that camera and outputs the depth image it would have produced if it were an RGB-D sensor: same intrinsics, same size, pixel `(u,v)` of the depth image lining up with pixel `(u,v)` of the color image. The result plugs into anything that consumes depth images: RTAB-Map's RGB-D pipeline, [`depth_image_proc`](https://docs.ros.org/en/jazzy/p/depth_image_proc/), obstacle avoidance built for depth cameras. + +Two typical setups: + +* **Lidar + one or more RGB cameras.** The natural way to feed a lidar into an RGB-D SLAM setup: the lidar supplies the geometry, the cameras the appearance. Run one instance per camera, each subscribing to the same cloud but to that camera's `camera_info`; a 3D lidar usually covers all of them at once. The resulting RGB-D streams can then be combined with [rtabmap_sync](https://docs.ros.org/en/jazzy/p/rtabmap_sync/)'s `rgbd_sync`/`rgbdx_sync` and given to RTAB-Map through its `rgbd_cameras` parameter. +* **ToF camera + RGB camera, not synchronized.** Two separate sensors, each with its own clock and its own pose, so their frames line up neither in time nor in space. Projecting the ToF cloud into the RGB camera registers the depth to the color image, and setting `fixed_frame_id` to a high-rate odometry frame — VIO, or an IMU-driven odometry running well above the camera rate — compensates the motion between the two stamps at the same time. See [Motion compensation](#motion-compensation). + +## Usage + +```bash +ros2 run rtabmap_util pointcloud_to_depthimage --ros-args \ + -r cloud:=/velodyne_points \ + -r camera_info:=/camera/color/camera_info \ + -p fixed_frame_id:=odom -p decimation:=4 -p fill_holes_size:=2 +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::PointCloudToDepthImage', + name='pointcloud_to_depthimage', + parameters=[{'fixed_frame_id': 'odom', 'decimation': 4, 'fill_holes_size': 2}], + remappings=[('cloud', '/velodyne_points'), + ('camera_info', '/camera/color/camera_info')]) +``` + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `cloud` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | The geometry to project. An empty cloud yields an all-zero image rather than nothing. | +| `camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | Defines the target camera: its intrinsics, its size, and through its `frame_id` its pose. Normally the RGB camera the depth image is being registered to. | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) (`32FC1`) | Depth in **meters**, in the camera info's frame. | +| `image_raw` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) (`16UC1`) | The same depth in **millimeters**. | +| `image/camera_info`, `image_raw/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | The input calibration, rescaled if `decimation` is set. | +| `cloud_transformed` | [`sensor_msgs/msg/PointCloud2`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/PointCloud2.html) | The input cloud in the camera frame. Debugging aid; only published when hole filling is on and something subscribes. | + +Nothing is computed unless one of the two image topics has a subscriber. + +## Required Transforms + +| Transform | Description | +|---|---| +| cloud frame → camera frame | Where the cloud's sensor sits relative to the camera. | +| `fixed_frame_id` → cloud frame, at both stamps | Only when `fixed_frame_id` is set, which is how motion between the two stamps is measured. See [Motion compensation](#motion-compensation). | + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `fixed_frame_id` | `string` | `""` | Frame the sensor's motion is measured against, usually `odom`. **Required when `approx` is true.** See [Motion compensation](#motion-compensation). | +| `approx` | `bool` | `true` | Match cloud and camera info by nearest stamp. Set false when the two share exact stamps, in which case `fixed_frame_id` is unnecessary. | +| `wait_for_transform` | `double` | `0.1` | Seconds to wait for a transform before dropping the frame. | +| `decimation` | `int` | `1` | Render at 1/`decimation` of the camera info's resolution. The published camera info is scaled to match. Must divide both the width and the height exactly, otherwise it is ignored with an error and the image comes out full size. See [Hole filling](#hole-filling). | +| `fill_holes_size` | `int` | `0` | Radius, in pixels, for filling gaps between projected points. `0` disables. See [Hole filling](#hole-filling). | +| `fill_holes_error` | `double` | `0.1` | Largest depth difference, in meters, across which a hole may be filled. | +| `fill_iterations` | `int` | `1` | How many times to repeat the filling pass. | +| `upscale` | `bool` | `false` | Interpolate the depth image back to full resolution after rendering. Only has an effect when `decimation` is greater than 1, and only needed when the consumer requires full resolution. See [Hole filling](#hole-filling). | +| `upscale_depth_error_ratio` | `double` | `0.02` | Relative depth difference tolerated across a block when upscaling. Above it the block is left empty rather than interpolated across an edge. | +| `topic_queue_size` | `int` | `10` | Queue depth of each input subscription. | +| `sync_queue_size` | `int` | `10` | Queue depth of the synchronizer. | +| `qos` | `int` | `0` | Reliability of the cloud subscription. | +| `qos_camera_info` | `int` | value of `qos` | Reliability of the camera info subscription. | + +## Motion compensation + +The cloud's sensor and the camera almost never fire at the same instant, and on a moving robot that offset matters: projecting a cloud captured 40 ms earlier into the camera's current pose puts everything in the wrong place. + +When `fixed_frame_id` is set, the node asks TF how the cloud's frame moved between the two stamps and folds that displacement into the projection, so the cloud is placed where the camera was **at its own stamp**. Driving forward at 1 m/s with a 40 ms offset moves everything 4 cm — enough to matter at close range. + +The lookup is only as good as the frame it measures against: TF interpolates between the samples it has, so the source publishing `fixed_frame_id` should run well above the sensor rate. A VIO or wheel odometry at 100+ Hz gives a meaningful displacement over a 40 ms gap; a 1 Hz SLAM output does not. + +Without `fixed_frame_id` the stamp difference is silently ignored, which is why the node logs a fatal error if `approx` is true and no fixed frame is given. If the transform cannot be found the frame is dropped rather than projected wrongly. + +## Hole filling + +A lidar cloud is far sparser than a camera image, so a direct projection is mostly gaps: individual pixels with depth, surrounded by zeros. + +**Start with `decimation`.** Rendering at a coarser resolution puts more points in each pixel, so the wide gaps between lidar rings largely disappear instead of having to be filled in afterwards. `decimation: 4` is a reasonable starting point for a 3D lidar against a full-resolution camera. The published camera info is scaled to match, so consumers that read it keep working at the smaller size. + +**Then close what is left with `fill_holes_size`.** It spreads each point over a small neighborhood, but only across depth differences smaller than `fill_holes_error`, so it fills a surface without bridging the gap between a foreground object and the wall behind it. Start at `2` and raise it only if the image is still speckled; too large and thin structures get fattened. + +`upscale` is for the specific case where the consumer needs the depth image back at the camera's full resolution — pairing it pixel-for-pixel with the full-size color image, for instance. It interpolates each decimated block bilinearly from its corners, and only where all four have depth and agree to within `upscale_depth_error_ratio`, so it stops at depth discontinuities rather than stretching a foreground object onto the wall behind it. Leave it off otherwise: it restores resolution the lidar never measured, at full-resolution cost. + +## Notes + +The output is dense in *layout* but sparse in *content*: pixels with no return are zero, the ROS convention for no reading. Consumers that assume every pixel is valid will need to handle that. diff --git a/rtabmap_util/doc/rgbd_relay.md b/rtabmap_util/doc/rgbd_relay.md new file mode 100644 index 00000000..1b2b8cb7 --- /dev/null +++ b/rtabmap_util/doc/rgbd_relay.md @@ -0,0 +1,75 @@ +# rgbd_relay + +Republishes an [`rtabmap_msgs/msg/RGBDImage`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/RGBDImage.html), optionally compressing or decompressing it on the way through. + +An `RGBDImage` can carry its images raw or compressed. This node converts between the two so that the expensive form crosses the network only where it has to: compress before a wifi link, decompress on the other side. + +With both `compress` and `uncompress` left false the message is forwarded untouched, which makes the node a plain relay — useful to give a topic a second name, or to bridge two incompatible QoS profiles with `qos_sub` and `qos_pub`. See [Bridging QoS profiles](#bridging-qos-profiles). + +## Usage + +Compress before sending over a slow link: + +```bash +ros2 run rtabmap_util rgbd_relay --ros-args \ + -r rgbd_image:=/camera/rgbd_image \ + -p compress:=true +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::RGBDRelay', + name='rgbd_relay', + parameters=[{'compress': True}], + remappings=[('rgbd_image', '/camera/rgbd_image')]) +``` + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `rgbd_image` | [`rtabmap_msgs/msg/RGBDImage`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/RGBDImage.html) | Queue depth `queue_sub`, 5 by default. | + +## Published Topics + +| Topic | Type | Description | +|---|---|---| +| `rgbd_image_relay` | [`rtabmap_msgs/msg/RGBDImage`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/RGBDImage.html) | Queue depth `queue_pub`, 1 by default. Published only when someone is subscribed. | + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `compress` | `bool` | `false` | Fill the compressed fields of the output. Color becomes JPEG; depth becomes PNG, or JPEG when the message carries a stereo pair rather than depth. Fields already compressed on input are passed through as-is. | +| `uncompress` | `bool` | `false` | Fill the raw fields of the output by decoding the compressed ones. Fields already raw on input are passed through as-is. | +| `qos` | `int` | `0` | Reliability of both sides: `0` system default, `1` reliable, `2` best effort. | +| `qos_sub` | `int` | value of `qos` | Reliability of the `rgbd_image` subscription alone. | +| `qos_pub` | `int` | value of `qos` | Reliability of the `rgbd_image_relay` publisher alone. | +| `queue_sub` | `int` | `5` | Queue depth of the `rgbd_image` subscription. Must be at least 1. | +| `queue_pub` | `int` | `1` | Queue depth of the `rgbd_image_relay` publisher. Must be at least 1. | + +## Bridging QoS profiles + +A subscriber that asks for **reliable** will not connect to a publisher offering **best effort** — the request cannot be satisfied, so the two silently never match. A best-effort subscriber, on the other hand, connects to either. + +That is a real problem when a camera driver publishes best effort and the consumer insists on reliable. Set the two sides of the relay separately and it forwards across the gap: + +```bash +ros2 run rtabmap_util rgbd_relay --ros-args \ + -r rgbd_image:=/camera/rgbd_image \ + -p qos_sub:=2 \ + -p qos_pub:=1 +``` + +Both parameters default to `qos`, so setting `qos` alone configures both sides at once. + +Reliability is all that is bridged — durability is left at the default, so a transient-local publisher is not converted. The queue depths are separate too, through `queue_sub` and `queue_pub`. + +## Notes + +Setting neither `compress` nor `uncompress` forwards the message unchanged and skips all image handling — the cheapest path by a wide margin. + +Setting both is allowed and produces a message carrying each image twice, raw and compressed. That is rarely what you want. + +Depth is compressed as **PNG**, a stereo right image as **JPEG**. diff --git a/rtabmap_util/doc/rgbd_split.md b/rtabmap_util/doc/rgbd_split.md new file mode 100644 index 00000000..f968a31a --- /dev/null +++ b/rtabmap_util/doc/rgbd_split.md @@ -0,0 +1,76 @@ +# rgbd_split + +Splits an [`rtabmap_msgs/msg/RGBDImage`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/RGBDImage.html) back into the standard ROS image topics. + +`RGBDImage` bundles color, depth and both camera infos into one message so they arrive together, which is what RTAB-Map wants. Everything else in the ROS ecosystem — RViz, `image_view`, [`depth_image_proc`](https://docs.ros.org/en/jazzy/p/depth_image_proc/) — expects separate `Image` and `CameraInfo` topics. This node unpacks the bundle for them. + +It is the inverse of [rtabmap_sync](https://docs.ros.org/en/jazzy/p/rtabmap_sync/)'s `rgbd_sync`, and of its `stereo_sync` when `stereo` is set — those two are what produce an `RGBDImage` in the first place. + +## Usage + +```bash +ros2 run rtabmap_util rgbd_split --ros-args -r rgbd_image:=/camera/rgbd_image +``` + +```python +ComposableNode( + package='rtabmap_util', + plugin='rtabmap_util::RGBDSplit', + name='rgbd_split', + remappings=[('rgbd_image', '/camera/rgbd_image')]) +``` + +## Subscribed Topics + +| Topic | Type | Description | +|---|---|---| +| `rgbd_image` | [`rtabmap_msgs/msg/RGBDImage`](https://docs.ros.org/en/jazzy/p/rtabmap_msgs/msg/RGBDImage.html) | Queue depth `queue_sub`, 5 by default. Raw or compressed images are both accepted. | + +## Published Topics + +The output topics are named after the **resolved** input topic, so remapping `rgbd_image` moves the outputs with it. With `rgbd_image` remapped to `/camera/rgbd_image` they are `/camera/rgbd_image/rgb/image` and so on. Setting `stereo: true` renames the two halves `left` and `right`, see [Stereo messages](#stereo-messages). + +| Topic | Type | Description | +|---|---|---| +| `/rgb/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | The color image, decompressed if needed. | +| `/rgb/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | | +| `/depth/image` | [`sensor_msgs/msg/Image`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/Image.html) | The depth image, or the right image of a stereo pair. | +| `/depth/camera_info` | [`sensor_msgs/msg/CameraInfo`](https://docs.ros.org/en/jazzy/p/sensor_msgs/msg/CameraInfo.html) | For a stereo pair this is the right camera, and its `P(0,3)` carries the baseline. | + +Each half is only unpacked if something is subscribed to it, so subscribing to color alone does not pay for depth decompression. + +## Parameters + +| Parameter | Type | Default | Description | +|---|---|---|---| +| `qos` | `int` | `0` | Reliability of both sides: `0` system default, `1` reliable, `2` best effort. | +| `qos_sub` | `int` | value of `qos` | Reliability of the `rgbd_image` subscription alone. | +| `qos_pub` | `int` | value of `qos` | Reliability of the four output publishers alone. | +| `queue_sub` | `int` | `5` | Queue depth of the `rgbd_image` subscription. Must be at least 1. | +| `queue_pub` | `int` | `1` | Queue depth of every publisher. Must be at least 1. | +| `stereo` | `bool` | `false` | Name the outputs `left`/`right` instead of `rgb`/`depth`. See [Stereo messages](#stereo-messages). | + +## Stereo messages + +The node handles **stereo** `RGBDImage` messages as well as RGB-D ones. In a stereo message the "depth" slot holds the right image, and the second camera info carries the baseline; the depth topics then carry the right camera, correctly typed as `mono8` or `bgr8` rather than mislabeled as depth. + +That works, but the topic names lie. Set `stereo: true` and the outputs are named for what they hold: + +| `stereo` | Output topics | +|---|---| +| `false` (default) | `/rgb/image`, `/rgb/camera_info`, `/depth/image`, `/depth/camera_info` | +| `true` | `/left/image`, `/left/camera_info`, `/right/image`, `/right/camera_info` | + +```bash +ros2 run rtabmap_util rgbd_split --ros-args \ + -r rgbd_image:=/camera/rgbd_image \ + -p stereo:=true +``` + +Only the names change — the message contents and the order of the two halves are the same either way, so the `rgb` slot always becomes the left image. The two namings are exclusive: with `stereo: true` nothing is published on `rgb`/`depth`. + +The node checks the setting against what actually arrives, going by the encoding of the second half: `16UC1`, `32FC1` and `mono16` are depth, anything else is an image. If the two disagree it logs a warning **once** and keeps forwarding — a mismatch makes the topic name misleading, not the data wrong, so it is never worth dropping a frame over. + +## Notes + +If a message has no `frame_id` on one of its sub-messages, the node fills it in from the other one so the output is always usable by TF. diff --git a/rtabmap_util/include/rtabmap_util/MapsManager.h b/rtabmap_util/include/rtabmap_util/MapsManager.h index f6dd3c61..b144fab5 100644 --- a/rtabmap_util/include/rtabmap_util/MapsManager.h +++ b/rtabmap_util/include/rtabmap_util/MapsManager.h @@ -57,22 +57,131 @@ class GridMap; namespace rtabmap_util { +/** + * @brief Turns a pose graph into the map topics, and publishes them. + * + * Given a set of node poses and the sensor data behind them, MapsManager assembles the + * ground and obstacle point clouds, the 2D occupancy grid, the octomap and the elevation + * map, and publishes whichever of them somebody is subscribed to. + * + * It is shared by rtabmap_slam's `rtabmap` node and rtabmap_util's `map_assembler`, which + * is why those two produce identical maps from identical parameters. + * + * @par Lifecycle + * Callers follow a fixed order: + * 1. init() to declare the ROS parameters and advertise the topics, + * 2. backwardCompatibilityParameters() to pick up parameters that have since moved into + * the RTAB-Map library, then setParameters() to apply the whole set, + * 3. updateMapCaches() whenever the graph changes, then publishMaps(). + * + * @par Laziness + * Nothing is assembled or published without a subscriber, and with `map_cleanup` set the + * caches are released once the last one goes away. A node can therefore call + * updateMapCaches() and publishMaps() unconditionally on every graph update and pay + * nothing while nobody is listening. + * + */ class MapsManager { public: MapsManager(); virtual ~MapsManager(); + + /** + * @brief Declares the ROS parameters and advertises the map topics on @p node. + * + * Must be called before anything else, and exactly once per node: the parameters are + * declared here, and declaring them twice throws. + * + * @param node node to advertise on and read parameters from + * @param name prefix used in the log lines, normally the node's name + * @param usePublicNamespace unused, kept for source compatibility + */ void init(rclcpp::Node & node, const std::string & name, bool usePublicNamespace); + + /// Drops every cached local grid, assembled cloud and global map. void clear(); + + /// @return True if any map topic has at least one subscriber. bool hasSubscribers() const; + + /// @return True if the map topics are latched, i.e. delivered to late subscribers. bool isLatching() const {return latching_;} + + /** + * @brief Whether the map changed on the last updateMapCaches(). + * + * @note Reports true when nothing is subscribed to the grid topics. The answer comes + * from OccupancyGrid::update(), which only runs when a grid is wanted, so with + * nobody listening the safe assumption is that the graph moved. + */ bool isMapUpdated() const; + + /** + * @brief Copies parameters that moved from rtabmap_ros into the RTAB-Map library. + * + * Reads the old ROS parameter names off @p node and, for each one that is set, writes + * its value into @p parameters under the RTAB-Map name that replaced it, with a + * warning. Call it before setParameters(). + * + * @param[in] node node to read the legacy parameters from + * @param[in,out] parameters parameter set to fill in + */ void backwardCompatibilityParameters(rclcpp::Node & node, rtabmap::ParametersMap & parameters) const; + + /** + * @brief Applies the RTAB-Map parameters, rebuilding the map objects. + * + * The occupancy grid, octomap and elevation map are recreated, so anything already + * assembled is lost; the cached local grids are kept. + */ void setParameters(const rtabmap::ParametersMap & parameters); + + /** + * @brief Installs an already assembled 2D map, e.g. one loaded from a database. + * + * @param map the grid, `CV_8SC1` with -1 unknown, 0 free, 100 occupied + * @param xMin world x of the map's origin, in meters + * @param yMin world y of the map's origin, in meters + * @param cellSize resolution, in meters + * @param poses poses of the nodes @p map was assembled from + * @param memory optional memory to load the missing local grids from, so the map can + * keep growing from where it left off + * + * @warning @p poses must not be empty. The grid is kept only together with the nodes + * it came from, so that the manager knows which are already in it; a call + * with no poses is ignored with a warning. + */ void set2DMap(const cv::Mat & map, float xMin, float yMin, float cellSize, const std::map & poses, const rtabmap::Memory * memory = 0); + /** + * @brief Applies the `map_filter_radius`/`map_filter_angle` thinning to @p poses. + * @return The poses that survive, or all of them when filtering is disabled. + */ std::map getFilteredPoses( const std::map & poses); + /** + * @brief Brings the local grid cache and the global maps up to date with the graph. + * + * For every pose not already mapped, the local occupancy grid is taken from the + * signature or the memory, or regenerated from the sensor data when the node carries + * none, and added to the cache. The global maps are then reassembled. + * + * @param poses node poses; landmarks (negative ids) are ignored, and id 0 is + * the not-yet-committed node, kept only if `map_always_update` + * @param memory memory to load node data from, may be null if @p signatures + * carries everything + * @param updateGrid force the occupancy grid to be updated + * @param updateOctomap force the octomap to be updated + * @param signatures node data, keyed by id, for nodes not in @p memory + * @return The poses actually mapped, after filtering. + * + * @note With @p updateGrid and @p updateOctomap both false, what gets updated is + * decided by which topics have subscribers. That is also the only way the + * elevation map is ever built, as it has no flag of its own. + * @note At least one of @p memory and @p signatures must be non-empty, and @p poses + * must not be empty; otherwise an error is logged and nothing is returned. + */ std::map updateMapCaches( const std::map & poses, const rtabmap::Memory * memory, @@ -80,25 +189,49 @@ public: bool updateOctomap, const std::map & signatures = std::map()); + /** + * @brief Publishes every map topic that has a subscriber. + * + * @param poses the same poses updateMapCaches() returned + * @param stamp stamp for all published messages + * @param mapFrameId frame id for all published messages + */ void publishMaps( const std::map & poses, const rclcpp::Time & stamp, const std::string & mapFrameId); + /** + * @brief The 2D occupancy grid as a ternary map. + * @param[out] xMin world x of the map's origin, in meters + * @param[out] yMin world y of the map's origin, in meters + * @param[out] gridCellSize resolution, in meters + * @return `CV_8SC1`, -1 unknown, 0 free, 100 occupied. Empty if nothing is assembled. + */ cv::Mat getGridMap( float & xMin, float & yMin, float & gridCellSize); + /** + * @brief The 2D occupancy grid as probabilities. + * @param[out] xMin world x of the map's origin, in meters + * @param[out] yMin world y of the map's origin, in meters + * @param[out] gridCellSize resolution, in meters + * @return `CV_8SC1`, -1 unknown, otherwise 0-100. Empty if nothing is assembled. + */ cv::Mat getGridProbMap( float & xMin, float & yMin, float & gridCellSize); #ifdef RTABMAP_OCTOMAP + /// @return The octomap, owned by this object. Never null. const rtabmap::OctoMap * getOctomap() const {return octomap_;} #endif + /// @return The global occupancy grid, owned by this object. Never null. const rtabmap::OccupancyGrid * getOccupancyGrid() const {return occupancyGrid_;} + /// @return The local grid segmenter, owned by this object. Never null. const rtabmap::LocalGridMaker * getLocalMapMaker() const {return localMapMaker_;} private: diff --git a/rtabmap_util/include/rtabmap_util/map_assembler.hpp b/rtabmap_util/include/rtabmap_util/map_assembler.hpp index 3c874ee0..7972d57f 100644 --- a/rtabmap_util/include/rtabmap_util/map_assembler.hpp +++ b/rtabmap_util/include/rtabmap_util/map_assembler.hpp @@ -62,6 +62,9 @@ private: void timerCallback(); + /// Subscribes to "mapData"; the node is live from here on. + void subscribeToMapData(); + #ifdef WITH_OCTOMAP_MSGS #ifdef RTABMAP_OCTOMAP void octomapBinaryCallback( @@ -100,6 +103,7 @@ private: #endif #endif bool localGridsRegenerated_; + double initializeFromRtabmapTimeout_; }; } \ No newline at end of file diff --git a/rtabmap_util/include/rtabmap_util/point_cloud_aggregator.hpp b/rtabmap_util/include/rtabmap_util/point_cloud_aggregator.hpp index ff72354a..6ae2820d 100644 --- a/rtabmap_util/include/rtabmap_util/point_cloud_aggregator.hpp +++ b/rtabmap_util/include/rtabmap_util/point_cloud_aggregator.hpp @@ -36,6 +36,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include #include #include +#include namespace rtabmap_util { @@ -66,8 +67,7 @@ private: const sensor_msgs::msg::PointCloud2::ConstSharedPtr cloudMsg_2); void combineClouds(const std::vector & cloudMsgs); - std::thread * warningThread_; - bool callbackCalled_; + std::unique_ptr syncDiagnostic_; typedef message_filters::sync_policies::ExactTime ExactSync4Policy; typedef message_filters::sync_policies::ApproximateTime ApproxSync4Policy; diff --git a/rtabmap_util/include/rtabmap_util/point_cloud_assembler.hpp b/rtabmap_util/include/rtabmap_util/point_cloud_assembler.hpp index cc756894..359cccbe 100644 --- a/rtabmap_util/include/rtabmap_util/point_cloud_assembler.hpp +++ b/rtabmap_util/include/rtabmap_util/point_cloud_assembler.hpp @@ -40,6 +40,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include #include #include +#include namespace rtabmap_util { @@ -73,8 +74,7 @@ private: void callbackCloud(const sensor_msgs::msg::PointCloud2::ConstSharedPtr cloudMsg); private: - std::thread * warningThread_; - bool callbackCalled_; + std::unique_ptr syncDiagnostic_; rclcpp::Subscription::SharedPtr cloudSub_; rclcpp::Publisher::SharedPtr cloudPub_; diff --git a/rtabmap_util/include/rtabmap_util/rgbd_split.hpp b/rtabmap_util/include/rtabmap_util/rgbd_split.hpp index d31a736a..6478dc5e 100644 --- a/rtabmap_util/include/rtabmap_util/rgbd_split.hpp +++ b/rtabmap_util/include/rtabmap_util/rgbd_split.hpp @@ -48,6 +48,9 @@ public: void callback(const rtabmap_msgs::msg::RGBDImage::SharedPtr input) const; private: + /// True when the outputs are named left/right rather than rgb/depth. + bool stereo_; + rclcpp::Subscription::SharedPtr rgbdImageSub_; image_transport::Publisher rgbPub_; diff --git a/rtabmap_util/package.xml b/rtabmap_util/package.xml index 35bc2f09..a349e6d1 100644 --- a/rtabmap_util/package.xml +++ b/rtabmap_util/package.xml @@ -36,7 +36,10 @@ rtabmap_sync grid_map_ros + ament_cmake_gtest + ament_cmake + rosdoc2.yaml diff --git a/rtabmap_util/rosdoc2.yaml b/rtabmap_util/rosdoc2.yaml new file mode 100644 index 00000000..6b7fbdda --- /dev/null +++ b/rtabmap_util/rosdoc2.yaml @@ -0,0 +1,35 @@ +## Configuration for rosdoc2, the documentation generator used by docs.ros.org. +## Regenerate the annotated default with: +## rosdoc2 default_config --package-path rtabmap_util +## Build the docs locally with: +## rosdoc2 build --package-path rtabmap_util --output-directory doc_output + +## This 'attic section' self-documents this file's type and version. +type: 'rosdoc2 config' +version: 1 + +--- + +settings: + ## Generate the standard index page from package.xml (description, maintainer, + ## license, links) and a table of contents for the builders below. + generate_package_index: true + + ## This is an ament_cmake package, so doxygen runs on the public headers by + ## default and there are no Python modules to document. + always_run_doxygen: false + always_run_sphinx_apidoc: false + +builders: + ## Doxygen parses the public C++ API out of include/. + - doxygen: { + name: 'rtabmap_util Public C/C++ API', + output_dir: 'generated/doxygen' + } + ## Sphinx renders the landing page and pulls the Doxygen XML in through + ## breathe/exhale so the API is browsable alongside the narrative docs. + - sphinx: { + name: 'rtabmap_util', + doxygen_xml_directory: 'generated/doxygen/xml', + output_dir: '' + } diff --git a/rtabmap_util/src/DbPlayerNode.cpp b/rtabmap_util/src/DbPlayerNode.cpp index cd892cea..9b178149 100644 --- a/rtabmap_util/src/DbPlayerNode.cpp +++ b/rtabmap_util/src/DbPlayerNode.cpp @@ -29,6 +29,8 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include "rtabmap/utilite/ULogger.h" #include "rclcpp/rclcpp.hpp" +#include + #ifndef _WIN32 #include #include @@ -83,7 +85,19 @@ int main(int argc, char **argv) rclcpp::NodeOptions options; options.arguments(arguments); - auto node = std::make_shared(options); + std::shared_ptr node; + try + { + node = std::make_shared(options); + } + catch(const std::exception & e) + { + // The node reports what went wrong before throwing; keep the process exit clean + // rather than letting an uncaught exception abort. + UERROR("%s", e.what()); + rclcpp::shutdown(); + return -1; + } rclcpp::Rate pauseRate(10); diff --git a/rtabmap_util/src/MapsManager.cpp b/rtabmap_util/src/MapsManager.cpp index 19c1a9a2..b142d14f 100644 --- a/rtabmap_util/src/MapsManager.cpp +++ b/rtabmap_util/src/MapsManager.cpp @@ -273,6 +273,12 @@ void MapsManager::set2DMap( const std::map & poses, const rtabmap::Memory * memory) { + if(!map.empty() && poses.empty()) + { + UWARN("Ignoring the 2D map (%dx%d): no poses were given. Pass the poses of the " + "nodes the map was assembled from.", map.cols, map.rows); + return; + } occupancyGrid_->setMap(map, xMin, yMin, cellSize, poses); //update cache in case the map should be updated if(memory && @@ -1420,6 +1426,7 @@ void MapsManager::publishMaps( msg->header.frame_id = mapFrameId; msg->header.stamp = stamp; elevationMapPub_->publish(std::move(msg)); + latched_.at(&elevationMapPub_) = true; } if(elevationMapPub_->get_subscription_count() == 0) { diff --git a/rtabmap_util/src/nodelets/db_player.cpp b/rtabmap_util/src/nodelets/db_player.cpp index 167af087..cacd6796 100644 --- a/rtabmap_util/src/nodelets/db_player.cpp +++ b/rtabmap_util/src/nodelets/db_player.cpp @@ -28,6 +28,8 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include #include +#include + #include #include @@ -106,13 +108,14 @@ DbPlayer::DbPlayer(const rclcpp::NodeOptions & options) : qosGlobalPose_ = this->declare_parameter("qos_global_pose", qos_); qosGps_ = this->declare_parameter("qos_gps", qos_); qosImu_ = this->declare_parameter("qos_imu", qos_); + qosEnvSensor_ = this->declare_parameter("qos_env_sensor", qos_); // A general 360 lidar with 0.5 deg increment scanAngleMin_ = this->declare_parameter("scan_angle_min", -M_PI); scanAngleMax_ = this->declare_parameter("scan_angle_max", M_PI); scanAngleIncrement_ = this->declare_parameter("scan_angle_increment", M_PI / 720.0); scanRangeMin_ = this->declare_parameter("scan_range_min", 0.0); - scanRangeMax_ = this->declare_parameter("scan_range_max", 60); + scanRangeMax_ = this->declare_parameter("scan_range_max", 60.0); RCLCPP_INFO(get_logger(), "frame_id = %s", frameId_.c_str()); RCLCPP_INFO(get_logger(), "odom_frame_id = %s", odomFrameId_.c_str()); @@ -136,8 +139,11 @@ DbPlayer::DbPlayer(const rclcpp::NodeOptions & options) : if(databasePath.empty()) { + // Throwing rather than exiting: this node can be loaded in a component container + // next to others, and taking the whole process down with it would be rude. RCLCPP_ERROR(get_logger(), "Parameter \"database\" must be set (path to a RTAB-Map database)."); - exit(-1); + throw std::invalid_argument( + "db_player: parameter \"database\" must be set (path to a RTAB-Map database)."); } databasePath = uReplaceChar(databasePath, '~', UDirectory::homeDir()); @@ -151,7 +157,8 @@ DbPlayer::DbPlayer(const rclcpp::NodeOptions & options) : if(!reader_->init()) { RCLCPP_ERROR(get_logger(), "Cannot open database \"%s\".", databasePath.c_str()); - exit(-1); + throw std::runtime_error( + uFormat("db_player: cannot open database \"%s\".", databasePath.c_str())); } const std::string servicePrefix = get_name() + std::string("/"); @@ -300,8 +307,12 @@ void DbPlayer::initializePublishers(const rtabmap::OdometryEvent & odom) if(!odom.data().laserScanRaw().isEmpty()) { - if(!scanPub_.get() && odom.data().laserScanRaw().is2d()) + // The publisher has to match the scan being replayed, not just whichever one has + // not been created yet: a 2D database must never advertise "scan_cloud". + if(odom.data().laserScanRaw().is2d()) { + if(!scanPub_.get()) + { scanPub_ = this->create_publisher("scan", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qosScan_)); if(odom.data().laserScanRaw().angleIncrement() > 0.0f) { @@ -316,6 +327,7 @@ void DbPlayer::initializePublishers(const rtabmap::OdometryEvent & odom) RCLCPP_INFO(get_logger(), " scan_range_min=%f", scanRangeMin_); RCLCPP_INFO(get_logger(), " scan_range_max=%f", scanRangeMax_); } + } } else if(!scanCloudPub_.get()) { @@ -569,7 +581,6 @@ bool DbPlayer::publishNextFrame() envSensorPub_->get_subscription_count() > 0 && !odom.data().envSensors().empty()) { - rtabmap_msgs::msg::EnvSensor msg; for(rtabmap::EnvSensors::const_iterator iter=odom.data().envSensors().begin(); iter!=odom.data().envSensors().end(); ++iter) { rtabmap_msgs::msg::EnvSensor msg; diff --git a/rtabmap_util/src/nodelets/disparity_to_depth.cpp b/rtabmap_util/src/nodelets/disparity_to_depth.cpp index e537b313..3c582c7f 100644 --- a/rtabmap_util/src/nodelets/disparity_to_depth.cpp +++ b/rtabmap_util/src/nodelets/disparity_to_depth.cpp @@ -28,6 +28,9 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include #include +#include +#include + #include #ifdef PRE_ROS_IRON @@ -44,16 +47,27 @@ DisparityToDepth::DisparityToDepth(const rclcpp::NodeOptions & options) : { int qos = RMW_QOS_POLICY_RELIABILITY_SYSTEM_DEFAULT; qos = this->declare_parameter("qos", qos); + // Each side can be set independently so the node can bridge a producer and a + // consumer that don't agree on reliability. Both default to qos. + int qosSub = this->declare_parameter("qos_sub", qos); + int qosPub = this->declare_parameter("qos_pub", qos); + int queueSub = this->declare_parameter("queue_sub", 1); + int queuePub = this->declare_parameter("queue_pub", 1); + + UASSERT_MSG(queueSub >= 1 && queuePub >= 1, + uFormat("queue_sub (%d) and queue_pub (%d) must be at least 1", queueSub, queuePub).c_str()); + + const rclcpp::QoS pubQos = rclcpp::QoS(queuePub).reliability((rmw_qos_reliability_policy_t)qosPub); #ifdef PRE_ROS_LYRICAL - pub32f_ = image_transport::create_publisher(this, "depth", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos).get_rmw_qos_profile()); - pub16u_ = image_transport::create_publisher(this, "depth_raw", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos).get_rmw_qos_profile()); + pub32f_ = image_transport::create_publisher(this, "depth", pubQos.get_rmw_qos_profile()); + pub16u_ = image_transport::create_publisher(this, "depth_raw", pubQos.get_rmw_qos_profile()); #else - pub32f_ = image_transport::create_publisher(*this, "depth", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos)); - pub16u_ = image_transport::create_publisher(*this, "depth_raw", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos)); + pub32f_ = image_transport::create_publisher(*this, "depth", pubQos); + pub16u_ = image_transport::create_publisher(*this, "depth_raw", pubQos); #endif - sub_ = create_subscription("disparity", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos), std::bind(&DisparityToDepth::callback, this, std::placeholders::_1)); + sub_ = create_subscription("disparity", rclcpp::QoS(queueSub).reliability((rmw_qos_reliability_policy_t)qosSub), std::bind(&DisparityToDepth::callback, this, std::placeholders::_1)); } DisparityToDepth::~DisparityToDepth(){} diff --git a/rtabmap_util/src/nodelets/lidar_deskewing.cpp b/rtabmap_util/src/nodelets/lidar_deskewing.cpp index d15e189b..518b6f8e 100644 --- a/rtabmap_util/src/nodelets/lidar_deskewing.cpp +++ b/rtabmap_util/src/nodelets/lidar_deskewing.cpp @@ -61,7 +61,7 @@ void LidarDeskewing::callbackScan(const sensor_msgs::msg::LaserScan::ConstShared msg->header.frame_id, fixedFrameId_, msg->header.stamp, - rclcpp::Time(msg->header.stamp.sec, msg->header.stamp.nanosec) + rclcpp::Duration::from_seconds(msg->ranges.size()*msg->time_increment), + rclcpp::Time(msg->header.stamp.sec, msg->header.stamp.nanosec) + rclcpp::Duration::from_seconds((msg->ranges.empty()?0:msg->ranges.size()-1)*msg->time_increment), *tfBuffer_, waitForTransformDuration_); if(tmpT.isNull()) diff --git a/rtabmap_util/src/nodelets/map_assembler.cpp b/rtabmap_util/src/nodelets/map_assembler.cpp index 07e91ad7..40a01570 100644 --- a/rtabmap_util/src/nodelets/map_assembler.cpp +++ b/rtabmap_util/src/nodelets/map_assembler.cpp @@ -54,12 +54,18 @@ MapAssembler::MapAssembler(const rclcpp::NodeOptions & options) : Node("map_assembler", options), lastNodeAdded_(-1), rtabmapNodeName_("rtabmap"), - localGridsRegenerated_(false) + localGridsRegenerated_(false), + initializeFromRtabmapTimeout_(5.0) { std::string configPath; configPath = this->declare_parameter("config_path", configPath); localGridsRegenerated_ = this->declare_parameter("regenerate_local_grids", localGridsRegenerated_); rtabmapNodeName_ = this->declare_parameter("rtabmap", rtabmapNodeName_); + // Seconds to wait for rtabmap's get_map_data service on start-up, which is how + // map_assembler catches up on a map that already exists. Set it to 0 to skip the call + // entirely: the subscription to "mapData" is then created right away instead of after + // the wait, which is what you want when map_assembler starts before rtabmap. + initializeFromRtabmapTimeout_ = this->declare_parameter("initialize_from_rtabmap_timeout", initializeFromRtabmapTimeout_); //parameters rtabmap::ParametersMap parameters; @@ -175,6 +181,7 @@ MapAssembler::MapAssembler(const rclcpp::NodeOptions & options) : } RCLCPP_INFO(this->get_logger(), "%s: regenerate_local_grids = %s", this->get_name(), localGridsRegenerated_?"true":"false"); + RCLCPP_INFO(this->get_logger(), "%s: initialize_from_rtabmap_timeout = %fs (0=don't ask rtabmap for the map)", this->get_name(), initializeFromRtabmapTimeout_); mapsManager_.init(*this, this->get_name(), true); mapsManager_.backwardCompatibilityParameters(*this, parameters); mapsManager_.setParameters(parameters); @@ -189,13 +196,29 @@ MapAssembler::MapAssembler(const rclcpp::NodeOptions & options) : #endif #endif - std::string getMapSrv = rtabmapNodeName_+"/get_map_data"; + if(initializeFromRtabmapTimeout_ > 0.0) + { + std::string getMapSrv = rtabmapNodeName_+"/get_map_data"; - // We cannot call the service and wait in the constructor, lets call it later and subscribe afterwards - serviceCbGroup_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive); - timerCbGroup_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive); - client_ = this->create_client(getMapSrv, rclcpp::ServicesQoS(), serviceCbGroup_); // Put it in a different group than the timer - timer_ = this->create_wall_timer(1s, std::bind(&MapAssembler::timerCallback, this), timerCbGroup_); + // We cannot call the service and wait in the constructor, lets call it later and subscribe afterwards + serviceCbGroup_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive); + timerCbGroup_ = this->create_callback_group(rclcpp::CallbackGroupType::MutuallyExclusive); + client_ = this->create_client(getMapSrv, rclcpp::ServicesQoS(), serviceCbGroup_); // Put it in a different group than the timer + timer_ = this->create_wall_timer(1s, std::bind(&MapAssembler::timerCallback, this), timerCbGroup_); + } + else + { + subscribeToMapData(); + } +} + +void MapAssembler::subscribeToMapData() +{ + rclcpp::SubscriptionOptions options; + // Null unless we came through the timer, in which case the node's default group is used. + options.callback_group = timerCbGroup_; + mapDataSub_ = create_subscription("mapData", rclcpp::QoS(1), + std::bind(&MapAssembler::mapDataReceivedCallback, this, std::placeholders::_1), options); } MapAssembler::~MapAssembler() {} @@ -213,7 +236,8 @@ void MapAssembler::timerCallback() std::string getMapSrv = rtabmapNodeName_+"/get_map_data"; RCLCPP_INFO(this->get_logger(), "Calling service \"%s\"...", getMapSrv.c_str()); - if(client_->wait_for_service(5s)) + if(client_->wait_for_service( + std::chrono::duration(initializeFromRtabmapTimeout_))) { auto request = std::make_shared(); request->global_map = false; @@ -237,18 +261,16 @@ void MapAssembler::timerCallback() } else { - RCLCPP_WARN(this->get_logger(), "Service \"%s\" not available after waiting for 5 seconds, " + RCLCPP_WARN(this->get_logger(), "Service \"%s\" not available after waiting for %f seconds, " "may not be a problem if rtabmap is started afterwards. If rtabmap " "is started after in localization mode, call %s/publish_maps " "service with graph_only=false to make sure map_assembler has all the data.", getMapSrv.c_str(), + initializeFromRtabmapTimeout_, rtabmapNodeName_.c_str()); } - rclcpp::SubscriptionOptions options; - options.callback_group = timerCbGroup_; - mapDataSub_ = create_subscription("mapData", rclcpp::QoS(1), - std::bind(&MapAssembler::mapDataReceivedCallback, this, std::placeholders::_1), options); + subscribeToMapData(); } void MapAssembler::mapDataReceivedCallback(const rtabmap_msgs::msg::MapData::ConstSharedPtr msg) diff --git a/rtabmap_util/src/nodelets/point_cloud_aggregator.cpp b/rtabmap_util/src/nodelets/point_cloud_aggregator.cpp index a8cc5333..3b95c71b 100644 --- a/rtabmap_util/src/nodelets/point_cloud_aggregator.cpp +++ b/rtabmap_util/src/nodelets/point_cloud_aggregator.cpp @@ -41,8 +41,6 @@ namespace rtabmap_util PointCloudAggregator::PointCloudAggregator(const rclcpp::NodeOptions & options) : Node("point_cloud_aggregator", options), - warningThread_(0), - callbackCalled_(false), exactSync4_(0), approxSync4_(0), exactSync3_(0), @@ -162,23 +160,16 @@ PointCloudAggregator::PointCloudAggregator(const rclcpp::NodeOptions & options) } - warningThread_ = new std::thread([&](){ - rclcpp::Rate r(1.0/5.0); - while(!callbackCalled_) - { - r.sleep(); - if(!callbackCalled_) - { - RCLCPP_WARN(this->get_logger(), "%s: Did not receive data since 5 seconds! Make sure the input topics are " - "published (\"$ ros2 topic hz my_topic\") and the timestamps in their " - "header are set. %s%s", - this->get_name(), - approx?"":"Parameter \"approx_sync\" is false, which means that input " - "topics should have all the exact timestamp for the callback to be called.", - subscribedTopicsMsg.c_str()); - } - } - }); + syncDiagnostic_.reset(new rtabmap_sync::SyncDiagnostic(this, 0.5)); + syncDiagnostic_->init(cloudSub_1_.getSubscriber()->get_topic_name(), + uFormat("%s: Did not receive data since 5 seconds! Make sure the input topics are " + "published (\"$ ros2 topic hz my_topic\") and the timestamps in their " + "header are set. %s%s", + this->get_name(), + approx?"":"Parameter \"approx_sync\" is false, which means that input " + "topics should have all the exact timestamp for the callback to be called.", + subscribedTopicsMsg.c_str())); + RCLCPP_INFO(this->get_logger(), "%s", subscribedTopicsMsg.c_str()); } @@ -190,13 +181,6 @@ PointCloudAggregator::~PointCloudAggregator() delete approxSync3_; delete exactSync2_; delete approxSync2_; - - if(warningThread_) - { - callbackCalled_=true; - warningThread_->join(); - delete warningThread_; - } } void PointCloudAggregator::clouds4_callback(const sensor_msgs::msg::PointCloud2::ConstSharedPtr cloudMsg_1, @@ -234,8 +218,8 @@ void PointCloudAggregator::clouds2_callback(const sensor_msgs::msg::PointCloud2: } void PointCloudAggregator::combineClouds(const std::vector & cloudMsgs) { - callbackCalled_ = true; UASSERT(cloudMsgs.size() > 1); + syncDiagnostic_->tickInput(cloudMsgs[0]->header.stamp); if(cloudPub_->get_subscription_count()) { pcl::PCLPointCloud2::Ptr output(new pcl::PCLPointCloud2); @@ -420,6 +404,7 @@ void PointCloudAggregator::combineClouds(const std::vectorheader.frame_id = frameId; cloudPub_->publish(std::move(rosCloud)); } + syncDiagnostic_->tickOutput(cloudMsgs[0]->header.stamp); } } diff --git a/rtabmap_util/src/nodelets/point_cloud_assembler.cpp b/rtabmap_util/src/nodelets/point_cloud_assembler.cpp index 81302ac1..975dec03 100644 --- a/rtabmap_util/src/nodelets/point_cloud_assembler.cpp +++ b/rtabmap_util/src/nodelets/point_cloud_assembler.cpp @@ -45,8 +45,6 @@ namespace rtabmap_util PointCloudAssembler::PointCloudAssembler(const rclcpp::NodeOptions & options) : Node("point_cloud_assembler", options), - warningThread_(0), - callbackCalled_(false), exactSync_(0), exactInfoSync_(0), maxClouds_(0), @@ -170,22 +168,14 @@ PointCloudAssembler::PointCloudAssembler(const rclcpp::NodeOptions & options) : syncOdomSub_.getSubscriber()->get_topic_name()); } - warningThread_ = new std::thread([&](){ - rclcpp::Rate r(1.0/5.0); - while(!callbackCalled_) - { - r.sleep(); - if(!callbackCalled_) - { - RCLCPP_WARN(this->get_logger(), - "%s: Did not receive data since 5 seconds! Make sure the input topics are " - "published (\"$ ros2 topic hz my_topic\") and the timestamps in their " - "header are set. %s", - get_name(), - subscribedTopicsMsg_.c_str()); - } - } - }); + syncDiagnostic_.reset(new rtabmap_sync::SyncDiagnostic(this, 0.5)); + syncDiagnostic_->init( + cloudSub_?cloudSub_->get_topic_name():syncCloudSub_.getSubscriber()->get_topic_name(), + uFormat("%s: Did not receive data since 5 seconds! Make sure the input topics are " + "published (\"$ ros2 topic hz my_topic\") and the timestamps in their " + "header are set. %s", + get_name(), + subscribedTopicsMsg_.c_str())); RCLCPP_INFO(this->get_logger(), "%s", subscribedTopicsMsg_.c_str()); } @@ -194,20 +184,12 @@ PointCloudAssembler::~PointCloudAssembler() { delete exactSync_; delete exactInfoSync_; - - if(warningThread_) - { - callbackCalled_=true; - warningThread_->join(); - delete warningThread_; - } } void PointCloudAssembler::callbackCloudOdom( const sensor_msgs::msg::PointCloud2::ConstSharedPtr cloudMsg, const nav_msgs::msg::Odometry::ConstSharedPtr odomMsg) { - callbackCalled_ = true; rtabmap::Transform odom = rtabmap_conversions::transformFromPoseMsg(odomMsg->pose.pose); if(!odom.isNull()) { @@ -270,7 +252,6 @@ void PointCloudAssembler::callbackCloudOdomInfo( const nav_msgs::msg::Odometry::ConstSharedPtr odomMsg, const rtabmap_msgs::msg::OdomInfo::ConstSharedPtr odomInfoMsg) { - callbackCalled_ = true; rtabmap::Transform odom = rtabmap_conversions::transformFromPoseMsg(odomMsg->pose.pose); if(!odom.isNull()) { @@ -293,7 +274,7 @@ void PointCloudAssembler::callbackCloudOdomInfo( void PointCloudAssembler::callbackCloud(const sensor_msgs::msg::PointCloud2::ConstSharedPtr cloudMsg) { - callbackCalled_ = true; + syncDiagnostic_->tickInput(cloudMsg->header.stamp); if(cloudPub_->get_subscription_count()) { UASSERT_MSG(cloudMsg->data.size() == cloudMsg->row_step*cloudMsg->height, @@ -487,6 +468,7 @@ void PointCloudAssembler::callbackCloud(const sensor_msgs::msg::PointCloud2::Con rosCloud.header.frame_id = frameId_; } cloudPub_->publish(rosCloud); + syncDiagnostic_->tickOutput(cloudMsg->header.stamp); if(circularBuffer_) { if(!isMoving) diff --git a/rtabmap_util/src/nodelets/pointcloud_to_depthimage.cpp b/rtabmap_util/src/nodelets/pointcloud_to_depthimage.cpp index 30c0cab1..44edcc35 100644 --- a/rtabmap_util/src/nodelets/pointcloud_to_depthimage.cpp +++ b/rtabmap_util/src/nodelets/pointcloud_to_depthimage.cpp @@ -164,9 +164,10 @@ void PointCloudToDepthImage::callback( if(cloudDisplacement.isNull()) { - RCLCPP_ERROR(this->get_logger(), "Could not find transform between %s and %s, accordingly to %s, aborting!", - pointCloud2Msg->header.frame_id.c_str(), - cameraInfoMsg->header.frame_id.c_str(), + RCLCPP_ERROR(this->get_logger(), "Could not find how %s moved between the cloud (%f) and the camera info (%f) stamps, accordingly to %s, aborting!", + pointCloud2Msg->header.frame_id.c_str(), + cloudStamp, + infoStamp, fixedFrameId_.c_str()); return; } diff --git a/rtabmap_util/src/nodelets/rgbd_relay.cpp b/rtabmap_util/src/nodelets/rgbd_relay.cpp index 45749ccd..d3bcb301 100644 --- a/rtabmap_util/src/nodelets/rgbd_relay.cpp +++ b/rtabmap_util/src/nodelets/rgbd_relay.cpp @@ -43,6 +43,7 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. #include "rtabmap/core/Compression.h" #include "rtabmap/utilite/UConversion.h" +#include "rtabmap/utilite/ULogger.h" namespace rtabmap_util { @@ -54,11 +55,20 @@ RGBDRelay::RGBDRelay(const rclcpp::NodeOptions & options) : { int qos = RMW_QOS_POLICY_RELIABILITY_SYSTEM_DEFAULT; qos = this->declare_parameter("qos", qos); + // The two sides can be set independently so the relay can bridge a publisher + // and a subscriber that don't agree on reliability. Both default to qos. + int qosSub = this->declare_parameter("qos_sub", qos); + int qosPub = this->declare_parameter("qos_pub", qos); + int queueSub = this->declare_parameter("queue_sub", 5); + int queuePub = this->declare_parameter("queue_pub", 1); compress_ = this->declare_parameter("compress", compress_); uncompress_ = this->declare_parameter("uncompress", uncompress_); - rgbdImageSub_ = create_subscription("rgbd_image", rclcpp::QoS(5).reliability((rmw_qos_reliability_policy_t)qos), std::bind(&RGBDRelay::callback, this, std::placeholders::_1)); - rgbdImagePub_ = create_publisher("rgbd_image_relay", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos)); + UASSERT_MSG(queueSub >= 1 && queuePub >= 1, + uFormat("queue_sub (%d) and queue_pub (%d) must be at least 1", queueSub, queuePub).c_str()); + + rgbdImageSub_ = create_subscription("rgbd_image", rclcpp::QoS(queueSub).reliability((rmw_qos_reliability_policy_t)qosSub), std::bind(&RGBDRelay::callback, this, std::placeholders::_1)); + rgbdImagePub_ = create_publisher("rgbd_image_relay", rclcpp::QoS(queuePub).reliability((rmw_qos_reliability_policy_t)qosPub)); } void RGBDRelay::callback(const rtabmap_msgs::msg::RGBDImage::SharedPtr input) const @@ -125,7 +135,7 @@ void RGBDRelay::callback(const rtabmap_msgs::msg::RGBDImage::SharedPtr input) co // already raw, just copy pointer output->rgb = input->rgb; } - if(!input->rgb_compressed.data.empty()) + else if(!input->rgb_compressed.data.empty()) { cv_bridge::toCvCopy(input->rgb_compressed)->toImageMsg(output->rgb); } @@ -135,20 +145,37 @@ void RGBDRelay::callback(const rtabmap_msgs::msg::RGBDImage::SharedPtr input) co // already raw, just copy pointer output->depth = input->depth; } - else if(input->depth_compressed.format.compare("jpg")==0) + else if(!input->depth_compressed.data.empty()) { - // right stereo image - cv_bridge::toCvCopy(input->depth_compressed)->toImageMsg(output->depth); - } - else - { - // dpeth image + // Decode first, then pick the encoding from what actually came out. + // Branching on the "jpg"/"png" format string instead would abort on a + // right image compressed as PNG, which nothing forbids. auto cvImg = std::make_unique(); cvImg->header = input->depth_compressed.header; cvImg->image = rtabmap::uncompressImage(input->depth_compressed.data); - UASSERT(cvImg->image.empty() || cvImg->image.type() == CV_32FC1 || cvImg->image.type() == CV_16UC1); - cvImg->encoding = cvImg->image.empty()?"":cvImg->image.type() == CV_32FC1?sensor_msgs::image_encodings::TYPE_32FC1:sensor_msgs::image_encodings::TYPE_16UC1; - cvImg->toImageMsg(output->depth); + if(cvImg->image.empty()) + { + RCLCPP_ERROR(this->get_logger(), "Could not decompress the depth/right image of \"%s\" (format=\"%s\").", + rgbdImageSub_->get_topic_name(), input->depth_compressed.format.c_str()); + } + else + { + switch(cvImg->image.type()) + { + case CV_32FC1: cvImg->encoding = sensor_msgs::image_encodings::TYPE_32FC1; break; + case CV_16UC1: cvImg->encoding = sensor_msgs::image_encodings::TYPE_16UC1; break; + case CV_8UC1: cvImg->encoding = sensor_msgs::image_encodings::MONO8; break; + case CV_8UC3: cvImg->encoding = sensor_msgs::image_encodings::BGR8; break; + default: + RCLCPP_ERROR(this->get_logger(), "Unsupported decompressed depth/right image type %d.", cvImg->image.type()); + cvImg->image = cv::Mat(); + break; + } + if(!cvImg->image.empty()) + { + cvImg->toImageMsg(output->depth); + } + } } } diff --git a/rtabmap_util/src/nodelets/rgbd_split.cpp b/rtabmap_util/src/nodelets/rgbd_split.cpp index e8c401e7..7d3ad63e 100644 --- a/rtabmap_util/src/nodelets/rgbd_split.cpp +++ b/rtabmap_util/src/nodelets/rgbd_split.cpp @@ -26,6 +26,10 @@ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. */ #include +#include +#include +#include +#include #ifdef PRE_ROS_IRON #include @@ -37,24 +41,55 @@ namespace rtabmap_util { RGBDSplit::RGBDSplit(const rclcpp::NodeOptions & options) : - Node("rgbd_split", options) + Node("rgbd_split", options), + stereo_(false) { int qos = RMW_QOS_POLICY_RELIABILITY_SYSTEM_DEFAULT; qos = this->declare_parameter("qos", qos); + // Each side can be set independently so the node can bridge a producer and a + // consumer that don't agree on reliability. Both default to qos. + int qosSub = this->declare_parameter("qos_sub", qos); + int qosPub = this->declare_parameter("qos_pub", qos); + int queueSub = this->declare_parameter("queue_sub", 5); + int queuePub = this->declare_parameter("queue_pub", 1); + // A stereo RGBDImage carries the right image in the depth slot, so name the outputs + // left/right instead of rgb/depth to say what they really are. + stereo_ = this->declare_parameter("stereo", false); RCLCPP_INFO(this->get_logger(), "%s: qos = %d", get_name(), qos); + RCLCPP_INFO(this->get_logger(), "%s: queue_sub = %d", get_name(), queueSub); + RCLCPP_INFO(this->get_logger(), "%s: queue_pub = %d", get_name(), queuePub); + RCLCPP_INFO(this->get_logger(), "%s: stereo = %s", get_name(), stereo_?"true":"false"); - rgbdImageSub_ = create_subscription("rgbd_image", rclcpp::QoS(5).reliability((rmw_qos_reliability_policy_t)qos), std::bind(&RGBDSplit::callback, this, std::placeholders::_1)); + UASSERT_MSG(queueSub >= 1 && queuePub >= 1, + uFormat("queue_sub (%d) and queue_pub (%d) must be at least 1", queueSub, queuePub).c_str()); + + rgbdImageSub_ = create_subscription("rgbd_image", rclcpp::QoS(queueSub).reliability((rmw_qos_reliability_policy_t)qosSub), std::bind(&RGBDSplit::callback, this, std::placeholders::_1)); + + const std::string base = rgbdImageSub_->get_topic_name(); + const std::string firstName = stereo_?"/left":"/rgb"; + const std::string secondName = stereo_?"/right":"/depth"; + const rclcpp::QoS pubQos = rclcpp::QoS(queuePub).reliability((rmw_qos_reliability_policy_t)qosPub); #ifdef PRE_ROS_LYRICAL - rgbPub_ = image_transport::create_publisher(this, std::string(rgbdImageSub_->get_topic_name()) + "/rgb/image", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos).get_rmw_qos_profile()); - depthPub_ = image_transport::create_publisher(this, std::string(rgbdImageSub_->get_topic_name()) + "/depth/image", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos).get_rmw_qos_profile()); + rgbPub_ = image_transport::create_publisher(this, base + firstName + "/image", pubQos.get_rmw_qos_profile()); + depthPub_ = image_transport::create_publisher(this, base + secondName + "/image", pubQos.get_rmw_qos_profile()); #else - rgbPub_ = image_transport::create_publisher(*this, std::string(rgbdImageSub_->get_topic_name()) + "/rgb/image", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos)); - depthPub_ = image_transport::create_publisher(*this, std::string(rgbdImageSub_->get_topic_name()) + "/depth/image", rclcpp::QoS(1).reliability((rmw_qos_reliability_policy_t)qos)); + rgbPub_ = image_transport::create_publisher(*this, base + firstName + "/image", pubQos); + depthPub_ = image_transport::create_publisher(*this, base + secondName + "/image", pubQos); #endif - rgbInfoPub_ = this->create_publisher(std::string(rgbdImageSub_->get_topic_name()) + "/rgb/camera_info", 1); - depthInfoPub_ = this->create_publisher(std::string(rgbdImageSub_->get_topic_name()) + "/depth/camera_info", 1); + rgbInfoPub_ = this->create_publisher(base + firstName + "/camera_info", pubQos); + depthInfoPub_ = this->create_publisher(base + secondName + "/camera_info", pubQos); + + // Resolved names: the outputs are derived from the input topic, so a remapping of + // "rgbd_image" moves all four with it. Print them so it is clear what to subscribe to. + RCLCPP_INFO(this->get_logger(), "%s: subscribed to:\n %s", get_name(), rgbdImageSub_->get_topic_name()); + RCLCPP_INFO(this->get_logger(), "%s: publishing:\n %s,\n %s,\n %s,\n %s", + get_name(), + rgbPub_.getTopic().c_str(), + rgbInfoPub_->get_topic_name(), + depthPub_.getTopic().c_str(), + depthInfoPub_->get_topic_name()); } @@ -100,7 +135,36 @@ void RGBDSplit::callback(const rtabmap_msgs::msg::RGBDImage::SharedPtr input) co #ifdef CV_BRIDGE_HYDRO ROS_ERROR("Unsupported compressed image copy, please upgrade at least to ROS Indigo to use this."); #else - cv_bridge::toCvCopy(input->depth_compressed)->toImageMsg(outputImage); + // Decode first, then pick the encoding from what actually came out. Going + // by the "jpg"/"png" format string instead would mislabel a depth PNG as + // mono8 (cv_bridge cannot infer 16-bit from it), and would abort outright on + // a right image compressed as PNG, which nothing forbids. + cv_bridge::CvImage cvImg; + cvImg.header = input->depth_compressed.header; + cvImg.image = rtabmap::uncompressImage(input->depth_compressed.data); + if(cvImg.image.empty()) + { + RCLCPP_ERROR(this->get_logger(), "Could not decompress the depth/right image of \"%s\" (format=\"%s\").", + rgbdImageSub_->get_topic_name(), input->depth_compressed.format.c_str()); + } + else + { + switch(cvImg.image.type()) + { + case CV_32FC1: cvImg.encoding = sensor_msgs::image_encodings::TYPE_32FC1; break; + case CV_16UC1: cvImg.encoding = sensor_msgs::image_encodings::TYPE_16UC1; break; + case CV_8UC1: cvImg.encoding = sensor_msgs::image_encodings::MONO8; break; + case CV_8UC3: cvImg.encoding = sensor_msgs::image_encodings::BGR8; break; + default: + RCLCPP_ERROR(this->get_logger(), "Unsupported decompressed depth/right image type %d.", cvImg.image.type()); + cvImg.image = cv::Mat(); + break; + } + } + if(!cvImg.image.empty()) + { + cvImg.toImageMsg(outputImage); + } #endif } if(outputCameraInfo.header.frame_id.empty()) { @@ -119,6 +183,37 @@ void RGBDSplit::callback(const rtabmap_msgs::msg::RGBDImage::SharedPtr input) co outputImage.header = outputCameraInfo.header; } } + // The "depth" slot of an RGBDImage holds either a depth image or the right image + // of a stereo pair, and "stereo" decides which name it goes out under. Warn when + // the two disagree: the topic name would be lying to every consumer downstream. + // Both directions only warn and keep forwarding -- publishing a right image on + // the depth topic is what this node has always done, and setups rely on it. + if(!outputImage.data.empty()) + { + const bool isDepth = + outputImage.encoding == sensor_msgs::image_encodings::TYPE_16UC1 || + outputImage.encoding == sensor_msgs::image_encodings::TYPE_32FC1 || + outputImage.encoding == sensor_msgs::image_encodings::MONO16; + if(stereo_ && isDepth) + { + RCLCPP_WARN_ONCE(this->get_logger(), + "Parameter \"stereo\" is true, so the second half is published as \"%s\", " + "but the received image is a depth image (encoding=\"%s\"), not the right " + "image of a stereo pair. Set \"stereo\" to false to publish it as depth. " + "(This warning is printed only once)", + depthPub_.getTopic().c_str(), outputImage.encoding.c_str()); + } + else if(!stereo_ && !isDepth) + { + RCLCPP_WARN_ONCE(this->get_logger(), + "Parameter \"stereo\" is false, so the second half is published as \"%s\", " + "but the received image is not a depth image (encoding=\"%s\"): it looks " + "like the right image of a stereo pair. Set \"stereo\" to true to publish " + "it under a name that says so. (This warning is printed only once)", + depthPub_.getTopic().c_str(), outputImage.encoding.c_str()); + } + } + depthPub_.publish(outputImage); depthInfoPub_->publish(outputCameraInfo); } diff --git a/rtabmap_util/test/db_builders.hpp b/rtabmap_util/test/db_builders.hpp new file mode 100644 index 00000000..bc6340d1 --- /dev/null +++ b/rtabmap_util/test/db_builders.hpp @@ -0,0 +1,366 @@ +/* +Copyright (c) 2010-2026, Mathieu Labbe - IntRoLab - Universite de Sherbrooke +All rights reserved. (BSD-3-Clause, see the repository root.) +*/ + +#ifndef RTABMAP_UTIL_DB_BUILDERS_HPP_ +#define RTABMAP_UTIL_DB_BUILDERS_HPP_ + +/** + * @file + * @brief Synthetic RTAB-Map databases for the db_player tests. + * + * db_player replays whatever a database happens to contain, and which topics it even + * creates depends on the payloads it finds. Rather than ship a recorded database, each + * scenario is written here with DBDriver so the expected values are visible right next + * to the assertions. + * + * @note Only the *compressed* buffers of a SensorData are persisted, so every payload is + * compressed before being handed to the driver. Saving raw-only data silently + * writes empty blobs. + */ + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +#include + +#include +#include +#include + +namespace rtabmap_util_test { + +//============================================================================ +// What every synthetic database contains, and what the tests assert against +//============================================================================ + +constexpr int kDbFrames = 3; ///< nodes in each database +constexpr double kFirstStamp = 1000.0; ///< stamp of node 1, seconds +constexpr double kStampStep = 0.05; ///< seconds between consecutive nodes +constexpr float kPoseStep = 0.5f; ///< meters along x between odometry poses +constexpr double kOdomVariance = 0.25; ///< diagonal of the odometry covariance + +constexpr int kImageWidth = 80; +constexpr int kImageHeight = 60; +constexpr double kFx = 100.0; +constexpr double kFy = 100.0; +constexpr double kCx = 40.0; +constexpr double kCy = 30.0; +constexpr double kBaseline = 0.12; +constexpr uint16_t kDepthMillimeters = 1500; + +constexpr double kGpsLongitude = -71.9; +constexpr double kGpsLatitude = 45.4; +constexpr double kGpsAltitude = 123.0; +constexpr double kGpsError = 2.5; +constexpr double kEnvSensorValue = 21.5; + +/// The odometry pose of node @p id, one step further along x than the previous one. +inline rtabmap::Transform poseOf(int id) +{ + return rtabmap::Transform(kPoseStep * float(id - 1), 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); +} + +/// The stamp of node @p id. +inline double stampOfNode(int id) +{ + return kFirstStamp + kStampStep * double(id - 1); +} + +/// Where the camera sits on the robot: 10 cm forward, 20 cm up, looking forward. +inline rtabmap::Transform cameraLocalTransform() +{ + return rtabmap::Transform(0.1f, 0.0f, 0.2f, 0.0f, 0.0f, 0.0f) * + rtabmap::CameraModel::opticalRotation(); +} + +/// Where the lidar sits on the robot. +inline rtabmap::Transform scanLocalTransform() +{ + return rtabmap::Transform(0.05f, 0.0f, 0.3f, 0.0f, 0.0f, 0.0f); +} + +/// The ground truth pose of node @p id, offset from the odometry pose so they differ. +inline rtabmap::Transform groundTruthOf(int id) +{ + return rtabmap::Transform(kPoseStep * float(id - 1), 1.0f, 0.0f, 0.0f, 0.0f, 0.0f); +} + +/// The prior (global) pose of node @p id. +inline rtabmap::Transform globalPoseOf(int id) +{ + return rtabmap::Transform(kPoseStep * float(id - 1), 2.0f, 0.0f, 0.0f, 0.0f, 0.0f); +} + +//============================================================================ +// A database file that cleans itself up +//============================================================================ + +/// A uniquely named database path under the test temp directory, erased on destruction. +class TempDatabase +{ +public: + explicit TempDatabase(const std::string & tag) + { + static int counter = 0; + path_ = std::string(::testing::TempDir()) + + uFormat("rtabmap_util_db_player_%s_%d_%d.db", tag.c_str(), (int)getpid(), ++counter); + UFile::erase(path_.c_str()); + } + + ~TempDatabase() { UFile::erase(path_.c_str()); } + + TempDatabase(const TempDatabase &) = delete; + TempDatabase & operator=(const TempDatabase &) = delete; + + const std::string & path() const { return path_; } + +private: + std::string path_; +}; + +//============================================================================ +// Writing the databases +//============================================================================ + +/// Builds the sensor payload of node @p id; see the writeXxxDatabase() functions. +typedef std::function DataBuilder; + +/// Adds anything beyond the payload: links, ground truth, and so on. +typedef std::function NodeDecorator; + +/** + * @brief Writes @p frames consecutive nodes sharing the plumbing every replay needs. + * + * Nodes are numbered from 1, stamped kStampStep apart (db_player replays at the database + * stamps, so a node without one aborts the read), posed kPoseStep apart along x, and + * joined by the neighbor links that carry the odometry covariance. + */ +inline void writeDatabase( + const std::string & path, int frames, + const DataBuilder & makeData, + const NodeDecorator & decorate = NodeDecorator()) +{ + rtabmap::DBDriver * driver = rtabmap::DBDriver::create(); + ASSERT_NE(driver, nullptr); + ASSERT_TRUE(driver->openConnection(path, /*overwritten=*/true)) << "cannot create " << path; + + for(int id=1; id<=frames; ++id) + { + const double stamp = stampOfNode(id); + rtabmap::Signature * s = new rtabmap::Signature( + id, /*mapId=*/0, /*weight=*/1, stamp, /*label=*/"", + poseOf(id), rtabmap::Transform(), makeData(id, stamp)); + + if(id > 1) + { + // The backward neighbor link is where DBReader reads the odometry + // covariance from: it publishes the inverse of this information matrix. + const rtabmap::Transform motion(kPoseStep, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + s->addLink(rtabmap::Link(id, id-1, rtabmap::Link::kNeighbor, motion.inverse(), + cv::Mat::eye(6, 6, CV_64FC1) / kOdomVariance)); + } + if(decorate) + { + decorate(id, *s); + } + + driver->asyncSave(s); // the driver takes ownership + driver->emptyTrashes(false); + } + + driver->closeConnection(true); + delete driver; +} + +/// A color image whose pixels identify the node, so a test can tell frames apart. +inline cv::Mat makeRgb(int id) +{ + return cv::Mat(kImageHeight, kImageWidth, CV_8UC3, cv::Scalar(id, 2*id, 3*id)); +} + +inline cv::Mat makeDepth() +{ + return cv::Mat(kImageHeight, kImageWidth, CV_16UC1, cv::Scalar(kDepthMillimeters)); +} + +inline rtabmap::CameraModel rgbdCameraModel() +{ + return rtabmap::CameraModel(kFx, kFy, kCx, kCy, cameraLocalTransform(), 0.0, + cv::Size(kImageWidth, kImageHeight)); +} + +inline rtabmap::StereoCameraModel stereoCameraModel() +{ + return rtabmap::StereoCameraModel(kFx, kFy, kCx, kCy, kBaseline, cameraLocalTransform(), + cv::Size(kImageWidth, kImageHeight)); +} + +/// RGB + registered depth from a single camera. +inline void writeRgbdDatabase(const std::string & path, int frames = kDbFrames) +{ + writeDatabase(path, frames, [](int id, double stamp) { + rtabmap::SensorData data; + data.setId(id); + data.setStamp(stamp); + data.setRGBDImage(rtabmap::compressImage2(makeRgb(id), ".png"), + rtabmap::compressImage2(makeDepth(), ".png"), rgbdCameraModel()); + return data; + }); +} + +/// A rectified mono stereo pair. +inline void writeStereoDatabase(const std::string & path, int frames = kDbFrames) +{ + writeDatabase(path, frames, [](int id, double stamp) { + const cv::Mat left(kImageHeight, kImageWidth, CV_8UC1, cv::Scalar(id)); + const cv::Mat right(kImageHeight, kImageWidth, CV_8UC1, cv::Scalar(2*id)); + rtabmap::SensorData data; + data.setId(id); + data.setStamp(stamp); + data.setStereoImage(rtabmap::compressImage2(left, ".png"), + rtabmap::compressImage2(right, ".png"), stereoCameraModel()); + return data; + }); +} + +/// A color image with no calibration at all, which db_player replays on "image". +inline void writeImageOnlyDatabase(const std::string & path, int frames = kDbFrames) +{ + writeDatabase(path, frames, [](int id, double stamp) { + rtabmap::SensorData data; + data.setId(id); + data.setStamp(stamp); + data.setRGBDImage(rtabmap::compressImage2(makeRgb(id), ".png"), cv::Mat(), + std::vector()); + return data; + }); +} + +//============================================================================ +// Laser scans +//============================================================================ + +constexpr int kScanBins = 20; +constexpr float kScanAngleMin = -1.0f; +constexpr float kScanAngleMax = 1.0f; +constexpr float kScanAngleIncrement = 0.1f; // (max-min)/kScanBins +constexpr float kScanRangeMin = 0.1f; +constexpr float kScanRangeMax = 10.0f; + +/// The range measured in bin @p bin of the 2D scan. +inline float scanRangeOf(int bin) { return 1.0f + 0.1f * float(bin); } + +/** + * @brief A 2D scan with one point at the center of every bin. + * + * db_player re-bins the cartesian points back into a LaserScan message, so putting each + * point at a bin center makes the expected index exact rather than a rounding coin flip. + */ +inline rtabmap::LaserScan makeScan2d() +{ + cv::Mat points(1, kScanBins, CV_32FC2); + for(int bin=0; bin(0, bin) = cv::Vec2f(range * std::cos(angle), range * std::sin(angle)); + } + return rtabmap::LaserScan(rtabmap::compressData2(points), rtabmap::LaserScan::kXY, + kScanRangeMin, kScanRangeMax, kScanAngleMin, kScanAngleMax, kScanAngleIncrement, + scanLocalTransform()); +} + +constexpr int kScanCloudPoints = 50; + +inline rtabmap::LaserScan makeScan3d() +{ + cv::Mat points(1, kScanCloudPoints, CV_32FC3); + for(int i=0; i(0, i) = cv::Vec3f(1.0f + 0.01f*float(i), 0.02f*float(i), 0.5f); + } + return rtabmap::LaserScan(rtabmap::compressData2(points), /*maxPoints=*/0, /*maxRange=*/0.0f, + rtabmap::LaserScan::kXYZ, scanLocalTransform()); +} + +/// A 2D lidar only, no camera. +inline void writeScan2dDatabase(const std::string & path, int frames = kDbFrames) +{ + writeDatabase(path, frames, [](int id, double stamp) { + rtabmap::SensorData data; + data.setId(id); + data.setStamp(stamp); + data.setLaserScan(makeScan2d()); + return data; + }); +} + +/// A 3D lidar only, no camera. +inline void writeScan3dDatabase(const std::string & path, int frames = kDbFrames) +{ + writeDatabase(path, frames, [](int id, double stamp) { + rtabmap::SensorData data; + data.setId(id); + data.setStamp(stamp); + data.setLaserScan(makeScan3d()); + return data; + }); +} + +//============================================================================ +// Everything else db_player can replay +//============================================================================ + +/// The gravity orientation stored as a link, which is how DBReader rebuilds an IMU. +inline rtabmap::Transform gravityTransform() +{ + return rtabmap::Transform(0.0f, 0.0f, 0.0f, 0.1f, 0.2f, 0.0f); +} + +/** + * @brief RGB-D plus the optional channels: ground truth, prior pose, GPS, gravity and an + * environmental sensor. + * + * @note The prior's information matrix must not leave a huge rotational variance, or + * DBReader drops the global pose on the assumption GPS already provided the prior. + */ +inline void writeRichDatabase(const std::string & path, int frames = kDbFrames) +{ + writeDatabase(path, frames, + [](int id, double stamp) { + rtabmap::SensorData data; + data.setId(id); + data.setStamp(stamp); + data.setRGBDImage(rtabmap::compressImage2(makeRgb(id), ".png"), + rtabmap::compressImage2(makeDepth(), ".png"), rgbdCameraModel()); + data.setGPS(rtabmap::GPS(stamp, kGpsLongitude, kGpsLatitude, kGpsAltitude, + kGpsError, /*bearing=*/0.0)); + rtabmap::EnvSensors sensors; + sensors.insert(std::make_pair(rtabmap::EnvSensor::kAmbientTemperature, + rtabmap::EnvSensor(rtabmap::EnvSensor::kAmbientTemperature, + kEnvSensorValue, stamp))); + data.setEnvSensors(sensors); + return data; + }, + [](int id, rtabmap::Signature & s) { + s.setGroundTruthPose(groundTruthOf(id)); + s.addLink(rtabmap::Link(id, id, rtabmap::Link::kPosePrior, globalPoseOf(id), + cv::Mat::eye(6, 6, CV_64FC1) * 100.0)); + s.addLink(rtabmap::Link(id, id, rtabmap::Link::kGravity, gravityTransform(), + cv::Mat::eye(6, 6, CV_64FC1))); + }); +} + +} // namespace rtabmap_util_test + +#endif /* RTABMAP_UTIL_DB_BUILDERS_HPP_ */ diff --git a/rtabmap_util/test/msg_builders.hpp b/rtabmap_util/test/msg_builders.hpp new file mode 100644 index 00000000..5a23bab7 --- /dev/null +++ b/rtabmap_util/test/msg_builders.hpp @@ -0,0 +1,217 @@ +/* +Copyright (c) 2010-2026, Mathieu Labbe - IntRoLab - Universite de Sherbrooke +All rights reserved. (BSD-3-Clause, see the repository root.) +*/ + +#ifndef RTABMAP_UTIL_MSG_BUILDERS_HPP_ +#define RTABMAP_UTIL_MSG_BUILDERS_HPP_ + +#include +#include +#include +#include +#include +#include + +#include +#ifdef PRE_ROS_IRON +#include +#else +#include +#endif + +#include +#include +#include + +namespace rtabmap_util_test { + +inline rclcpp::Time stampOf(double seconds) +{ + return rclcpp::Time( + int32_t(seconds), uint32_t((seconds - int32_t(seconds)) * 1e9), RCL_ROS_TIME); +} + +/// A rectified pinhole CameraInfo; @p tx is P(0,3), non-zero for a stereo right camera. +inline sensor_msgs::msg::CameraInfo makeCameraInfo( + const std::string & frameId, double stamp, int width, int height, + double tx = 0.0, double fx = 100.0) +{ + sensor_msgs::msg::CameraInfo info; + info.header.frame_id = frameId; + info.header.stamp = stampOf(stamp); + info.width = width; + info.height = height; + info.distortion_model = "plumb_bob"; + info.d = {0.0, 0.0, 0.0, 0.0, 0.0}; + info.k = {fx, 0.0, width/2.0, 0.0, fx, height/2.0, 0.0, 0.0, 1.0}; + info.r = {1.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 1.0}; + info.p = {fx, 0.0, width/2.0, tx, 0.0, fx, height/2.0, 0.0, 0.0, 0.0, 1.0, 0.0}; + return info; +} + +inline sensor_msgs::msg::Image makeImage( + const std::string & frameId, double stamp, + const cv::Mat & image, const std::string & encoding) +{ + std_msgs::msg::Header header; + header.frame_id = frameId; + header.stamp = stampOf(stamp); + sensor_msgs::msg::Image msg; + cv_bridge::CvImage(header, encoding, image).toImageMsg(msg); + return msg; +} + +/// An RGB-D message with raw bgr8 color and 16UC1 depth. +inline rtabmap_msgs::msg::RGBDImage makeRGBDImage( + const std::string & frameId, double stamp, int width = 8, int height = 8, + const cv::Scalar & rgbColor = cv::Scalar(10, 20, 30), uint16_t depthValue = 1500) +{ + rtabmap_msgs::msg::RGBDImage msg; + msg.header.frame_id = frameId; + msg.header.stamp = stampOf(stamp); + msg.rgb = makeImage(frameId, stamp, cv::Mat(height, width, CV_8UC3, rgbColor), "bgr8"); + msg.depth = makeImage(frameId, stamp, + cv::Mat(height, width, CV_16UC1, cv::Scalar(depthValue)), "16UC1"); + msg.rgb_camera_info = makeCameraInfo(frameId, stamp, width, height); + msg.depth_camera_info = makeCameraInfo(frameId, stamp, width, height); + return msg; +} + +/** + * @brief An RGB-D message carrying a stereo pair instead of depth. + * + * The "depth" slot holds the mono8 right image and the second camera info carries the + * baseline in P(0,3), which is what makes consumers treat the pair as stereo rather + * than as color plus depth. + */ +inline rtabmap_msgs::msg::RGBDImage makeStereoRGBDImage( + const std::string & frameId, double stamp, int width = 8, int height = 8, + double baseline = 0.12, double fx = 100.0) +{ + rtabmap_msgs::msg::RGBDImage msg; + msg.header.frame_id = frameId; + msg.header.stamp = stampOf(stamp); + msg.rgb = makeImage(frameId, stamp, + cv::Mat(height, width, CV_8UC3, cv::Scalar(10, 20, 30)), "bgr8"); + msg.depth = makeImage(frameId, stamp, + cv::Mat(height, width, CV_8UC1, cv::Scalar(60)), "mono8"); // right image + msg.rgb_camera_info = makeCameraInfo(frameId, stamp, width, height, 0.0, fx); + msg.depth_camera_info = + makeCameraInfo(frameId, stamp, width, height, -fx*baseline, fx); + return msg; +} + +/** + * @brief A dense unorganized XYZ float cloud. + * + * @note This writes the points exactly as given: it does not model sensor motion. To + * build a cloud that deskewing can actually correct, use makeSkewedWallScan(), + * which derives the distortion from the same trajectory the TF describes. + * + * @param withTimeChannel add a FLOAT32 "t" channel of per-point offsets, as a spinning + * lidar publishes, so the cloud can be deskewed. + */ +inline sensor_msgs::msg::PointCloud2 makeXYZCloud( + const std::string & frameId, double stamp, + const std::vector & points, + bool withTimeChannel = false, double sweepDuration = 0.099) +{ + sensor_msgs::msg::PointCloud2 cloud; + cloud.header.frame_id = frameId; + cloud.header.stamp = stampOf(stamp); + cloud.height = 1; + cloud.width = points.size(); + cloud.is_bigendian = false; + cloud.is_dense = true; + + const int fieldCount = withTimeChannel ? 4 : 3; + cloud.fields.resize(fieldCount); + const char * names[4] = {"x", "y", "z", "t"}; + for(int i=0; i(&cloud.data[i * cloud.point_step]); + p[0] = points[i].x; + p[1] = points[i].y; + p[2] = points[i].z; + if(withTimeChannel) + { + p[3] = points.size() > 1 + ? float(sweepDuration * double(i) / double(points.size() - 1)) + : 0.0f; + } + } + return cloud; +} + +/** + * @brief The raw scan of a flat wall captured while the sensor moves straight at it. + * + * Sample @p i is taken at `i * step` seconds into the sweep, by which time the sensor + * has closed in by `displacement(elapsed)`. Expressed in the sensor frame at capture + * time the wall therefore appears to slide closer: a straight wall is recorded bent. + * Deskewing with the same motion must flatten it back to @p wallDistance. + * + * @param frameId sensor frame + * @param stamp stamp of the first sample, which is also the message stamp + * @param sampleCount number of samples along the wall + * @param sweepDuration seconds from the first sample to the last + * @param wallDistance distance to the wall at the first sample, in meters + * @param displacement distance travelled as a function of seconds since the first + * sample; must match the motion published to TF + */ +inline sensor_msgs::msg::PointCloud2 makeSkewedWallScan( + const std::string & frameId, double stamp, + size_t sampleCount, double sweepDuration, float wallDistance, + const std::function & displacement) +{ + std::vector points; + points.reserve(sampleCount); + for(size_t i=0; i 1 ? sweepDuration * double(i) / double(sampleCount - 1) : 0.0; + points.push_back(cv::Point3f( + wallDistance - float(displacement(elapsed)), // the skew + -1.0f + 2.0f * float(i) / float(sampleCount > 1 ? sampleCount - 1 : 1), + 0.0f)); + } + return makeXYZCloud(frameId, stamp, points, /*withTimeChannel=*/true, sweepDuration); +} + +/** + * @brief Reads the x/y/z of a point from any FLOAT32 xyz cloud. + * + * Looks the offsets up in the field list rather than assuming they are 0/4/8, so it also + * works on clouds produced by laser_geometry, which lay their fields out differently. + */ +inline cv::Point3f readXYZ(const sensor_msgs::msg::PointCloud2 & cloud, size_t index) +{ + uint32_t xOffset = 0, yOffset = 4, zOffset = 8; + for(size_t i=0; i(base + xOffset), + *reinterpret_cast(base + yOffset), + *reinterpret_cast(base + zOffset)); +} + +} // namespace rtabmap_util_test + +#endif /* RTABMAP_UTIL_MSG_BUILDERS_HPP_ */ diff --git a/rtabmap_util/test/node_test_utils.hpp b/rtabmap_util/test/node_test_utils.hpp new file mode 100644 index 00000000..1031c1c1 --- /dev/null +++ b/rtabmap_util/test/node_test_utils.hpp @@ -0,0 +1,320 @@ +/* +Copyright (c) 2010-2026, Mathieu Labbe - IntRoLab - Universite de Sherbrooke +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the Universite de Sherbrooke nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +*/ + +#ifndef RTABMAP_UTIL_NODE_TEST_UTILS_HPP_ +#define RTABMAP_UTIL_NODE_TEST_UTILS_HPP_ + +#include + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace rtabmap_util_test { + +/** + * @brief Brings rclcpp up once for the whole test binary. + * + * Registered as a gtest global environment so it runs before the first test and shuts + * down after the last one, which keeps gtest_main usable. + */ +class RclcppEnvironment : public ::testing::Environment +{ +public: + void SetUp() override + { + if(!rclcpp::ok()) + { + rclcpp::init(0, nullptr); + } + } + void TearDown() override + { + if(rclcpp::ok()) + { + rclcpp::shutdown(); + } + } +}; + +/// Registers RclcppEnvironment. Call once at file scope in each test binary. +inline ::testing::Environment * registerRclcppEnvironment() +{ + static ::testing::Environment * const env = + ::testing::AddGlobalTestEnvironment(new RclcppEnvironment); + return env; +} + +/** + * @brief Base fixture for driving a node under test over real ROS topics. + * + * The node under test and a helper node share one single-threaded executor, so + * publishing, the node's callback and the assertion all happen on the same thread and + * the tests stay deterministic. No launch files and no separate processes are involved: + * everything runs in the gtest binary. + */ +class NodeTest : public ::testing::Test +{ +protected: + void SetUp() override + { + executor_ = std::make_shared(); + helper_ = std::make_shared("rtabmap_util_test_helper"); + executor_->add_node(helper_); + } + + void TearDown() override + { + for(const rclcpp::Node::SharedPtr & node : nodes_) + { + executor_->remove_node(node); + } + nodes_.clear(); + executor_->remove_node(helper_); + helper_.reset(); + executor_.reset(); + } + + /// Adds a node under test to the shared executor and keeps it alive for the test. + template + std::shared_ptr addNode(const std::shared_ptr & node) + { + executor_->add_node(node); + nodes_.push_back(node); + return node; + } + + /// The helper node, used to publish inputs and subscribe to outputs. + rclcpp::Node::SharedPtr helper() { return helper_; } + + /** + * @brief Spins until @p done returns true, or the timeout elapses. + * @return true if @p done became true + */ + bool spinUntil( + const std::function & done, + std::chrono::milliseconds timeout = std::chrono::milliseconds(5000)) + { + const std::chrono::steady_clock::time_point deadline = + std::chrono::steady_clock::now() + timeout; + while(rclcpp::ok() && std::chrono::steady_clock::now() < deadline) + { + if(done()) + { + return true; + } + executor_->spin_once(std::chrono::milliseconds(10)); + } + return done(); + } + + /** + * @brief Runs every node of the fixture on a multi-threaded executor until @p done. + * + * A node whose callback waits on another of its own callbacks -- a service call made + * from a timer, say -- makes no progress under spinUntil(), because the second + * callback cannot run while the first is still on the stack. Such nodes put the two + * callbacks in different callback groups precisely so a multi-threaded executor can + * overlap them; this hands them the threads to do it, then puts the nodes back on the + * usual single-threaded executor. + * + * @warning Callbacks run on executor threads for the duration, so do not have any + * Collector subscribed while this runs: the test thread would read its + * messages while another thread appends to them. Use it to get a node + * through its start-up handshake, before subscribing to anything. + */ + bool spinMultiThreadedUntil( + const std::function & done, + std::chrono::milliseconds timeout = std::chrono::milliseconds(15000)) + { + rclcpp::executors::MultiThreadedExecutor booting(rclcpp::ExecutorOptions(), 4); + for(const rclcpp::Node::SharedPtr & node : nodes_) + { + executor_->remove_node(node); + booting.add_node(node); + } + executor_->remove_node(helper_); + booting.add_node(helper_); + + std::thread spinner([&booting]() { booting.spin(); }); + const std::chrono::steady_clock::time_point deadline = + std::chrono::steady_clock::now() + timeout; + while(rclcpp::ok() && !done() && std::chrono::steady_clock::now() < deadline) + { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } + const bool result = done(); + booting.cancel(); + spinner.join(); + + booting.remove_node(helper_); + executor_->add_node(helper_); + for(const rclcpp::Node::SharedPtr & node : nodes_) + { + booting.remove_node(node); + executor_->add_node(node); + } + return result; + } + + /// Spins for a fixed duration, for the "nothing should happen" assertions. + void spinFor(std::chrono::milliseconds duration) + { + const std::chrono::steady_clock::time_point deadline = + std::chrono::steady_clock::now() + duration; + while(rclcpp::ok() && std::chrono::steady_clock::now() < deadline) + { + executor_->spin_once(std::chrono::milliseconds(10)); + } + } + + /** + * @brief Waits until @p publisher has at least @p count matched subscriptions. + * + * Publishing before the node under test has discovered the topic silently drops the + * message, which is the most common cause of a flaky in-process node test. + */ + template + bool waitForSubscriber(const PublisherT & publisher, size_t count = 1) + { + return spinUntil([&]() { return publisher->get_subscription_count() >= count; }); + } + + /** + * @brief Waits until @p subscription sees at least one publisher. + * + * Several nodes only publish when they have subscribers, so the test's subscription + * has to be discovered before the input is sent. + */ + template + bool waitForPublisher(const SubscriptionT & subscription, size_t count = 1) + { + return spinUntil([&]() { return subscription->get_publisher_count() >= count; }); + } + + /** + * @brief Publishes a static transform on /tf_static. + * + * /tf_static is transient-local, so a listener that subscribes later still receives + * it. That makes static frames far less timing-sensitive in tests than /tf. + */ + void publishStaticTf( + const std::string & parent, const std::string & child, + double x = 0.0, double y = 0.0, double z = 0.0) + { + if(!staticTfPublisher_) + { + staticTfPublisher_ = helper_->create_publisher( + "/tf_static", rclcpp::QoS(100).transient_local()); + } + geometry_msgs::msg::TransformStamped t; + t.header.stamp = helper_->now(); + t.header.frame_id = parent; + t.child_frame_id = child; + t.transform.translation.x = x; + t.transform.translation.y = y; + t.transform.translation.z = z; + t.transform.rotation.w = 1.0; + tf2_msgs::msg::TFMessage msg; + msg.transforms.push_back(t); + staticTfPublisher_->publish(msg); + spinFor(std::chrono::milliseconds(100)); + } + + /// Publishes a static transform with a rotation, given as roll/pitch/yaw. + void publishStaticTfRPY( + const std::string & parent, const std::string & child, + double roll, double pitch, double yaw, + double x = 0.0, double y = 0.0, double z = 0.0) + { + if(!staticTfPublisher_) + { + staticTfPublisher_ = helper_->create_publisher( + "/tf_static", rclcpp::QoS(100).transient_local()); + } + tf2::Quaternion q; + q.setRPY(roll, pitch, yaw); + geometry_msgs::msg::TransformStamped t; + t.header.stamp = helper_->now(); + t.header.frame_id = parent; + t.child_frame_id = child; + t.transform.translation.x = x; + t.transform.translation.y = y; + t.transform.translation.z = z; + t.transform.rotation = tf2::toMsg(q); + tf2_msgs::msg::TFMessage msg; + msg.transforms.push_back(t); + staticTfPublisher_->publish(msg); + spinFor(std::chrono::milliseconds(100)); + } + + /// Collects every message received on @p topic, for later assertions. + template + struct Collector + { + typename rclcpp::Subscription::SharedPtr subscription; + std::vector messages; + size_t size() const { return messages.size(); } + bool empty() const { return messages.empty(); } + const MsgT & back() const { return *messages.back(); } + const MsgT & front() const { return *messages.front(); } + }; + + /// Subscribes the helper node to @p topic and records everything it receives. + template + std::shared_ptr> collect( + const std::string & topic, const rclcpp::QoS & qos = rclcpp::QoS(10)) + { + std::shared_ptr> collector = std::make_shared>(); + collector->subscription = helper_->create_subscription( + topic, qos, + [collector](const typename MsgT::ConstSharedPtr msg) { + collector->messages.push_back(msg); + }); + return collector; + } + +private: + rclcpp::executors::SingleThreadedExecutor::SharedPtr executor_; + rclcpp::Node::SharedPtr helper_; + std::vector nodes_; + rclcpp::Publisher::SharedPtr staticTfPublisher_; +}; + +} // namespace rtabmap_util_test + +#endif /* RTABMAP_UTIL_NODE_TEST_UTILS_HPP_ */ diff --git a/rtabmap_util/test/test_db_player.cpp b/rtabmap_util/test/test_db_player.cpp new file mode 100644 index 00000000..f045de04 --- /dev/null +++ b/rtabmap_util/test/test_db_player.cpp @@ -0,0 +1,809 @@ +/* +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 "db_builders.hpp" + +#include + +#include + +#include +#include + +#include + +using namespace rtabmap_util_test; + +namespace { +::testing::Environment * const kEnv = registerRclcppEnvironment(); + +/// Replay at 1000x the recorded stamps: db_player sleeps between frames otherwise. +constexpr double kReplayRate = 1000.0; + +bool hasParameter(const std::vector & overrides, const std::string & name) +{ + for(size_t i=0; i overrides = {}) + { + if(!hasParameter(overrides, "database")) + { + overrides.push_back(rclcpp::Parameter("database", databasePath)); + } + if(!hasParameter(overrides, "rate")) + { + overrides.push_back(rclcpp::Parameter("rate", kReplayRate)); + } + player_ = addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + } + + /// Reads one frame, which is what creates the publishers. + void primePublishers() + { + ASSERT_TRUE(player_->publishNextFrame()) << "the database has no readable frame"; + } + + /// Replays frames until @p done, or the database runs out. + bool replayUntil(const std::function & done) + { + for(int i=0; ipublishNextFrame()) { break; } + spinFor(std::chrono::milliseconds(30)); + } + return done(); + } + + /** + * @brief The database node a replayed message came from, recovered from its stamp. + * + * How many frames a test ends up replaying depends on discovery, so the expected + * pose is derived from the stamp the message itself carries rather than assumed. + * That also checks the stamp really comes from the database. + */ + static int nodeIdOf(const builtin_interfaces::msg::Time & stamp) + { + const double seconds = rtabmap_conversions::timestampFromROS(stamp); + return int(std::round((seconds - kFirstStamp) / kStampStep)) + 1; + } + + /// The most recent transform published for @p parent -> @p child. + static bool findTransform( + const Collector & tf, + const std::string & parent, const std::string & child, + geometry_msgs::msg::TransformStamped & out) + { + bool found = false; + for(size_t i=0; itransforms.size(); ++j) + { + const geometry_msgs::msg::TransformStamped & t = tf.messages[i]->transforms[j]; + if(t.header.frame_id == parent && t.child_frame_id == child) + { + out = t; + found = true; + } + } + } + return found; + } + + static rtabmap::Transform toRtabmap(const geometry_msgs::msg::TransformStamped & t) + { + return rtabmap_conversions::transformFromGeometryMsg(t.transform); + } + + /// Subscribes to /tf and waits for db_player's broadcaster to be discovered. + std::shared_ptr> collectTf() + { + std::shared_ptr> tf = + collect("/tf", rclcpp::QoS(100)); + EXPECT_TRUE(waitForPublisher(tf->subscription)); + return tf; + } + + std::shared_ptr player_; +}; + +//============================================================================ +// RGB-D +//============================================================================ + +TEST_F(DbPlayerTest, ReplaysRgbAndDepthImages) +{ + TempDatabase db("rgbd"); + writeRgbdDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> rgb = + collect("rgb/image"); + std::shared_ptr> depth = + collect("depth/image"); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + ASSERT_TRUE(replayUntil([&]() { return !rgb->empty() && !depth->empty(); })) + << "no image replayed"; + + EXPECT_EQ(rgb->back().encoding, sensor_msgs::image_encodings::BGR8); + EXPECT_EQ(rgb->back().width, uint32_t(kImageWidth)); + EXPECT_EQ(rgb->back().height, uint32_t(kImageHeight)); + EXPECT_EQ(rgb->back().header.frame_id, "camera_optical_link"); + + EXPECT_EQ(depth->back().encoding, sensor_msgs::image_encodings::TYPE_16UC1); + EXPECT_EQ(depth->back().header.frame_id, "camera_optical_link") + << "depth is registered with the color camera, so it shares its frame"; + EXPECT_EQ(*reinterpret_cast(depth->back().data.data()), kDepthMillimeters); +} + +TEST_F(DbPlayerTest, StampsImagesWithTheDatabaseStamps) +{ + TempDatabase db("stamps"); + writeRgbdDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> rgb = + collect("rgb/image"); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !rgb->empty(); })); + + const int id = nodeIdOf(rgb->front().header.stamp); + EXPECT_GE(id, 2) << "the first frame only creates the publishers"; + EXPECT_LE(id, kDbFrames); + EXPECT_NEAR(rtabmap_conversions::timestampFromROS(rgb->front().header.stamp), + stampOfNode(id), 1e-6); +} + +TEST_F(DbPlayerTest, ReplaysCameraCalibration) +{ + TempDatabase db("caminfo"); + writeRgbdDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> rgb = + collect("rgb/image"); + std::shared_ptr> rgbInfo = + collect("rgb/camera_info"); + std::shared_ptr> depthInfo = + collect("depth/camera_info"); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + ASSERT_TRUE(waitForPublisher(rgbInfo->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !rgbInfo->empty() && !depthInfo->empty(); })); + + EXPECT_EQ(rgbInfo->back().width, uint32_t(kImageWidth)); + EXPECT_EQ(rgbInfo->back().height, uint32_t(kImageHeight)); + EXPECT_NEAR(rgbInfo->back().k[0], kFx, 1e-6); + EXPECT_NEAR(rgbInfo->back().k[2], kCx, 1e-6); + EXPECT_NEAR(rgbInfo->back().k[4], kFy, 1e-6); + EXPECT_NEAR(rgbInfo->back().k[5], kCy, 1e-6); + EXPECT_EQ(rgbInfo->back().header.frame_id, "camera_optical_link"); + + EXPECT_NEAR(depthInfo->back().k[0], kFx, 1e-6) + << "the depth camera info repeats the color calibration"; +} + +TEST_F(DbPlayerTest, ReplaysImageWithoutCalibrationOnImageTopic) +{ + // A database with no calibration at all is still replayable, on "image". + TempDatabase db("imageonly"); + writeImageOnlyDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> image = + collect("image"); + ASSERT_TRUE(waitForPublisher(image->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !image->empty(); })); + + EXPECT_EQ(image->back().encoding, sensor_msgs::image_encodings::BGR8); + EXPECT_EQ(image->back().width, uint32_t(kImageWidth)); +} + +//============================================================================ +// Stereo +//============================================================================ + +TEST_F(DbPlayerTest, ReplaysStereoPairAndCalibration) +{ + TempDatabase db("stereo"); + writeStereoDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> left = + collect("left/image"); + std::shared_ptr> right = + collect("right/image"); + std::shared_ptr> leftInfo = + collect("left/camera_info"); + std::shared_ptr> rightInfo = + collect("right/camera_info"); + ASSERT_TRUE(waitForPublisher(left->subscription)); + ASSERT_TRUE(waitForPublisher(right->subscription)); + ASSERT_TRUE(replayUntil([&]() { + return !left->empty() && !right->empty() && + !leftInfo->empty() && !rightInfo->empty(); })); + + EXPECT_EQ(left->back().encoding, sensor_msgs::image_encodings::MONO8); + EXPECT_EQ(left->back().header.frame_id, "left_camera_optical_link"); + EXPECT_EQ(right->back().encoding, sensor_msgs::image_encodings::MONO8); + EXPECT_EQ(right->back().header.frame_id, "right_camera_optical_link"); + + // Both cameras share the intrinsics of a rectified pair and are stamped with the + // frame of the image they belong to. + EXPECT_EQ(leftInfo->back().width, uint32_t(kImageWidth)); + EXPECT_EQ(leftInfo->back().height, uint32_t(kImageHeight)); + EXPECT_NEAR(leftInfo->back().k[0], kFx, 1e-6); + EXPECT_NEAR(leftInfo->back().k[2], kCx, 1e-6); + EXPECT_NEAR(leftInfo->back().k[4], kFy, 1e-6); + EXPECT_NEAR(leftInfo->back().k[5], kCy, 1e-6); + EXPECT_EQ(leftInfo->back().header.frame_id, "left_camera_optical_link"); + EXPECT_NEAR(rightInfo->back().k[0], kFx, 1e-6); + EXPECT_EQ(rightInfo->back().header.frame_id, "right_camera_optical_link"); + + // Only the right camera carries the baseline: it is P(0,3) = -fx*baseline, and the + // left camera of a rectified pair sits at the origin of the stereo frame. + EXPECT_NEAR(leftInfo->back().p[3], 0.0, 1e-6); + EXPECT_NEAR(rightInfo->back().p[3], -kFx*kBaseline, 1e-6); +} + +//============================================================================ +// Laser scans +//============================================================================ + +TEST_F(DbPlayerTest, Replays2dLaserScan) +{ + TempDatabase db("scan2d"); + writeScan2dDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> scan = + collect("scan"); + ASSERT_TRUE(waitForPublisher(scan->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !scan->empty(); })) << "no scan replayed"; + + const sensor_msgs::msg::LaserScan & msg = scan->back(); + EXPECT_EQ(msg.header.frame_id, "base_laser_link"); + + // The scan carries its own angles, so the scan_angle_* parameters are not used. + EXPECT_NEAR(msg.angle_min, kScanAngleMin, 1e-6); + EXPECT_NEAR(msg.angle_max, kScanAngleMax, 1e-6); + EXPECT_NEAR(msg.angle_increment, kScanAngleIncrement, 1e-6); + EXPECT_NEAR(msg.range_min, kScanRangeMin, 1e-6); + EXPECT_NEAR(msg.range_max, kScanRangeMax, 1e-6); + + // db_player re-bins the cartesian points, so every bin must come back at its range. + ASSERT_EQ(msg.ranges.size(), size_t(kScanBins)); + for(int bin=0; bin> scan = + collect("scan"); + std::shared_ptr> cloud = + collect("scan_cloud"); + + while(player_->publishNextFrame()) { spinFor(std::chrono::milliseconds(30)); } + + EXPECT_FALSE(scan->empty()) << "the 2D scan must still be replayed"; + EXPECT_EQ(cloud->subscription->get_publisher_count(), 0u) + << "a 2D database must not advertise scan_cloud"; + EXPECT_TRUE(cloud->empty()); +} + +TEST_F(DbPlayerTest, A3dScanNeverAdvertisesScan) +{ + TempDatabase db("scan3donly"); + writeScan3dDatabase(db.path()); + start(db.path()); + + std::shared_ptr> scan = + collect("scan"); + std::shared_ptr> cloud = + collect("scan_cloud"); + + while(player_->publishNextFrame()) { spinFor(std::chrono::milliseconds(30)); } + + EXPECT_FALSE(cloud->empty()) << "the 3D scan must still be replayed"; + EXPECT_EQ(scan->subscription->get_publisher_count(), 0u) + << "a 3D database must not advertise scan"; +} + +TEST_F(DbPlayerTest, UsesScanParametersWhenTheScanHasNoAngles) +{ + // A scan saved without angle metadata falls back to the scan_angle_*/scan_range_* + // parameters, which is how a database recorded from a 3D lidar can be replayed as 2D. + const double angleMin = -0.5; + const double angleIncrement = 0.05; + const int targetBin = 10; + // The center of the target bin: db_player truncates (angle-angle_min)/increment, so a + // bearing on a bin boundary would land on either side depending on the rounding. + const float bearing = float(angleMin + (double(targetBin) + 0.5) * angleIncrement); + const float nearest = 1.0f; + + TempDatabase db("scan2dnoangles"); + writeDatabase(db.path(), kDbFrames, [bearing, nearest](int id, double stamp) { + cv::Mat points(1, kScanBins, CV_32FC2); + for(int bin=0; bin(0, bin) = + cv::Vec2f(range * std::cos(bearing), range * std::sin(bearing)); + } + rtabmap::SensorData data; + data.setId(id); + data.setStamp(stamp); + data.setLaserScan(rtabmap::LaserScan(rtabmap::compressData2(points), + /*maxPoints=*/0, /*maxRange=*/0.0f, rtabmap::LaserScan::kXY, + scanLocalTransform())); + return data; + }); + start(db.path(), {rclcpp::Parameter("scan_angle_min", angleMin), + rclcpp::Parameter("scan_angle_max", 0.5), + rclcpp::Parameter("scan_angle_increment", angleIncrement), + rclcpp::Parameter("scan_range_min", 0.2), + rclcpp::Parameter("scan_range_max", 20.0)}); + primePublishers(); + + std::shared_ptr> scan = + collect("scan"); + ASSERT_TRUE(waitForPublisher(scan->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !scan->empty(); })); + + const sensor_msgs::msg::LaserScan & msg = scan->back(); + EXPECT_NEAR(msg.angle_min, angleMin, 1e-6); + EXPECT_NEAR(msg.angle_max, 0.5, 1e-6); + EXPECT_NEAR(msg.angle_increment, angleIncrement, 1e-6); + EXPECT_NEAR(msg.range_min, 0.2, 1e-6); + EXPECT_NEAR(msg.range_max, 20.0, 1e-6); + ASSERT_EQ(msg.ranges.size(), 20u) << "ceil((0.5 - -0.5)/0.05)"; + + EXPECT_NEAR(msg.ranges[targetBin], nearest, 1e-3) + << "every point shares a bearing, so only its bin is filled, at the nearest range"; + for(size_t bin=0; bin> cloud = + collect("scan_cloud"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !cloud->empty(); })) << "no cloud replayed"; + + EXPECT_EQ(cloud->back().header.frame_id, "base_laser_link"); + EXPECT_EQ(cloud->back().width * cloud->back().height, uint32_t(kScanCloudPoints)); +} + +//============================================================================ +// Odometry +//============================================================================ + +TEST_F(DbPlayerTest, ReplaysOdometryWithItsCovariance) +{ + TempDatabase db("odom"); + writeRgbdDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> odom = + collect("odom"); + ASSERT_TRUE(waitForPublisher(odom->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !odom->empty(); })) << "no odometry replayed"; + + const nav_msgs::msg::Odometry & msg = odom->back(); + EXPECT_EQ(msg.header.frame_id, "odom"); + EXPECT_EQ(msg.child_frame_id, "base_link"); + + const int id = nodeIdOf(msg.header.stamp); + ASSERT_GE(id, 1); + ASSERT_LE(id, kDbFrames); + EXPECT_NEAR(msg.pose.pose.position.x, poseOf(id).x(), 1e-5) + << "the pose must be the one recorded for node " << id; + EXPECT_NEAR(msg.pose.pose.position.y, 0.0, 1e-5); + + // The covariance is the inverse of the neighbor link's information matrix. + EXPECT_NEAR(msg.pose.covariance[0], kOdomVariance, 1e-6); + EXPECT_NEAR(msg.pose.covariance[35], kOdomVariance, 1e-6); +} + +TEST_F(DbPlayerTest, IgnoreOdomDropsTheOdometry) +{ + TempDatabase db("ignoreodom"); + writeRgbdDatabase(db.path()); + start(db.path(), {rclcpp::Parameter("ignore_odom", true)}); + primePublishers(); + + std::shared_ptr> odom = + collect("odom"); + std::shared_ptr> rgb = + collect("rgb/image"); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !rgb->empty(); })) + << "the images must still be replayed"; + + EXPECT_EQ(odom->subscription->get_publisher_count(), 0u) + << "with no odometry in the stream the topic is never even created"; + EXPECT_TRUE(odom->empty()); +} + +//============================================================================ +// Transforms +//============================================================================ + +TEST_F(DbPlayerTest, BroadcastsOdometryAndCameraTransforms) +{ + TempDatabase db("tf"); + writeRgbdDatabase(db.path()); + start(db.path()); + std::shared_ptr> tf = collectTf(); + + // TF is not gated on subscribers, so the very first frame already broadcasts. + ASSERT_TRUE(replayUntil([&]() { return !tf->empty(); })) << "nothing broadcast on /tf"; + + geometry_msgs::msg::TransformStamped odomToBase; + ASSERT_TRUE(findTransform(*tf, "odom", "base_link", odomToBase)); + const int id = nodeIdOf(odomToBase.header.stamp); + ASSERT_GE(id, 1); + ASSERT_LE(id, kDbFrames); + EXPECT_NEAR(odomToBase.transform.translation.x, poseOf(id).x(), 1e-5); + + geometry_msgs::msg::TransformStamped baseToCamera; + ASSERT_TRUE(findTransform(*tf, "base_link", "camera_optical_link", baseToCamera)); + EXPECT_LT(toRtabmap(baseToCamera).getDistance(cameraLocalTransform()), 1e-4f) + << "the camera transform is the model's local transform: " + << toRtabmap(baseToCamera).prettyPrint(); +} + +TEST_F(DbPlayerTest, BroadcastsStereoTransformsShiftedByTheBaseline) +{ + TempDatabase db("stereotf"); + writeStereoDatabase(db.path()); + start(db.path()); + std::shared_ptr> tf = collectTf(); + ASSERT_TRUE(replayUntil([&]() { return !tf->empty(); })); + + geometry_msgs::msg::TransformStamped baseToLeft, baseToRight; + ASSERT_TRUE(findTransform(*tf, "base_link", "left_camera_optical_link", baseToLeft)); + ASSERT_TRUE(findTransform(*tf, "base_link", "right_camera_optical_link", baseToRight)); + + EXPECT_LT(toRtabmap(baseToLeft).getDistance(cameraLocalTransform()), 1e-4f); + + // The right camera carries the baseline in Tx, which db_player turns back into a + // translation along the optical x axis so the frame sits next to the left one. + const rtabmap::Transform expectedRight = + cameraLocalTransform() * rtabmap::Transform(kBaseline, 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + EXPECT_LT(toRtabmap(baseToRight).getDistance(expectedRight), 1e-4f) + << toRtabmap(baseToRight).prettyPrint(); +} + +TEST_F(DbPlayerTest, BroadcastsTheLaserTransform) +{ + TempDatabase db("scantf"); + writeScan3dDatabase(db.path()); + start(db.path()); + std::shared_ptr> tf = collectTf(); + ASSERT_TRUE(replayUntil([&]() { return !tf->empty(); })); + + geometry_msgs::msg::TransformStamped baseToLaser; + ASSERT_TRUE(findTransform(*tf, "base_link", "base_laser_link", baseToLaser)); + EXPECT_LT(toRtabmap(baseToLaser).getDistance(scanLocalTransform()), 1e-4f) + << toRtabmap(baseToLaser).prettyPrint(); +} + +TEST_F(DbPlayerTest, BroadcastsGroundTruthAndImuTransforms) +{ + TempDatabase db("richtf"); + writeRichDatabase(db.path()); + start(db.path()); + std::shared_ptr> tf = collectTf(); + ASSERT_TRUE(replayUntil([&]() { return !tf->empty(); })); + + geometry_msgs::msg::TransformStamped worldToGt; + ASSERT_TRUE(findTransform(*tf, "world", "base_link_gt", worldToGt)); + const int id = nodeIdOf(worldToGt.header.stamp); + ASSERT_GE(id, 1); + ASSERT_LE(id, kDbFrames); + EXPECT_LT(toRtabmap(worldToGt).getDistance(groundTruthOf(id)), 1e-4f) + << "the ground truth is published apart from the odometry"; + + geometry_msgs::msg::TransformStamped baseToImu; + ASSERT_TRUE(findTransform(*tf, "base_link", "imu_link", baseToImu)); + EXPECT_TRUE(toRtabmap(baseToImu).isIdentity()) + << "a gravity link is already expressed in the base frame"; +} + +TEST_F(DbPlayerTest, RenamesFramesFromParameters) +{ + TempDatabase db("frames"); + writeRgbdDatabase(db.path()); + start(db.path(), {rclcpp::Parameter("frame_id", std::string("robot")), + rclcpp::Parameter("odom_frame_id", std::string("world_odom")), + rclcpp::Parameter("camera_frame_id", std::string("optical"))}); + std::shared_ptr> tf = collectTf(); + ASSERT_TRUE(replayUntil([&]() { return !tf->empty(); })); + + geometry_msgs::msg::TransformStamped t; + EXPECT_TRUE(findTransform(*tf, "world_odom", "robot", t)); + EXPECT_TRUE(findTransform(*tf, "robot", "optical", t)); + EXPECT_FALSE(findTransform(*tf, "odom", "base_link", t)) << "the defaults must be gone"; +} + +TEST_F(DbPlayerTest, PublishTfFalseBroadcastsNothing) +{ + TempDatabase db("notf"); + writeRgbdDatabase(db.path()); + start(db.path(), {rclcpp::Parameter("publish_tf", false)}); + std::shared_ptr> tf = + collect("/tf", rclcpp::QoS(100)); + + ASSERT_TRUE(player_->publishNextFrame()); + ASSERT_TRUE(player_->publishNextFrame()); + spinFor(std::chrono::milliseconds(300)); + + EXPECT_TRUE(tf->empty()) << "publish_tf:=false must not create the broadcaster"; +} + +//============================================================================ +// The optional channels +//============================================================================ + +TEST_F(DbPlayerTest, ReplaysGlobalPose) +{ + TempDatabase db("globalpose"); + writeRichDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> pose = + collect("global_pose"); + ASSERT_TRUE(waitForPublisher(pose->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !pose->empty(); })) << "no global pose replayed"; + + const int id = nodeIdOf(pose->back().header.stamp); + ASSERT_GE(id, 1); + ASSERT_LE(id, kDbFrames); + EXPECT_EQ(pose->back().header.frame_id, "base_link"); + EXPECT_NEAR(pose->back().pose.pose.position.y, globalPoseOf(id).y(), 1e-5) + << "the prior pose is offset in y, unlike the odometry"; + // The prior was saved with an information matrix of 100*I. + EXPECT_NEAR(pose->back().pose.covariance[0], 0.01, 1e-6); +} + +TEST_F(DbPlayerTest, ReplaysGpsFix) +{ + TempDatabase db("gps"); + writeRichDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> gps = + collect("gps/fix"); + ASSERT_TRUE(waitForPublisher(gps->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !gps->empty(); })) << "no GPS replayed"; + + const sensor_msgs::msg::NavSatFix & msg = gps->back(); + EXPECT_NEAR(msg.longitude, kGpsLongitude, 1e-9); + EXPECT_NEAR(msg.latitude, kGpsLatitude, 1e-9); + EXPECT_NEAR(msg.altitude, kGpsAltitude, 1e-9); + EXPECT_EQ(msg.position_covariance_type, + uint8_t(sensor_msgs::msg::NavSatFix::COVARIANCE_TYPE_DIAGONAL_KNOWN)); + EXPECT_NEAR(msg.position_covariance[0], kGpsError*kGpsError, 1e-9) + << "the reported error is squared into a variance"; + EXPECT_NEAR(msg.position_covariance[4], kGpsError*kGpsError, 1e-9); + EXPECT_NEAR(msg.position_covariance[8], kGpsError*kGpsError, 1e-9); +} + +TEST_F(DbPlayerTest, ReplaysImu) +{ + TempDatabase db("imu"); + writeRichDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> imu = + collect("imu"); + ASSERT_TRUE(waitForPublisher(imu->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !imu->empty(); })) << "no IMU replayed"; + + EXPECT_EQ(imu->back().header.frame_id, "imu_link"); + + // DBReader rebuilds the IMU from the gravity link, so only the orientation survives. + const Eigen::Quaterniond expected = gravityTransform().getQuaterniond(); + EXPECT_NEAR(std::abs(imu->back().orientation.w), std::abs(expected.w()), 1e-5); + EXPECT_NEAR(std::abs(imu->back().orientation.x), std::abs(expected.x()), 1e-5); + EXPECT_NEAR(std::abs(imu->back().orientation.y), std::abs(expected.y()), 1e-5); + EXPECT_NEAR(std::abs(imu->back().orientation.z), std::abs(expected.z()), 1e-5); +} + +TEST_F(DbPlayerTest, ReplaysEnvSensor) +{ + TempDatabase db("envsensor"); + writeRichDatabase(db.path()); + start(db.path()); + primePublishers(); + + std::shared_ptr> env = + collect("env_sensor"); + ASSERT_TRUE(waitForPublisher(env->subscription)); + ASSERT_TRUE(replayUntil([&]() { return !env->empty(); })) << "no env sensor replayed"; + + EXPECT_EQ(env->back().type, int(rtabmap::EnvSensor::kAmbientTemperature)); + EXPECT_NEAR(env->back().value, kEnvSensorValue, 1e-9); + EXPECT_EQ(env->back().header.frame_id, "base_link"); +} + +TEST_F(DbPlayerTest, PublishesClockWhenAsked) +{ + TempDatabase db("clock"); + writeRgbdDatabase(db.path()); + start(db.path(), {rclcpp::Parameter("publish_clock", true)}); + std::shared_ptr> clock = + collect("/clock"); + ASSERT_TRUE(waitForPublisher(clock->subscription)); + + // The clock is not gated on subscribers either. + ASSERT_TRUE(replayUntil([&]() { return !clock->empty(); })) << "no clock published"; + + const int id = nodeIdOf(clock->back().clock); + ASSERT_GE(id, 1); + ASSERT_LE(id, kDbFrames); + EXPECT_NEAR(rtabmap_conversions::timestampFromROS(clock->back().clock), + stampOfNode(id), 1e-6) << "the clock follows the database stamps"; +} + +TEST_F(DbPlayerTest, NoClockByDefault) +{ + TempDatabase db("noclock"); + writeRgbdDatabase(db.path()); + start(db.path()); + std::shared_ptr> clock = + collect("/clock"); + + ASSERT_TRUE(player_->publishNextFrame()); + ASSERT_TRUE(player_->publishNextFrame()); + spinFor(std::chrono::milliseconds(300)); + + EXPECT_TRUE(clock->empty()); +} + +//============================================================================ +// Reading the database +//============================================================================ + +TEST_F(DbPlayerTest, StopsAtTheEndOfTheDatabase) +{ + TempDatabase db("end"); + writeRgbdDatabase(db.path(), 4); + start(db.path()); + + int frames = 0; + while(player_->publishNextFrame()) + { + ++frames; + ASSERT_LE(frames, 10) << "publishNextFrame() never reported the end"; + } + EXPECT_EQ(frames, 4) << "every node must be replayed exactly once"; +} + +TEST_F(DbPlayerTest, StartIdSkipsTheEarlierNodes) +{ + TempDatabase db("startid"); + writeRgbdDatabase(db.path(), 4); + start(db.path(), {rclcpp::Parameter("start_id", 3)}); + std::shared_ptr> tf = collectTf(); + + int frames = 0; + while(player_->publishNextFrame()) { ++frames; } + spinFor(std::chrono::milliseconds(200)); + EXPECT_EQ(frames, 2) << "nodes 3 and 4 only"; + + geometry_msgs::msg::TransformStamped t; + ASSERT_TRUE(findTransform(*tf, "odom", "base_link", t)); + EXPECT_EQ(nodeIdOf(tf->front().transforms[0].header.stamp), 3) + << "the replay must start at node 3"; +} + +//============================================================================ +// Pause / resume +//============================================================================ + +TEST_F(DbPlayerTest, StartsRunning) +{ + TempDatabase db("pause"); + writeRgbdDatabase(db.path()); + start(db.path()); + EXPECT_FALSE(player_->isPaused()); +} + +TEST_F(DbPlayerTest, PauseAndResumeServicesTogglePlayback) +{ + TempDatabase db("pausesrv"); + writeRgbdDatabase(db.path()); + start(db.path()); + + rclcpp::Client::SharedPtr pause = + helper()->create_client("db_player/pause"); + rclcpp::Client::SharedPtr resume = + helper()->create_client("db_player/resume"); + ASSERT_TRUE(spinUntil([&]() { return pause->service_is_ready() && resume->service_is_ready(); })) + << "the pause/resume services were never advertised"; + + pause->async_send_request(std::make_shared()); + ASSERT_TRUE(spinUntil([&]() { return player_->isPaused(); })) << "pause had no effect"; + + resume->async_send_request(std::make_shared()); + ASSERT_TRUE(spinUntil([&]() { return !player_->isPaused(); })) << "resume had no effect"; +} + +//============================================================================ +// Opening the database +//============================================================================ + +TEST_F(DbPlayerTest, ThrowsWithoutADatabaseParameter) +{ + // The node used to exit(-1) here, which took down every other node sharing its + // component container. Throwing lets the caller decide. + EXPECT_THROW( + std::make_shared(rclcpp::NodeOptions()), + std::invalid_argument); +} + +TEST_F(DbPlayerTest, ThrowsWhenTheDatabaseCannotBeOpened) +{ + TempDatabase db("missing"); // the path is never written + EXPECT_THROW( + std::make_shared(rclcpp::NodeOptions().parameter_overrides( + {rclcpp::Parameter("database", db.path())})), + std::runtime_error); +} diff --git a/rtabmap_util/test/test_disparity_to_depth.cpp b/rtabmap_util/test/test_disparity_to_depth.cpp new file mode 100644 index 00000000..ae622e59 --- /dev/null +++ b/rtabmap_util/test/test_disparity_to_depth.cpp @@ -0,0 +1,248 @@ +/* +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 + +using namespace rtabmap_util_test; + +namespace { +::testing::Environment * const kEnv = registerRclcppEnvironment(); + +constexpr float kBaseline = 0.1f; // t, meters +constexpr float kFocal = 500.0f; // f, pixels +constexpr int kWidth = 4; +constexpr int kHeight = 4; + +/// A 4x4 32FC1 disparity image, every pixel set to @p disparity. +stereo_msgs::msg::DisparityImage makeDisparity( + float disparity, + const std::string & encoding = sensor_msgs::image_encodings::TYPE_32FC1) +{ + stereo_msgs::msg::DisparityImage msg; + msg.header.frame_id = "camera_link"; + msg.header.stamp = rclcpp::Time(1000, 0, RCL_ROS_TIME); + msg.t = kBaseline; + msg.f = kFocal; + msg.min_disparity = 1.0f; + msg.max_disparity = 100.0f; + + msg.image.header = msg.header; + msg.image.encoding = encoding; + msg.image.height = kHeight; + msg.image.width = kWidth; + msg.image.step = kWidth * sizeof(float); + msg.image.data.resize(msg.image.step * kHeight); + float * p = reinterpret_cast(msg.image.data.data()); + for(int i=0; i(&img.data[row * img.step + col * sizeof(float)]); +} + +uint16_t pixel16u(const sensor_msgs::msg::Image & img, int row, int col) +{ + return *reinterpret_cast(&img.data[row * img.step + col * sizeof(uint16_t)]); +} +} // namespace + +class DisparityToDepthTest : public NodeTest {}; + +TEST_F(DisparityToDepthTest, ConvertsDisparityToMetricDepth) +{ + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> depth = + collect("depth"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("disparity", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(depth->subscription)) << "the node never advertised depth"; + + // depth = baseline * focal / disparity = 0.1 * 500 / 10 = 5 m + pub->publish(makeDisparity(10.0f)); + ASSERT_TRUE(spinUntil([&]() { return !depth->empty(); })); + + const sensor_msgs::msg::Image & img = depth->back(); + EXPECT_EQ(img.encoding, sensor_msgs::image_encodings::TYPE_32FC1); + EXPECT_EQ(img.width, uint32_t(kWidth)); + EXPECT_EQ(img.height, uint32_t(kHeight)); + EXPECT_EQ(img.header.frame_id, "camera_link") << "the input header must be preserved"; + EXPECT_NEAR(pixel32f(img, 0, 0), 5.0f, 1e-4); + EXPECT_NEAR(pixel32f(img, kHeight-1, kWidth-1), 5.0f, 1e-4); +} + +TEST_F(DisparityToDepthTest, PublishesMillimetersOnDepthRaw) +{ + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> raw = + collect("depth_raw"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("disparity", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(raw->subscription)); + + pub->publish(makeDisparity(10.0f)); + ASSERT_TRUE(spinUntil([&]() { return !raw->empty(); })); + + const sensor_msgs::msg::Image & img = raw->back(); + EXPECT_EQ(img.encoding, sensor_msgs::image_encodings::TYPE_16UC1); + EXPECT_EQ(pixel16u(img, 0, 0), 5000) << "5 m expressed in millimeters"; +} + +TEST_F(DisparityToDepthTest, PublishesBothUnitsConsistentlyFromOneInput) +{ + // With both topics subscribed the node fills the 32FC1 and 16UC1 images in the same + // pass. The two must describe the same depth, one in meters and one in millimeters. + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> meters = + collect("depth"); + std::shared_ptr> millimeters = + collect("depth_raw"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("disparity", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(meters->subscription)); + ASSERT_TRUE(waitForPublisher(millimeters->subscription)); + + // A disparity of 25 gives 0.1 * 500 / 25 = 2 m. + pub->publish(makeDisparity(25.0f)); + ASSERT_TRUE(spinUntil([&]() { return !meters->empty() && !millimeters->empty(); })) + << "both outputs must be produced from a single input"; + + EXPECT_EQ(meters->back().encoding, sensor_msgs::image_encodings::TYPE_32FC1); + EXPECT_EQ(millimeters->back().encoding, sensor_msgs::image_encodings::TYPE_16UC1); + + for(int row=0; rowback(), row, col); + const uint16_t mm = pixel16u(millimeters->back(), row, col); + EXPECT_NEAR(m, 2.0f, 1e-4) << "at " << row << "," << col; + EXPECT_EQ(mm, 2000) << "at " << row << "," << col; + EXPECT_EQ(mm, uint16_t(m * 1000.0f)) << "the two units must agree at " << row << "," << col; + } + } +} + +TEST_F(DisparityToDepthTest, LeavesOutOfRangeDisparityAtZero) +{ + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> depth = + collect("depth"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("disparity", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + // Above max_disparity (100), so no depth can be computed. + pub->publish(makeDisparity(500.0f)); + ASSERT_TRUE(spinUntil([&]() { return !depth->empty(); })); + + EXPECT_FLOAT_EQ(pixel32f(depth->back(), 0, 0), 0.0f); +} + +TEST_F(DisparityToDepthTest, RejectsNon32FC1Input) +{ + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> depth = + collect("depth"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("disparity", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + pub->publish(makeDisparity(10.0f, sensor_msgs::image_encodings::TYPE_16UC1)); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(depth->empty()) << "only 32FC1 disparity is supported"; +} + +TEST_F(DisparityToDepthTest, HonorsTheConfiguredQueueDepths) +{ + // Queue depth is not observable from outside, so this pins down that the parameters + // are accepted and the node still converts with them set. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("queue_sub", 20), + rclcpp::Parameter("queue_pub", 10)}))); + + std::shared_ptr> depth = + collect("depth"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("disparity", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + pub->publish(makeDisparity(1.0f)); + ASSERT_TRUE(spinUntil([&]() { return !depth->empty(); })); + EXPECT_EQ(depth->back().encoding, sensor_msgs::image_encodings::TYPE_32FC1); +} + +TEST_F(DisparityToDepthTest, RejectsAZeroQueueDepth) +{ + EXPECT_THROW( + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("queue_pub", 0)}))), + UException); +} + +TEST_F(DisparityToDepthTest, BridgesABestEffortSourceToAReliableConsumer) +{ + // A reliable subscription refuses to match a best-effort publisher, so setting the + // two sides apart is what lets the conversion cross that gap. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("qos_sub", 2), + rclcpp::Parameter("qos_pub", 1)}))); + + std::shared_ptr> depth = + collect("depth", rclcpp::QoS(10).reliable()); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher( + "disparity", rclcpp::QoS(10).best_effort()); + ASSERT_TRUE(waitForSubscriber(pub)) << "a best-effort source must reach the node"; + ASSERT_TRUE(waitForPublisher(depth->subscription)) + << "a reliable consumer must be able to subscribe to the depth output"; + + pub->publish(makeDisparity(1.0f)); + ASSERT_TRUE(spinUntil([&]() { return !depth->empty(); })); + EXPECT_EQ(depth->back().encoding, sensor_msgs::image_encodings::TYPE_32FC1); +} + +TEST_F(DisparityToDepthTest, TheTwoQosSidesFallBackToQos) +{ + // Only qos is given, so both sides must be best effort: a reliable consumer matches + // neither the publishers nor, from the other end, the subscription. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("qos", 2)}))); + + std::shared_ptr> depth = + collect("depth", rclcpp::QoS(10).reliable()); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher( + "disparity", rclcpp::QoS(10).best_effort()); + EXPECT_TRUE(waitForSubscriber(pub)) << "the subscription must have followed qos"; + + spinFor(std::chrono::milliseconds(500)); + EXPECT_EQ(depth->subscription->get_publisher_count(), 0u) + << "the publishers must have followed qos too: best effort, so a reliable " + "consumer cannot match them"; +} diff --git a/rtabmap_util/test/test_imu_to_tf.cpp b/rtabmap_util/test/test_imu_to_tf.cpp new file mode 100644 index 00000000..f35ca7a7 --- /dev/null +++ b/rtabmap_util/test/test_imu_to_tf.cpp @@ -0,0 +1,205 @@ +/* +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 + +using namespace rtabmap_util_test; + +namespace { +::testing::Environment * const kEnv = registerRclcppEnvironment(); + +/// An Imu message whose orientation is a pure rotation of @p yaw about z. +sensor_msgs::msg::Imu makeImu(const std::string & frameId, double stamp, double yaw = 0.0) +{ + tf2::Quaternion q; + q.setRPY(0.0, 0.0, yaw); + + sensor_msgs::msg::Imu msg; + msg.header.frame_id = frameId; + msg.header.stamp = rclcpp::Time(int32_t(stamp), uint32_t((stamp - int32_t(stamp)) * 1e9), RCL_ROS_TIME); + msg.orientation = tf2::toMsg(q); + return msg; +} +} // namespace + +class ImuToTFTest : public NodeTest +{ +protected: + rclcpp::Publisher::SharedPtr staticTfKeepAlive_; +}; + +TEST_F(ImuToTFTest, BroadcastsOrientationAsTf) +{ + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("fixed_frame_id", "odom")}))); + + std::shared_ptr> tf = + collect("/tf"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("imu/data", 10); + ASSERT_TRUE(waitForSubscriber(pub)) << "the node never subscribed to imu/data"; + + pub->publish(makeImu("imu_link", 1000.0, /*yaw=*/M_PI/2.0)); + ASSERT_TRUE(spinUntil([&]() { return !tf->empty(); })) << "no transform was broadcast"; + + ASSERT_EQ(tf->back().transforms.size(), 1u); + const geometry_msgs::msg::TransformStamped & t = tf->back().transforms[0]; + EXPECT_EQ(t.header.frame_id, "odom"); + EXPECT_EQ(t.child_frame_id, "imu_link") << "with no base_frame_id the imu frame is used"; + + // The broadcast rotation must be the IMU's orientation. + tf2::Quaternion q; + tf2::fromMsg(t.transform.rotation, q); + EXPECT_NEAR(tf2::getYaw(q), M_PI/2.0, 1e-6); + + // It is an orientation only: no translation. + EXPECT_NEAR(t.transform.translation.x, 0.0, 1e-9); + EXPECT_NEAR(t.transform.translation.y, 0.0, 1e-9); + EXPECT_NEAR(t.transform.translation.z, 0.0, 1e-9); +} + +TEST_F(ImuToTFTest, UsesTheConfiguredFixedFrame) +{ + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("fixed_frame_id", "my_odom")}))); + + std::shared_ptr> tf = + collect("/tf"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("imu/data", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + pub->publish(makeImu("imu_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !tf->empty(); })); + + EXPECT_EQ(tf->back().transforms[0].header.frame_id, "my_odom"); +} + +TEST_F(ImuToTFTest, PreservesTheImuStamp) +{ + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> tf = + collect("/tf"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("imu/data", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + const sensor_msgs::msg::Imu imu = makeImu("imu_link", 1234.5); + pub->publish(imu); + ASSERT_TRUE(spinUntil([&]() { return !tf->empty(); })); + + EXPECT_EQ(tf->back().transforms[0].header.stamp.sec, imu.header.stamp.sec); + EXPECT_EQ(tf->back().transforms[0].header.stamp.nanosec, imu.header.stamp.nanosec); +} + +TEST_F(ImuToTFTest, ReportsTheOrientationInTheBaseFrame) +{ + // With base_frame_id set and the mounting transform available, the node re-expresses + // the IMU orientation in the base frame and broadcasts that frame instead. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("base_frame_id", "base_link"), + rclcpp::Parameter("wait_for_transform_duration", 0.5)}))); + publishStaticTf("base_link", "imu_link", 0.1, 0.0, 0.2); // translation only + + std::shared_ptr> tf = + collect("/tf"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("imu/data", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + pub->publish(makeImu("imu_link", 1000.0, /*yaw=*/M_PI/2.0)); + ASSERT_TRUE(spinUntil([&]() { return !tf->empty(); })) + << "with the mounting transform available a transform must be broadcast"; + + const geometry_msgs::msg::TransformStamped & t = tf->back().transforms[0]; + EXPECT_EQ(t.header.frame_id, "odom"); + EXPECT_EQ(t.child_frame_id, "base_link") + << "the base frame is broadcast, not the imu frame"; + + // The mounting has no rotation, so the orientation is unchanged. + tf2::Quaternion q; + tf2::fromMsg(t.transform.rotation, q); + EXPECT_NEAR(tf2::getYaw(q), M_PI/2.0, 1e-6); +} + +TEST_F(ImuToTFTest, IgnoresAYawOnlyMountingOffset) +{ + // The node strips the yaw of the mounting transform (it uses only getYaw to build + // the correction), so a purely yaw-rotated mount leaves the reported orientation + // alone: the IMU's absolute yaw is what matters, not how it is bolted on. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("base_frame_id", "base_link"), + rclcpp::Parameter("wait_for_transform_duration", 0.5)}))); + + // base_link -> imu_link rotated 90 degrees about z. + { + tf2::Quaternion mount; + mount.setRPY(0.0, 0.0, M_PI/2.0); + geometry_msgs::msg::TransformStamped m; + m.header.stamp = helper()->now(); + m.header.frame_id = "base_link"; + m.child_frame_id = "imu_link"; + m.transform.rotation = tf2::toMsg(mount); + tf2_msgs::msg::TFMessage msg; + msg.transforms.push_back(m); + rclcpp::Publisher::SharedPtr staticPub = + helper()->create_publisher( + "/tf_static", rclcpp::QoS(100).transient_local()); + staticPub->publish(msg); + spinFor(std::chrono::milliseconds(150)); + staticTfKeepAlive_ = staticPub; + } + + std::shared_ptr> tf = + collect("/tf"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("imu/data", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + pub->publish(makeImu("imu_link", 1000.0, /*yaw=*/M_PI/4.0)); + ASSERT_TRUE(spinUntil([&]() { return !tf->empty(); })); + + const geometry_msgs::msg::TransformStamped & t = tf->back().transforms[0]; + EXPECT_EQ(t.child_frame_id, "base_link"); + + tf2::Quaternion q; + tf2::fromMsg(t.transform.rotation, q); + EXPECT_NEAR(tf2::getYaw(q), M_PI/4.0, 1e-5) + << "the mounting yaw must cancel out, leaving the imu's own yaw"; +} + +TEST_F(ImuToTFTest, DropsTheMessageWhenTheBaseTransformIsMissing) +{ + // base_frame_id differs from the imu frame, so the node needs imu_link -> base_link + // from TF. Nothing publishes it, so nothing may be broadcast. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("base_frame_id", "base_link"), + rclcpp::Parameter("wait_for_transform_duration", 0.0)}))); + + std::shared_ptr> tf = + collect("/tf"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("imu/data", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + pub->publish(makeImu("imu_link", 1000.0, M_PI/2.0)); + spinFor(std::chrono::milliseconds(500)); + + EXPECT_TRUE(tf->empty()) << "without the base transform the node must not broadcast"; +} diff --git a/rtabmap_util/test/test_lidar_deskewing.cpp b/rtabmap_util/test/test_lidar_deskewing.cpp new file mode 100644 index 00000000..e28db65c --- /dev/null +++ b/rtabmap_util/test/test_lidar_deskewing.cpp @@ -0,0 +1,204 @@ +/* +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"; +} diff --git a/rtabmap_util/test/test_map_assembler.cpp b/rtabmap_util/test/test_map_assembler.cpp new file mode 100644 index 00000000..e2b22312 --- /dev/null +++ b/rtabmap_util/test/test_map_assembler.cpp @@ -0,0 +1,549 @@ +/* +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 + +#include +#include +#include + +#include + +#if defined(WITH_OCTOMAP_MSGS) and defined(RTABMAP_OCTOMAP) +#include +#endif + +using namespace rtabmap_util_test; + +namespace { +::testing::Environment * const kEnv = registerRclcppEnvironment(); + +constexpr float kCellSize = 0.05f; +/// Anything above this is an obstacle once a grid is regenerated from a scan. +constexpr float kGroundHeight = 0.1f; +constexpr float kObstacleHeight = 0.5f; + +cv::Mat toCellMat(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; +} + +/** + * @brief A graph node as it arrives on "mapData". + * + * map_assembler only caches a node that carries compressed images or a compressed scan, + * so the scan is what makes the node acceptable at all. The occupancy grid is what + * MapsManager normally uses; the two are deliberately given different geometry so a test + * can tell which one ended up in the map. + * + * @param scan points of the raw scan, in the node's frame + * @param ground ground cells of the ready-made grid + * @param obstacles obstacle cells of the ready-made grid + */ +rtabmap::Signature makeNode( + int id, const rtabmap::Transform & pose, + const std::vector & scan, + const std::vector & ground, + const std::vector & obstacles) +{ + rtabmap::SensorData data; + data.setId(id); + data.setStamp(1000.0 + id); + data.setLaserScan(rtabmap::LaserScan(rtabmap::compressData2(toCellMat(scan)), + /*maxPoints=*/0, /*maxRange=*/0.0f, rtabmap::LaserScan::kXYZ)); + if(!ground.empty() || !obstacles.empty()) + { + data.setOccupancyGrid(toCellMat(ground), toCellMat(obstacles), cv::Mat(), kCellSize, + cv::Point3f(0, 0, 0)); + } + return rtabmap::Signature(id, /*mapId=*/0, /*weight=*/1, data.stamp(), /*label=*/"", + pose, rtabmap::Transform(), data); +} + +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)); +} + +bool containsPoint(const sensor_msgs::msg::PointCloud2 & cloud, const cv::Point3f & expected, + float tolerance = 1e-3f) +{ + for(size_t i=0; icreate_service( + std::string(kRtabmapName) + "/get_map_data", + [this](const std::shared_ptr, + std::shared_ptr response) { + ++getMapCalls_; + response->data = initialMap_; + }); + } + + /// Creates the node with the start-up call skipped, so it subscribes right away. + void start(std::vector overrides = {}) + { + overrides.push_back(rclcpp::Parameter("initialize_from_rtabmap_timeout", 0.0)); + createNode(overrides); + ASSERT_TRUE(waitForSubscriber(mapDataPub_)) + << "map_assembler never subscribed to mapData"; + } + + /** + * @brief Creates the node with the start-up call enabled, as it is by default. + * + * It only subscribes to "mapData" once that call has returned, and the call blocks a + * callback on another callback of the same node, so it needs a multi-threaded + * executor to get through. + */ + void startInitializingFromRtabmap(double timeout = 5.0, + std::vector overrides = {}) + { + overrides.push_back( + rclcpp::Parameter("initialize_from_rtabmap_timeout", timeout)); + createNode(overrides); + ASSERT_TRUE(spinMultiThreadedUntil( + [&]() { return mapDataPub_->get_subscription_count() > 0; })) + << "map_assembler never subscribed to mapData"; + } + + void createNode(std::vector overrides) + { + overrides.push_back(rclcpp::Parameter("rtabmap", std::string(kRtabmapName))); + assembler_ = addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + mapDataPub_ = helper()->create_publisher("mapData", + rclcpp::QoS(1)); + } + + /// A MapData carrying @p signatures and a graph over their poses. + static rtabmap_msgs::msg::MapData makeMapData( + const std::vector & signatures, + const std::vector & graphIds = {}) + { + std::map poses; + rtabmap_msgs::msg::MapData msg; + msg.header.frame_id = "map"; + msg.header.stamp = stampOf(2000.0); + + for(size_t i=0; i(), + rtabmap::Transform::getIdentity(), msg.graph); + return msg; + } + + static rtabmap::Transform poseOf(int id) + { + return rtabmap::Transform(2.0f * float(id - 1), 0.0f, 0.0f, 0.0f, 0.0f, 0.0f); + } + + /// Two nodes whose ready-made grids hold one ground and one obstacle cell each. + static std::vector twoNodes() + { + return { + makeNode(1, poseOf(1), + /*scan=*/{cv::Point3f(0.5f, -0.1f, 0.0f), + cv::Point3f(0.5f, 0.1f, kObstacleHeight)}, + /*ground=*/{cv::Point3f(0.5f, -0.1f, 0.0f)}, + /*obstacles=*/{cv::Point3f(1.0f, 0.1f, 0.0f)}), + makeNode(2, poseOf(2), + /*scan=*/{cv::Point3f(0.5f, -0.1f, 0.0f), + cv::Point3f(0.5f, 0.1f, kObstacleHeight)}, + /*ground=*/{cv::Point3f(0.5f, -0.1f, 0.0f)}, + /*obstacles=*/{cv::Point3f(1.0f, 0.1f, 0.0f)})}; + } + + void publishMapData(const rtabmap_msgs::msg::MapData & msg) + { + mapDataPub_->publish(msg); + } + + std::shared_ptr assembler_; + rclcpp::Publisher::SharedPtr mapDataPub_; + rclcpp::Service::SharedPtr getMapService_; + rtabmap_msgs::msg::MapData initialMap_; + std::atomic_int getMapCalls_{0}; +}; + +constexpr const char * MapAssemblerTest::kRtabmapName; + +//============================================================================ +// Start-up +//============================================================================ + +TEST_F(MapAssemblerTest, AsksRtabmapForTheMapByDefault) +{ + advertiseGetMapData(makeMapData(twoNodes())); + createNode({}); // no overrides at all, so the default timeout applies + ASSERT_TRUE(spinMultiThreadedUntil( + [&]() { return mapDataPub_->get_subscription_count() > 0; })); + + EXPECT_EQ(getMapCalls_.load(), 1) << "the start-up service call is made exactly once"; +} + +TEST_F(MapAssemblerTest, SkipsTheStartUpCallWhenTheTimeoutIsZero) +{ + // Nothing to catch up on, so the node should not spend its start-up waiting on a + // service: it subscribes immediately instead. + advertiseGetMapData(makeMapData(twoNodes())); + start(); + EXPECT_EQ(getMapCalls_.load(), 0) << "rtabmap must not be called with a zero timeout"; +} + +TEST_F(MapAssemblerTest, SubscribesAnywayWhenRtabmapNeverAnswers) +{ + // Nothing advertises get_map_data, so the start-up call times out. The node must + // still come up and subscribe, since rtabmap may be started afterwards. + // Short, because unlike every other test here this one waits the timeout out. + startInitializingFromRtabmap(/*timeout=*/0.5); + EXPECT_EQ(getMapCalls_.load(), 0); +} + +TEST_F(MapAssemblerTest, WaitsForRtabmapToShowUpDuringTheTimeout) +{ + // get_map_data is not advertised when the node starts asking for it: it appears part + // way through the wait. The call must still go through, which is what makes the + // timeout a real wait rather than a check of what happens to be up already. + // + // The node's timer fires one second after construction and then waits 750 ms, so + // advertising at 1250 ms lands inside that window with room on both sides. + std::thread rtabmapStartsLate([this]() { + std::this_thread::sleep_for(std::chrono::milliseconds(1250)); + advertiseGetMapData(makeMapData(twoNodes())); + }); + + startInitializingFromRtabmap(/*timeout=*/0.75); + rtabmapStartsLate.join(); + + EXPECT_EQ(getMapCalls_.load(), 1) << "rtabmap showed up before the wait expired"; +} + +TEST_F(MapAssemblerTest, StartsFromTheMapRtabmapHandsBack) +{ + // The nodes come from the start-up call, and the graph that arrives later names them + // without resending their data. The map must still be assembled from the cache. + advertiseGetMapData(makeMapData(twoNodes())); + startInitializingFromRtabmap(); + + std::shared_ptr> cloud = + collect("cloud_map"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + + publishMapData(makeMapData({}, /*graphIds=*/{1, 2})); + ASSERT_TRUE(spinUntil([&]() { return !cloud->empty(); })) << "no cloud assembled"; + + EXPECT_EQ(pointCount(cloud->back()), 4u) << "one ground and one obstacle cell per node"; +} + +//============================================================================ +// Assembling +//============================================================================ + +TEST_F(MapAssemblerTest, AssemblesTheCloudFromMapData) +{ + start(); + + std::shared_ptr> cloud = + collect("cloud_map"); + std::shared_ptr> obstacles = + collect("cloud_obstacles"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + + publishMapData(makeMapData(twoNodes())); + ASSERT_TRUE(spinUntil([&]() { return !cloud->empty() && !obstacles->empty(); })) + << "no cloud assembled"; + + EXPECT_EQ(cloud->back().header.frame_id, "map") << "the frame comes from the message"; + EXPECT_EQ(pointCount(cloud->back()), 4u); + EXPECT_EQ(pointCount(obstacles->back()), 2u); + // Node 2 sits 2 m along x, so its obstacle cell lands at 3 m. + EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(1.0f, 0.1f, 0.0f))); + EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(3.0f, 0.1f, 0.0f))); +} + +TEST_F(MapAssemblerTest, PublishesTheOccupancyGrid) +{ + start(); + + std::shared_ptr> grid = + collect("map"); + ASSERT_TRUE(waitForPublisher(grid->subscription)); + + publishMapData(makeMapData(twoNodes())); + ASSERT_TRUE(spinUntil([&]() { return !grid->empty(); })) << "no grid assembled"; + + EXPECT_EQ(grid->back().header.frame_id, "map"); + EXPECT_NEAR(grid->back().info.resolution, kCellSize, 1e-6); + EXPECT_GT(grid->back().info.width, 0u); +} + +TEST_F(MapAssemblerTest, IgnoresAnEmptyMapData) +{ + start(); + + std::shared_ptr> cloud = + collect("cloud_map"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + + rtabmap_msgs::msg::MapData empty; + empty.header.frame_id = "map"; + empty.header.stamp = stampOf(2000.0); + publishMapData(empty); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(cloud->empty()) << "a message with no graph and no nodes is nothing to do"; +} + +TEST_F(MapAssemblerTest, PublishesAnEmptyMapForAGraphWithNoCachedNodes) +{ + // A graph can name nodes whose data map_assembler has never seen -- it has no cache + // at all here. It still publishes, using the poses as they are. + start(); + + std::shared_ptr> cloud = + collect("cloud_map"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + + publishMapData(makeMapData({}, /*graphIds=*/{1, 2})); + ASSERT_TRUE(spinUntil([&]() { return !cloud->empty(); })) << "nothing published"; + + EXPECT_EQ(pointCount(cloud->back()), 0u) << "no data cached, so nothing to assemble"; + EXPECT_EQ(cloud->back().header.frame_id, "map"); +} + +//============================================================================ +// regenerate_local_grids +//============================================================================ + +TEST_F(MapAssemblerTest, UsesTheGridsThatCameWithTheNodes) +{ + // By default the ready-made grid wins: its obstacle is at y=+0.1, the scan's is at + // y=-0.1 with the ground point, so the two are told apart by where the cells land. + start({rclcpp::Parameter(rtabmap::Parameters::kGridSensor(), std::string("0")), + rclcpp::Parameter(rtabmap::Parameters::kGridNormalsSegmentation(), + std::string("false")), + rclcpp::Parameter(rtabmap::Parameters::kGridMaxGroundHeight(), + std::string("0.1"))}); + + std::shared_ptr> obstacles = + collect("cloud_obstacles"); + ASSERT_TRUE(waitForPublisher(obstacles->subscription)); + + publishMapData(makeMapData({twoNodes()[0]})); + ASSERT_TRUE(spinUntil([&]() { return !obstacles->empty(); })); + + EXPECT_EQ(pointCount(obstacles->back()), 1u); + EXPECT_TRUE(containsPoint(obstacles->back(), cv::Point3f(1.0f, 0.1f, 0.0f))) + << "the obstacle cell of the grid that came with the node"; +} + +TEST_F(MapAssemblerTest, RegenerateLocalGridsRebuildsThemFromTheScan) +{ + // With regenerate_local_grids the grid that came with the node is thrown away, so + // MapsManager segments the scan instead: the raised scan point becomes the obstacle. + start({rclcpp::Parameter("regenerate_local_grids", true), + rclcpp::Parameter(rtabmap::Parameters::kGridSensor(), std::string("0")), + rclcpp::Parameter(rtabmap::Parameters::kGridNormalsSegmentation(), + std::string("false")), + rclcpp::Parameter(rtabmap::Parameters::kGridMaxGroundHeight(), + std::string("0.1"))}); + + std::shared_ptr> obstacles = + collect("cloud_obstacles"); + ASSERT_TRUE(waitForPublisher(obstacles->subscription)); + + publishMapData(makeMapData({twoNodes()[0]})); + ASSERT_TRUE(spinUntil([&]() { return !obstacles->empty(); })); + + EXPECT_EQ(pointCount(obstacles->back()), 1u); + EXPECT_TRUE(containsPoint(obstacles->back(), + cv::Point3f(0.5f, 0.1f, kObstacleHeight), kCellSize)) + << "the raised scan point, not the cell the node arrived with"; + EXPECT_FALSE(containsPoint(obstacles->back(), cv::Point3f(1.0f, 0.1f, 0.0f))) + << "the grid that came with the node must have been discarded"; +} + +//============================================================================ +// Services +//============================================================================ + +TEST_F(MapAssemblerTest, ResetEmptiesTheMap) +{ + start(); + + std::shared_ptr> cloud = + collect("cloud_map"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + + publishMapData(makeMapData(twoNodes())); + ASSERT_TRUE(spinUntil([&]() { return !cloud->empty(); })); + ASSERT_EQ(pointCount(cloud->back()), 4u); + + rclcpp::Client::SharedPtr reset = + helper()->create_client("map_assembler/reset"); + ASSERT_TRUE(spinUntil([&]() { return reset->service_is_ready(); })) + << "the reset service was never advertised"; + reset->async_send_request(std::make_shared()); + ASSERT_TRUE(spinUntil([&]() { return cloud->size() >= 1u; })); + spinFor(std::chrono::milliseconds(200)); + + // The cache is gone, so the same graph now assembles nothing. + const size_t before = cloud->size(); + publishMapData(makeMapData({}, /*graphIds=*/{1, 2})); + ASSERT_TRUE(spinUntil([&]() { return cloud->size() > before; })); + EXPECT_EQ(pointCount(cloud->back()), 0u) << "reset must drop the cached nodes"; +} + +#if defined(WITH_OCTOMAP_MSGS) and defined(RTABMAP_OCTOMAP) +TEST_F(MapAssemblerTest, ServesTheBinaryOctomap) +{ + start(); + + std::shared_ptr> cloud = + collect("cloud_map"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + publishMapData(makeMapData(twoNodes())); + ASSERT_TRUE(spinUntil([&]() { return !cloud->empty(); })); + + rclcpp::Client::SharedPtr client = + helper()->create_client( + "map_assembler/octomap_binary"); + ASSERT_TRUE(spinUntil([&]() { return client->service_is_ready(); })); + + auto future = client->async_send_request( + std::make_shared()); + ASSERT_TRUE(spinUntil([&]() { + return future.wait_for(std::chrono::seconds(0)) == std::future_status::ready; })) + << "octomap_binary never answered"; + + // Keep the response alive: future.get() hands back a temporary shared_ptr, so binding + // a reference into it would dangle. + const std::shared_ptr response = future.get(); + EXPECT_EQ(response->map.header.frame_id, "map") + << "the frame of the last map data received"; + EXPECT_TRUE(response->map.binary); + EXPECT_FALSE(response->map.data.empty()) + << "the octomap is built on demand from the cache"; +} + +TEST_F(MapAssemblerTest, ServesTheFullOctomap) +{ + start(); + + std::shared_ptr> cloud = + collect("cloud_map"); + ASSERT_TRUE(waitForPublisher(cloud->subscription)); + publishMapData(makeMapData(twoNodes())); + ASSERT_TRUE(spinUntil([&]() { return !cloud->empty(); })); + + rclcpp::Client::SharedPtr client = + helper()->create_client( + "map_assembler/octomap_full"); + ASSERT_TRUE(spinUntil([&]() { return client->service_is_ready(); })); + + auto future = client->async_send_request( + std::make_shared()); + ASSERT_TRUE(spinUntil([&]() { + return future.wait_for(std::chrono::seconds(0)) == std::future_status::ready; })); + + EXPECT_FALSE(future.get()->map.binary); +} + +TEST_F(MapAssemblerTest, ServesAnEmptyOctomapWithoutData) +{ + start(); + + rclcpp::Client::SharedPtr client = + helper()->create_client( + "map_assembler/octomap_binary"); + ASSERT_TRUE(spinUntil([&]() { return client->service_is_ready(); })); + + auto future = client->async_send_request( + std::make_shared()); + ASSERT_TRUE(spinUntil([&]() { + return future.wait_for(std::chrono::seconds(0)) == std::future_status::ready; })); + + EXPECT_TRUE(future.get()->map.data.empty()) << "nothing cached, nothing to serve"; +} +#endif diff --git a/rtabmap_util/test/test_maps_manager.cpp b/rtabmap_util/test/test_maps_manager.cpp new file mode 100644 index 00000000..aa49d7f1 --- /dev/null +++ b/rtabmap_util/test/test_maps_manager.cpp @@ -0,0 +1,1060 @@ +/* +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); +} + diff --git a/rtabmap_util/test/test_obstacles_detection.cpp b/rtabmap_util/test/test_obstacles_detection.cpp new file mode 100644 index 00000000..50c9e499 --- /dev/null +++ b/rtabmap_util/test/test_obstacles_detection.cpp @@ -0,0 +1,423 @@ +/* +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); +} diff --git a/rtabmap_util/test/test_point_cloud_aggregator.cpp b/rtabmap_util/test/test_point_cloud_aggregator.cpp new file mode 100644 index 00000000..45469223 --- /dev/null +++ b/rtabmap_util/test/test_point_cloud_aggregator.cpp @@ -0,0 +1,194 @@ +/* +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 + +using namespace rtabmap_util_test; + +namespace { +::testing::Environment * const kEnv = registerRclcppEnvironment(); +} + +class PointCloudAggregatorTest : public NodeTest +{ +protected: + /// odom -> base_link advancing along x at 1 m/s across the two cloud stamps. + void publishOdomMotion(double startStamp, double duration) + { + rclcpp::Publisher::SharedPtr tfPub = + helper()->create_publisher("/tf", rclcpp::QoS(100)); + spinFor(std::chrono::milliseconds(100)); + for(int i=0; i<=6; ++i) + { + const double elapsed = duration * double(i) / 6.0; + geometry_msgs::msg::TransformStamped t; + t.header.stamp = stampOf(startStamp + elapsed); + t.header.frame_id = "odom"; + t.child_frame_id = "base_link"; + t.transform.translation.x = elapsed; // 1 m/s + t.transform.rotation.w = 1.0; + tf2_msgs::msg::TFMessage msg; + msg.transforms.push_back(t); + tfPub->publish(msg); + } + spinFor(std::chrono::milliseconds(200)); + tfPub_ = tfPub; + } + + /** + * @brief Publishes three pairs of clouds observing one landmark 5 m ahead in odom. + * + * Pair k is stamped at 1000.0+0.2k and 0.1 s later. The robot drives at 1 m/s, so + * each sensor measures the landmark at 5 m minus the distance travelled by then. + */ + void publishPairs( + const rclcpp::Publisher::SharedPtr & pub1, + const rclcpp::Publisher::SharedPtr & pub2) + { + for(int k=0; k<3; ++k) + { + const double t1 = 1000.0 + 0.2*double(k); + const double t2 = t1 + 0.1; + pub1->publish(makeXYZCloud("lidar_a", t1, {{float(5.0-(t1-1000.0)), 0.0f, 0.0f}})); + pub2->publish(makeXYZCloud("lidar_b", t2, {{float(5.0-(t2-1000.0)), 0.0f, 0.0f}})); + spinFor(std::chrono::milliseconds(50)); + } + } + + rclcpp::Publisher::SharedPtr tfPub_; +}; + +TEST_F(PointCloudAggregatorTest, CombinesTwoSynchronizedClouds) +{ + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("count", 2), + rclcpp::Parameter("frame_id", "base_link"), + rclcpp::Parameter("approx_sync", true), + rclcpp::Parameter("wait_for_transform", 0.2)}))); + publishStaticTf("base_link", "lidar_a", 0.0, 0.2, 0.0); + publishStaticTf("base_link", "lidar_b", 0.0, -0.2, 0.0); + + std::shared_ptr> out = + collect("combined_cloud"); + rclcpp::Publisher::SharedPtr pub1 = + helper()->create_publisher("cloud1", 10); + rclcpp::Publisher::SharedPtr pub2 = + helper()->create_publisher("cloud2", 10); + ASSERT_TRUE(waitForSubscriber(pub1)); + ASSERT_TRUE(waitForSubscriber(pub2)); + + const std::vector a = {{1.0f, 0.0f, 0.0f}, {2.0f, 0.0f, 0.0f}}; + const std::vector b = {{3.0f, 0.0f, 0.0f}}; + pub1->publish(makeXYZCloud("lidar_a", 1000.0, a)); + pub2->publish(makeXYZCloud("lidar_b", 1000.0, b)); + + ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })) << "no combined cloud published"; + EXPECT_EQ(out->back().width, a.size() + b.size()) << "every input point must survive"; + EXPECT_EQ(out->back().header.frame_id, "base_link") + << "the combined cloud is expressed in frame_id"; +} + +TEST_F(PointCloudAggregatorTest, AlignsCloudsCapturedAtDifferentTimesWhileMoving) +{ + // The two sensors fire 0.1 s apart while the robot drives forward at 1 m/s, so they + // see the same world point at different ranges. With fixed_frame_id set, the second + // cloud is motion-compensated back to the first one's stamp and the two coincide. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("count", 2), + rclcpp::Parameter("frame_id", "base_link"), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("approx_sync", true), + rclcpp::Parameter("wait_for_transform", 0.2)}))); + publishStaticTf("base_link", "lidar_a"); + publishStaticTf("base_link", "lidar_b"); + publishOdomMotion(1000.0, 0.6); + + std::shared_ptr> out = + collect("combined_cloud"); + rclcpp::Publisher::SharedPtr pub1 = + helper()->create_publisher("cloud1", 10); + rclcpp::Publisher::SharedPtr pub2 = + helper()->create_publisher("cloud2", 10); + ASSERT_TRUE(waitForSubscriber(pub1)); + ASSERT_TRUE(waitForSubscriber(pub2)); + + // A landmark 5 m ahead in odom, the robot driving at 1 m/s. Several pairs are sent + // because the ApproximateTime policy needs a following message before it can commit + // to a match when the stamps differ; the first emitted pair is the one asserted on. + publishPairs(pub1, pub2); + + ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })) << "no combined cloud"; + const sensor_msgs::msg::PointCloud2 & cloud = out->front(); + ASSERT_EQ(cloud.width, 2u); + + // Both observations of the same landmark must land on the same point. + EXPECT_NEAR(readXYZ(cloud, 0).x, 5.0f, 5e-3); + EXPECT_NEAR(readXYZ(cloud, 1).x, 5.0f, 5e-3) + << "the later cloud must be compensated for the 0.1 m of motion"; +} + +TEST_F(PointCloudAggregatorTest, WithoutAFixedFrameCloudsAreNotMotionCompensated) +{ + // Same inputs, no fixed_frame_id: the second cloud is taken at face value and the + // two observations stay 0.1 m apart. This is what fixed_frame_id exists to fix. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("count", 2), + rclcpp::Parameter("frame_id", "base_link"), + rclcpp::Parameter("approx_sync", true), + rclcpp::Parameter("wait_for_transform", 0.2)}))); + publishStaticTf("base_link", "lidar_a"); + publishStaticTf("base_link", "lidar_b"); + publishOdomMotion(1000.0, 0.6); + + std::shared_ptr> out = + collect("combined_cloud"); + rclcpp::Publisher::SharedPtr pub1 = + helper()->create_publisher("cloud1", 10); + rclcpp::Publisher::SharedPtr pub2 = + helper()->create_publisher("cloud2", 10); + ASSERT_TRUE(waitForSubscriber(pub1)); + ASSERT_TRUE(waitForSubscriber(pub2)); + + publishPairs(pub1, pub2); + + ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })); + const sensor_msgs::msg::PointCloud2 & cloud = out->front(); + ASSERT_EQ(cloud.width, 2u); + + EXPECT_NEAR(readXYZ(cloud, 0).x, 5.0f, 5e-3); + EXPECT_NEAR(readXYZ(cloud, 1).x, 4.9f, 5e-3) + << "uncompensated, the second observation stays where it was measured"; +} + +TEST_F(PointCloudAggregatorTest, WaitsForEveryInput) +{ + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("count", 2), + rclcpp::Parameter("frame_id", "base_link"), + rclcpp::Parameter("approx_sync", true)}))); + publishStaticTf("base_link", "lidar_a"); + publishStaticTf("base_link", "lidar_b"); + + std::shared_ptr> out = + collect("combined_cloud"); + rclcpp::Publisher::SharedPtr pub1 = + helper()->create_publisher("cloud1", 10); + ASSERT_TRUE(waitForSubscriber(pub1)); + + // Only one of the two inputs arrives: the synchronizer must not fire. + pub1->publish(makeXYZCloud("lidar_a", 1000.0, {{1.0f, 0.0f, 0.0f}})); + spinFor(std::chrono::milliseconds(500)); + + EXPECT_TRUE(out->empty()) << "a single input must not produce a combined cloud"; +} diff --git a/rtabmap_util/test/test_point_cloud_assembler.cpp b/rtabmap_util/test/test_point_cloud_assembler.cpp new file mode 100644 index 00000000..9e7cc7f7 --- /dev/null +++ b/rtabmap_util/test/test_point_cloud_assembler.cpp @@ -0,0 +1,399 @@ +/* +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(); +} + +class PointCloudAssemblerTest : public NodeTest +{ +protected: + /// Starts the assembler with @p overrides, plus a static odom -> lidar transform. + void start(const std::vector & overrides) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + publishStaticTf("odom", "lidar"); + publishStaticTf("lidar", "base_link"); + out_ = collect("assembled_cloud"); + pub_ = helper()->create_publisher("cloud", 10); + ASSERT_TRUE(waitForSubscriber(pub_)); + } + + static bool hasField(const sensor_msgs::msg::PointCloud2 & cloud, const std::string & name) + { + for(size_t i=0; i> out_; + rclcpp::Publisher::SharedPtr pub_; +}; + +TEST_F(PointCloudAssemblerTest, PublishesAfterMaxCloudsAreAccumulated) +{ + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("max_clouds", 3), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}))); + publishStaticTf("odom", "lidar"); + + std::shared_ptr> out = + collect("assembled_cloud"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("cloud", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + const std::vector points = {{1.0f, 0.0f, 0.0f}, {2.0f, 0.0f, 0.0f}}; + + // The first two clouds are only accumulated. + pub->publish(makeXYZCloud("lidar", 1000.0, points)); + pub->publish(makeXYZCloud("lidar", 1000.1, points)); + spinFor(std::chrono::milliseconds(300)); + EXPECT_TRUE(out->empty()) << "nothing is published before max_clouds is reached"; + + // The third completes the batch. + pub->publish(makeXYZCloud("lidar", 1000.2, points)); + ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })) + << "the assembled cloud must be published on the third input"; + + EXPECT_EQ(out->back().width, 3 * points.size()) << "all three clouds must be included"; + EXPECT_EQ(out->back().header.frame_id, "lidar") + << "the assembled cloud comes back in the sensor frame"; +} + +TEST_F(PointCloudAssemblerTest, AssemblingTimePublishesAfterTheConfiguredSpan) +{ + // An alternative trigger to max_clouds: publish once the newest cloud is at least + // assembling_time newer than the oldest one held. + start({rclcpp::Parameter("max_clouds", 0), + rclcpp::Parameter("assembling_time", 0.25), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + for(int i=0; i<3; ++i) // 1000.0, 1000.1, 1000.2 -- span 0.2 s, below 0.25 + { + pub_->publish(makeXYZCloud("lidar", 1000.0 + 0.1*i, points)); + } + spinFor(std::chrono::milliseconds(300)); + EXPECT_TRUE(out_->empty()) << "0.2 s of clouds is short of assembling_time"; + + pub_->publish(makeXYZCloud("lidar", 1000.3, points)); // span now 0.3 s + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) + << "crossing assembling_time must publish"; + EXPECT_EQ(out_->back().width, 4u) << "all four clouds are included"; +} + +TEST_F(PointCloudAssemblerTest, CircularBufferPublishesOnEveryCloud) +{ + // With a circular buffer the node emits a sliding window instead of filling up, + // clearing and starting again: every input produces an output. + start({rclcpp::Parameter("max_clouds", 3), + rclcpp::Parameter("circular_buffer", true), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + for(int i=0; i<4; ++i) + { + pub_->publish(makeXYZCloud("lidar", 1000.0 + 0.1*i, points)); + ASSERT_TRUE(spinUntil([&]() { return out_->size() >= size_t(i+1); })) + << "cloud " << i << " did not produce an output"; + } + EXPECT_EQ(out_->size(), 4u) << "one output per input, not one per full batch"; + // The window is capped at max_clouds. + EXPECT_LE(out_->back().width, 3u); +} + +TEST_F(PointCloudAssemblerTest, RangeMaxDropsDistantPoints) +{ + start({rclcpp::Parameter("max_clouds", 1), + rclcpp::Parameter("range_max", 3.0), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + // Two points inside 3 m, one well beyond it. + pub_->publish(makeXYZCloud("lidar", 1000.0, + {{1.0f, 0.0f, 0.0f}, {2.0f, 0.0f, 0.0f}, {9.0f, 0.0f, 0.0f}})); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width, 2u) << "the 9 m point must be filtered out"; +} + +TEST_F(PointCloudAssemblerTest, RemoveZDropsTheZField) +{ + start({rclcpp::Parameter("max_clouds", 1), + rclcpp::Parameter("remove_z", true), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + pub_->publish(makeXYZCloud("lidar", 1000.0, {{1.0f, 0.0f, 0.5f}})); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + // The field is removed entirely, not zeroed: the output is a 2D cloud. + EXPECT_TRUE(hasField(out_->back(), "x")); + EXPECT_TRUE(hasField(out_->back(), "y")); + EXPECT_FALSE(hasField(out_->back(), "z")) << "remove_z drops the field itself"; +} + +TEST_F(PointCloudAssemblerTest, FrameIdSetsTheOutputFrame) +{ + start({rclcpp::Parameter("max_clouds", 1), + rclcpp::Parameter("frame_id", "base_link"), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + pub_->publish(makeXYZCloud("lidar", 1000.0, {{1.0f, 0.0f, 0.0f}})); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().header.frame_id, "base_link") + << "the assembled cloud is returned in frame_id when it is set"; +} + +TEST_F(PointCloudAssemblerTest, SkipCloudsIgnoresIntermediateClouds) +{ + // skip_clouds=1 keeps every other cloud, so reaching max_clouds=2 takes four inputs. + start({rclcpp::Parameter("max_clouds", 2), + rclcpp::Parameter("skip_clouds", 1), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + pub_->publish(makeXYZCloud("lidar", 1000.0, points)); + pub_->publish(makeXYZCloud("lidar", 1000.1, points)); + spinFor(std::chrono::milliseconds(300)); + EXPECT_TRUE(out_->empty()) << "one of those two was skipped, so the batch is short"; + + pub_->publish(makeXYZCloud("lidar", 1000.2, points)); + pub_->publish(makeXYZCloud("lidar", 1000.3, points)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + EXPECT_EQ(out_->back().width, 2u) << "two kept clouds, two skipped"; +} + +TEST_F(PointCloudAssemblerTest, LinearUpdateSkipsCloudsWhileStationary) +{ + // With linear_update set, a cloud captured without the robot having moved far enough + // is discarded rather than accumulated, so a parked robot never fills a batch. + start({rclcpp::Parameter("max_clouds", 3), + rclcpp::Parameter("linear_update", 0.5), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + for(int i=0; i<5; ++i) // the TF is static, so the robot never moves + { + pub_->publish(makeXYZCloud("lidar", 1000.0 + 0.1*i, points)); + } + spinFor(std::chrono::milliseconds(500)); + + EXPECT_TRUE(out_->empty()) + << "a stationary robot must not accumulate a batch when linear_update is set"; +} + +TEST_F(PointCloudAssemblerTest, WithoutLinearUpdateEveryCloudCounts) +{ + // The same stationary robot, with the motion filter disabled: the batch fills. + start({rclcpp::Parameter("max_clouds", 3), + rclcpp::Parameter("linear_update", 0.0), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + for(int i=0; i<3; ++i) + { + pub_->publish(makeXYZCloud("lidar", 1000.0 + 0.1*i, points)); + } + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + EXPECT_EQ(out_->back().width, 3u); +} + +TEST_F(PointCloudAssemblerTest, VoxelSizeDownsamplesTheCloud) +{ + start({rclcpp::Parameter("max_clouds", 1), + rclcpp::Parameter("voxel_size", 0.5), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + // 100 points packed into a 0.2 m cube: a 0.5 m voxel grid collapses them. + std::vector dense; + for(int i=0; i<10; ++i) + { + for(int j=0; j<10; ++j) + { + dense.push_back(cv::Point3f(1.0f + 0.02f*i, 0.02f*j, 0.0f)); + } + } + pub_->publish(makeXYZCloud("lidar", 1000.0, dense)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_LT(out_->back().width, dense.size()) + << "voxel_size must reduce the point count"; + EXPECT_GT(out_->back().width, 0u); +} + +/// Fixture for the odometry-synchronized modes, which need fixed_frame_id to be empty. +class PointCloudAssemblerOdomTest : public NodeTest +{ +protected: + void start(std::vector overrides) + { + // fixed_frame_id defaults to "odom"; it has to be cleared for the node to + // subscribe to the odometry topic instead of reading TF directly. + overrides.push_back(rclcpp::Parameter("fixed_frame_id", "")); + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + publishStaticTf("odom", "lidar"); + out_ = collect("assembled_cloud"); + cloudPub_ = helper()->create_publisher("cloud", 10); + odomPub_ = helper()->create_publisher("odom", 10); + odomInfoPub_ = helper()->create_publisher("odom_info", 10); + ASSERT_TRUE(waitForSubscriber(cloudPub_)); + ASSERT_TRUE(waitForSubscriber(odomPub_)); + } + + /// An odometry message at the origin; a null one has an all-zero orientation. + nav_msgs::msg::Odometry makeOdom(double stamp, bool null = false) + { + nav_msgs::msg::Odometry odom; + odom.header.stamp = stampOf(stamp); + odom.header.frame_id = "odom"; + odom.child_frame_id = "lidar"; + odom.pose.pose.orientation.w = null ? 0.0 : 1.0; + return odom; + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr cloudPub_; + rclcpp::Publisher::SharedPtr odomPub_; + rclcpp::Publisher::SharedPtr odomInfoPub_; +}; + +TEST_F(PointCloudAssemblerOdomTest, TakesTheFixedFrameFromTheOdometryMessage) +{ + // With fixed_frame_id empty the node syncs cloud with odom and uses the odometry + // header's frame as the fixed frame. + start({rclcpp::Parameter("max_clouds", 2), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + for(int i=0; i<2; ++i) + { + const double t = 1000.0 + 0.1*i; + cloudPub_->publish(makeXYZCloud("lidar", t, points)); + odomPub_->publish(makeOdom(t)); // exact sync: identical stamps + spinFor(std::chrono::milliseconds(50)); + } + + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) + << "cloud+odom synchronization must drive the assembly"; + EXPECT_EQ(out_->back().width, 2u); +} + +TEST_F(PointCloudAssemblerOdomTest, NullOdometryResetsTheBuffer) +{ + // A null odometry means tracking was lost, so the accumulated clouds are dropped + // rather than being stitched across the discontinuity. + start({rclcpp::Parameter("max_clouds", 3), + rclcpp::Parameter("wait_for_transform", 0.2)}); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + + // Two good clouds, then a lost-tracking frame, then two more. + for(int i=0; i<2; ++i) + { + const double t = 1000.0 + 0.1*i; + cloudPub_->publish(makeXYZCloud("lidar", t, points)); + odomPub_->publish(makeOdom(t)); + spinFor(std::chrono::milliseconds(50)); + } + cloudPub_->publish(makeXYZCloud("lidar", 1000.2, points)); + odomPub_->publish(makeOdom(1000.2, /*null=*/true)); + spinFor(std::chrono::milliseconds(150)); + EXPECT_TRUE(out_->empty()) << "the null odometry must not complete the batch"; + + // After the reset it takes three fresh clouds again, not one. + for(int i=0; i<2; ++i) + { + const double t = 1000.3 + 0.1*i; + cloudPub_->publish(makeXYZCloud("lidar", t, points)); + odomPub_->publish(makeOdom(t)); + spinFor(std::chrono::milliseconds(50)); + } + EXPECT_TRUE(out_->empty()) << "the buffer restarted, so two clouds are not enough"; + + cloudPub_->publish(makeXYZCloud("lidar", 1000.5, points)); + odomPub_->publish(makeOdom(1000.5)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + EXPECT_EQ(out_->back().width, 3u) << "only the post-reset clouds are assembled"; +} + +TEST_F(PointCloudAssemblerOdomTest, SubscribeOdomInfoKeepsOnlyKeyFrames) +{ + // With subscribe_odom_info the node also takes OdomInfo and accumulates a cloud only + // when that frame became a key frame. + start({rclcpp::Parameter("max_clouds", 2), + rclcpp::Parameter("subscribe_odom_info", true), + rclcpp::Parameter("wait_for_transform", 0.2)}); + ASSERT_TRUE(waitForSubscriber(odomInfoPub_)); + + const std::vector points = {{1.0f, 0.0f, 0.0f}}; + auto publishFrame = [&](double t, bool keyFrame) { + rtabmap_msgs::msg::OdomInfo info; + info.header.stamp = stampOf(t); + info.header.frame_id = "odom"; + info.key_frame_added = keyFrame; + cloudPub_->publish(makeXYZCloud("lidar", t, points)); + odomPub_->publish(makeOdom(t)); + odomInfoPub_->publish(info); + spinFor(std::chrono::milliseconds(60)); + }; + + publishFrame(1000.0, false); + publishFrame(1000.1, false); + publishFrame(1000.2, false); + EXPECT_TRUE(out_->empty()) << "non key frames must be ignored"; + + publishFrame(1000.3, true); + publishFrame(1000.4, true); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + EXPECT_EQ(out_->back().width, 2u) << "only the two key frames are assembled"; +} + +TEST_F(PointCloudAssemblerTest, DropsCloudsWithoutTheFixedFrame) +{ + // No TF at all, so the assembler cannot place the clouds relative to each other. + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("max_clouds", 2), + rclcpp::Parameter("fixed_frame_id", "odom"), + rclcpp::Parameter("wait_for_transform", 0.0)}))); + + std::shared_ptr> out = + collect("assembled_cloud"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("cloud", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + pub->publish(makeXYZCloud("lidar", 1000.0, {{1.0f, 0.0f, 0.0f}})); + pub->publish(makeXYZCloud("lidar", 1000.1, {{1.0f, 0.0f, 0.0f}})); + spinFor(std::chrono::milliseconds(500)); + + EXPECT_TRUE(out->empty()); +} diff --git a/rtabmap_util/test/test_point_cloud_xyz.cpp b/rtabmap_util/test/test_point_cloud_xyz.cpp new file mode 100644 index 00000000..700ea6cf --- /dev/null +++ b/rtabmap_util/test/test_point_cloud_xyz.cpp @@ -0,0 +1,344 @@ +/* +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(); + +constexpr int kWidth = 16; +constexpr int kHeight = 16; +constexpr double kFx = 100.0; + +/// A depth image where every pixel is at @p meters. +sensor_msgs::msg::Image makeDepth( + double stamp, float meters, + const std::string & encoding = sensor_msgs::image_encodings::TYPE_32FC1) +{ + cv::Mat image; + if(encoding == sensor_msgs::image_encodings::TYPE_32FC1) + { + image = cv::Mat(kHeight, kWidth, CV_32FC1, cv::Scalar(meters)); + } + else + { + image = cv::Mat(kHeight, kWidth, CV_16UC1, cv::Scalar(uint16_t(meters*1000.0f))); + } + return makeImage("camera_link", stamp, image, encoding); +} + +/// A disparity image where every pixel carries @p disparity, so depth = f*t/disparity. +stereo_msgs::msg::DisparityImage makeDisparity( + double stamp, float disparity, float focal = float(kFx), float baseline = 0.1f) +{ + stereo_msgs::msg::DisparityImage msg; + msg.header.frame_id = "camera_link"; + msg.header.stamp = stampOf(stamp); + msg.f = focal; + msg.t = baseline; + msg.min_disparity = 1.0f; + msg.max_disparity = 100.0f; + msg.image = makeImage("camera_link", stamp, + cv::Mat(kHeight, kWidth, CV_32FC1, cv::Scalar(disparity)), + sensor_msgs::image_encodings::TYPE_32FC1); + return msg; +} + +/// The same, in the 16SC1 fixed-point form where the stored value is 16*disparity. +stereo_msgs::msg::DisparityImage makeDisparity16SC1(double stamp, float disparity) +{ + stereo_msgs::msg::DisparityImage msg = makeDisparity(stamp, disparity); + msg.image = makeImage("camera_link", stamp, + cv::Mat(kHeight, kWidth, CV_16SC1, cv::Scalar(short(disparity*16.0f))), + sensor_msgs::image_encodings::TYPE_16SC1); + return msg; +} + +bool hasField(const sensor_msgs::msg::PointCloud2 & cloud, const std::string & name) +{ + for(size_t i=0; i & overrides = {}) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + out_ = collect("cloud"); + depthPub_ = helper()->create_publisher("depth/image", 10); + infoPub_ = helper()->create_publisher("depth/camera_info", 10); + ASSERT_TRUE(waitForSubscriber(depthPub_)); + ASSERT_TRUE(waitForSubscriber(infoPub_)); + ASSERT_TRUE(waitForPublisher(out_->subscription)); + } + + /// Publishes a synchronized depth + camera_info pair. + void publishFrame(double stamp, float meters, + const std::string & encoding = sensor_msgs::image_encodings::TYPE_32FC1) + { + depthPub_->publish(makeDepth(stamp, meters, encoding)); + infoPub_->publish(makeCameraInfo("camera_link", stamp, kWidth, kHeight, 0.0, kFx)); + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr depthPub_; + rclcpp::Publisher::SharedPtr infoPub_; +}; + +TEST_F(PointCloudXYZTest, ProjectsDepthIntoACloud) +{ + start(); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kWidth*kHeight)) + << "one point per pixel at decimation 1"; + EXPECT_EQ(cloud.header.frame_id, "camera_link") + << "the cloud takes the depth image's frame"; + + // The principal-point pixel projects straight ahead at the measured depth. + const size_t center = size_t(kHeight/2) * kWidth + kWidth/2; + EXPECT_NEAR(readXYZ(cloud, center).z, 2.0f, 1e-3); +} + +TEST_F(PointCloudXYZTest, Accepts16UC1Millimeters) +{ + start(); + publishFrame(1000.0, 2.0f, sensor_msgs::image_encodings::TYPE_16UC1); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const size_t center = size_t(kHeight/2) * kWidth + kWidth/2; + EXPECT_NEAR(readXYZ(out_->back(), center).z, 2.0f, 1e-3) + << "millimeter depth must be converted to meters"; +} + +TEST_F(PointCloudXYZTest, RejectsUnsupportedEncoding) +{ + start(); + depthPub_->publish(makeImage("camera_link", 1000.0, + cv::Mat(kHeight, kWidth, CV_8UC3, cv::Scalar(1,2,3)), "bgr8")); + infoPub_->publish(makeCameraInfo("camera_link", 1000.0, kWidth, kHeight, 0.0, kFx)); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(out_->empty()) << "only 32FC1, 16UC1 and mono16 depth are supported"; +} + +TEST_F(PointCloudXYZTest, DecimationReducesThePointCount) +{ + start({rclcpp::Parameter("decimation", 2)}); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, uint32_t(kWidth*kHeight)/4) + << "decimation 2 keeps one pixel in four"; +} + +TEST_F(PointCloudXYZTest, MaxDepthMarksFarPointsInvalid) +{ + // cloudFromDepth keeps the cloud organized: points outside the depth range become + // NaN rather than disappearing, so the point count is unchanged. + start({rclcpp::Parameter("max_depth", 1.0)}); + publishFrame(1000.0, 5.0f); // beyond the limit + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kWidth*kHeight)) + << "the cloud stays organized"; + EXPECT_TRUE(std::isnan(readXYZ(cloud, 0).z)) << "every point is past max_depth"; + EXPECT_TRUE(std::isnan(readXYZ(cloud, kWidth*kHeight-1).z)); +} + +TEST_F(PointCloudXYZTest, WithinMaxDepthPointsStayValid) +{ + start({rclcpp::Parameter("max_depth", 10.0)}); + publishFrame(1000.0, 5.0f); // inside the limit + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const size_t center = size_t(kHeight/2) * kWidth + kWidth/2; + EXPECT_FALSE(std::isnan(readXYZ(out_->back(), center).z)); + EXPECT_NEAR(readXYZ(out_->back(), center).z, 5.0f, 1e-3); +} + +TEST_F(PointCloudXYZTest, MinDepthMarksNearPointsInvalid) +{ + start({rclcpp::Parameter("min_depth", 3.0)}); + publishFrame(1000.0, 1.0f); // closer than the limit + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_TRUE(std::isnan(readXYZ(out_->back(), 0).z)) + << "every point is nearer than min_depth"; +} + +TEST_F(PointCloudXYZTest, FilterNaNsRemovesInvalidPoints) +{ + // With filter_nans the invalid points are dropped instead, giving an unorganized + // cloud that is empty when nothing is in range. + start({rclcpp::Parameter("max_depth", 1.0), + rclcpp::Parameter("filter_nans", true)}); + publishFrame(1000.0, 5.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, 0u) + << "filter_nans must remove the out-of-range points"; +} + +TEST_F(PointCloudXYZTest, NormalKAddsNormalFields) +{ + start({rclcpp::Parameter("normal_k", 10)}); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_TRUE(hasField(out_->back(), "normal_x")) + << "asking for normals must change the point type"; + EXPECT_TRUE(hasField(out_->back(), "normal_z")); +} + +TEST_F(PointCloudXYZTest, NoNormalFieldsByDefault) +{ + start(); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_FALSE(hasField(out_->back(), "normal_x")); +} + +TEST_F(PointCloudXYZTest, StaysSilentWithoutASubscriber) +{ + // The projection is skipped entirely when nobody wants the cloud. + addNode(std::make_shared(rclcpp::NodeOptions())); + rclcpp::Publisher::SharedPtr depthPub = + helper()->create_publisher("depth/image", 10); + rclcpp::Publisher::SharedPtr infoPub = + helper()->create_publisher("depth/camera_info", 10); + ASSERT_TRUE(waitForSubscriber(depthPub)); + + depthPub->publish(makeDepth(1000.0, 2.0f)); + infoPub->publish(makeCameraInfo("camera_link", 1000.0, kWidth, kHeight, 0.0, kFx)); + spinFor(std::chrono::milliseconds(300)); + + std::shared_ptr> late = + collect("cloud"); + spinFor(std::chrono::milliseconds(200)); + EXPECT_TRUE(late->empty()); +} + +//============================================================================ +// disparity/image + disparity/camera_info +//============================================================================ + +class PointCloudXYZDisparityTest : public NodeTest +{ +protected: + void start(const std::vector & overrides = {}) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + out_ = collect("cloud"); + dispPub_ = helper()->create_publisher( + "disparity/image", 10); + infoPub_ = helper()->create_publisher( + "disparity/camera_info", 10); + ASSERT_TRUE(waitForSubscriber(dispPub_)); + ASSERT_TRUE(waitForSubscriber(infoPub_)); + ASSERT_TRUE(waitForPublisher(out_->subscription)); + } + + void publishFrame(double stamp, const stereo_msgs::msg::DisparityImage & disparity) + { + dispPub_->publish(disparity); + infoPub_->publish(makeCameraInfo("camera_link", stamp, kWidth, kHeight, 0.0, kFx)); + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr dispPub_; + rclcpp::Publisher::SharedPtr infoPub_; +}; + +TEST_F(PointCloudXYZDisparityTest, ProjectsDisparityIntoACloud) +{ + start(); + publishFrame(1000.0, makeDisparity(1000.0, 5.0f)); // depth = f*t/d = 100*0.1/5 = 2 m + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kWidth*kHeight)); + EXPECT_EQ(cloud.header.frame_id, "camera_link") + << "the cloud takes the disparity image's frame"; + + const size_t center = size_t(kHeight/2) * kWidth + kWidth/2; + EXPECT_NEAR(readXYZ(cloud, center).z, 2.0f, 1e-3); +} + +TEST_F(PointCloudXYZDisparityTest, Accepts16SC1FixedPointDisparity) +{ + // The 16-bit form stores 16*disparity, so the same 5 px must still give 2 m. + start(); + publishFrame(1000.0, makeDisparity16SC1(1000.0, 5.0f)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const size_t center = size_t(kHeight/2) * kWidth + kWidth/2; + EXPECT_NEAR(readXYZ(out_->back(), center).z, 2.0f, 1e-3); +} + +TEST_F(PointCloudXYZDisparityTest, RejectsUnsupportedDisparityEncoding) +{ + start(); + stereo_msgs::msg::DisparityImage msg = makeDisparity(1000.0, 5.0f); + msg.image = makeImage("camera_link", 1000.0, + cv::Mat(kHeight, kWidth, CV_8UC1, cv::Scalar(5)), "mono8"); + publishFrame(1000.0, msg); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(out_->empty()) << "only 32FC1 and 16SC1 disparity are supported"; +} + +TEST_F(PointCloudXYZDisparityTest, MaxDepthMarksFarPointsInvalid) +{ + // Like the depth path, cloudFromDisparity keeps the cloud organized and turns the + // out-of-range points into NaN instead of removing them. + start({rclcpp::Parameter("max_depth", 1.0)}); + publishFrame(1000.0, makeDisparity(1000.0, 5.0f)); // 2 m, beyond the limit + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kWidth*kHeight)); + EXPECT_TRUE(std::isnan(readXYZ(cloud, size_t(kHeight/2)*kWidth + kWidth/2).z)); +} + +TEST_F(PointCloudXYZDisparityTest, FilterNaNsRemovesInvalidPoints) +{ + start({rclcpp::Parameter("max_depth", 1.0), + rclcpp::Parameter("filter_nans", true)}); + publishFrame(1000.0, makeDisparity(1000.0, 5.0f)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, 0u); +} + +TEST_F(PointCloudXYZDisparityTest, DecimationReducesThePointCount) +{ + start({rclcpp::Parameter("decimation", 2)}); + publishFrame(1000.0, makeDisparity(1000.0, 5.0f)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, uint32_t(kWidth*kHeight)/4); +} diff --git a/rtabmap_util/test/test_point_cloud_xyzrgb.cpp b/rtabmap_util/test/test_point_cloud_xyzrgb.cpp new file mode 100644 index 00000000..a17221a0 --- /dev/null +++ b/rtabmap_util/test/test_point_cloud_xyzrgb.cpp @@ -0,0 +1,531 @@ +/* +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(); + +constexpr int kWidth = 16; +constexpr int kHeight = 16; +constexpr double kFx = 100.0; +constexpr int kCenter = (kHeight/2) * kWidth + kWidth/2; + +/// The color every synthetic RGB image is painted with, in OpenCV's BGR order. +const cv::Scalar kColor(10, 20, 30); + +sensor_msgs::msg::Image makeRgb(double stamp, const std::string & encoding = "bgr8") +{ + if(encoding == "mono8") + { + return makeImage("camera_link", stamp, + cv::Mat(kHeight, kWidth, CV_8UC1, cv::Scalar(128)), encoding); + } + return makeImage("camera_link", stamp, + cv::Mat(kHeight, kWidth, CV_8UC3, kColor), encoding); +} + +sensor_msgs::msg::Image makeDepth(double stamp, float meters, + const std::string & encoding = sensor_msgs::image_encodings::TYPE_32FC1) +{ + cv::Mat image = encoding == sensor_msgs::image_encodings::TYPE_32FC1 + ? cv::Mat(kHeight, kWidth, CV_32FC1, cv::Scalar(meters)) + : cv::Mat(kHeight, kWidth, CV_16UC1, cv::Scalar(uint16_t(meters*1000.0f))); + return makeImage("camera_link", stamp, image, encoding); +} + +/// A disparity image where every pixel carries @p disparity, so depth = f*t/disparity. +stereo_msgs::msg::DisparityImage makeDisparity(double stamp, float disparity, + float focal = float(kFx), float baseline = 0.1f) +{ + stereo_msgs::msg::DisparityImage msg; + msg.header.frame_id = "camera_link"; + msg.header.stamp = stampOf(stamp); + msg.f = focal; + msg.t = baseline; + msg.min_disparity = 1.0f; + msg.max_disparity = 100.0f; + msg.image = makeImage("camera_link", stamp, + cv::Mat(kHeight, kWidth, CV_32FC1, cv::Scalar(disparity)), + sensor_msgs::image_encodings::TYPE_32FC1); + return msg; +} + +bool hasField(const sensor_msgs::msg::PointCloud2 & cloud, const std::string & name) +{ + for(size_t i=0; i> 16) & 0xFF), + uint8_t((packed >> 8) & 0xFF), + uint8_t(packed & 0xFF)); +} +} // namespace + +class PointCloudXYZRGBTest : public NodeTest +{ +protected: + void start(const std::vector & overrides = {}) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + out_ = collect("cloud"); + rgbPub_ = helper()->create_publisher("rgb/image", 10); + depthPub_ = helper()->create_publisher("depth/image", 10); + infoPub_ = helper()->create_publisher("rgb/camera_info", 10); + ASSERT_TRUE(waitForSubscriber(rgbPub_)); + ASSERT_TRUE(waitForSubscriber(depthPub_)); + ASSERT_TRUE(waitForSubscriber(infoPub_)); + ASSERT_TRUE(waitForPublisher(out_->subscription)); + } + + /// Publishes a synchronized rgb + depth + camera_info triple. + void publishFrame(double stamp, float meters, + const std::string & depthEncoding = sensor_msgs::image_encodings::TYPE_32FC1, + const std::string & rgbEncoding = "bgr8") + { + rgbPub_->publish(makeRgb(stamp, rgbEncoding)); + depthPub_->publish(makeDepth(stamp, meters, depthEncoding)); + infoPub_->publish(makeCameraInfo("camera_link", stamp, kWidth, kHeight, 0.0, kFx)); + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr rgbPub_; + rclcpp::Publisher::SharedPtr depthPub_; + rclcpp::Publisher::SharedPtr infoPub_; +}; + +//============================================================================ +// rgb + depth + camera_info +//============================================================================ + +TEST_F(PointCloudXYZRGBTest, ProjectsRgbAndDepthIntoAColoredCloud) +{ + start(); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kWidth*kHeight)) + << "one point per pixel at decimation 1"; + EXPECT_EQ(cloud.header.frame_id, "camera_link") + << "the cloud takes the RGB image's frame"; + EXPECT_TRUE(hasField(cloud, "rgb")) << "the whole point of this node"; + EXPECT_NEAR(readXYZ(cloud, kCenter).z, 2.0f, 1e-3); + + // The RGB image is uniform, so every point carries the same color. cv_bridge hands + // the node a bgr8 image, which reaches the cloud as r=30, g=20, b=10. + const cv::Vec3b rgb = readRGB(cloud, kCenter); + EXPECT_EQ(int(rgb[0]), 30); + EXPECT_EQ(int(rgb[1]), 20); + EXPECT_EQ(int(rgb[2]), 10); +} + +TEST_F(PointCloudXYZRGBTest, Accepts16UC1Millimeters) +{ + start(); + publishFrame(1000.0, 2.0f, sensor_msgs::image_encodings::TYPE_16UC1); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_NEAR(readXYZ(out_->back(), kCenter).z, 2.0f, 1e-3) + << "millimeter depth must be converted to meters"; +} + +TEST_F(PointCloudXYZRGBTest, AcceptsMono8Color) +{ + start(); + publishFrame(1000.0, 2.0f, sensor_msgs::image_encodings::TYPE_32FC1, "mono8"); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const cv::Vec3b rgb = readRGB(out_->back(), kCenter); + EXPECT_EQ(int(rgb[0]), 128) << "a grey image gives grey points"; + EXPECT_EQ(int(rgb[1]), 128); + EXPECT_EQ(int(rgb[2]), 128); +} + +TEST_F(PointCloudXYZRGBTest, RejectsUnsupportedDepthEncoding) +{ + start(); + rgbPub_->publish(makeRgb(1000.0)); + depthPub_->publish(makeImage("camera_link", 1000.0, + cv::Mat(kHeight, kWidth, CV_8UC3, kColor), "bgr8")); + infoPub_->publish(makeCameraInfo("camera_link", 1000.0, kWidth, kHeight, 0.0, kFx)); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(out_->empty()) << "only 32FC1, 16UC1 and mono16 depth are supported"; +} + +TEST_F(PointCloudXYZRGBTest, DecimationReducesThePointCount) +{ + start({rclcpp::Parameter("decimation", 2)}); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, uint32_t(kWidth*kHeight)/4) + << "decimation 2 keeps one pixel in four"; +} + +TEST_F(PointCloudXYZRGBTest, RoiRatiosCropTheCloud) +{ + // A quarter off each side of a 16x16 image leaves an 8x8 window. + start({rclcpp::Parameter("roi_ratios", std::string("0.25 0.25 0.25 0.25"))}); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, 64u); +} + +TEST_F(PointCloudXYZRGBTest, MaxDepthMarksFarPointsInvalid) +{ + // cloudFromDepthRGB keeps the cloud organized: out-of-range points become NaN + // rather than disappearing, so the point count is unchanged. + start({rclcpp::Parameter("max_depth", 1.0)}); + publishFrame(1000.0, 5.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, uint32_t(kWidth*kHeight)); + EXPECT_TRUE(std::isnan(readXYZ(out_->back(), kCenter).z)); +} + +TEST_F(PointCloudXYZRGBTest, MinDepthMarksNearPointsInvalid) +{ + start({rclcpp::Parameter("min_depth", 3.0)}); + publishFrame(1000.0, 1.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_TRUE(std::isnan(readXYZ(out_->back(), kCenter).z)); +} + +TEST_F(PointCloudXYZRGBTest, FilterNaNsRemovesInvalidPoints) +{ + start({rclcpp::Parameter("max_depth", 1.0), + rclcpp::Parameter("filter_nans", true)}); + publishFrame(1000.0, 5.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_EQ(out_->back().width * out_->back().height, 0u) + << "filter_nans must remove the out-of-range points"; +} + +TEST_F(PointCloudXYZRGBTest, NormalKAddsNormalFields) +{ + start({rclcpp::Parameter("normal_k", 10)}); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_TRUE(hasField(out_->back(), "normal_x")) + << "asking for normals must change the point type"; + EXPECT_TRUE(hasField(out_->back(), "rgb")) << "and must keep the color"; +} + +TEST_F(PointCloudXYZRGBTest, NoNormalFieldsByDefault) +{ + start(); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_FALSE(hasField(out_->back(), "normal_x")); +} + +TEST_F(PointCloudXYZRGBTest, VoxelSizeThinsTheCloud) +{ + // A frontal plane at 2 m spans about 0.32 m across a 16-pixel image at fx=100, so a + // 0.1 m voxel grid collapses the 256 points into far fewer. + start({rclcpp::Parameter("voxel_size", 0.1)}); + publishFrame(1000.0, 2.0f); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const uint32_t points = out_->back().width * out_->back().height; + EXPECT_GT(points, 0u); + EXPECT_LT(points, uint32_t(kWidth*kHeight)); +} + +TEST_F(PointCloudXYZRGBTest, StaysSilentWithoutASubscriber) +{ + // The projection is skipped entirely when nobody wants the cloud. + addNode(std::make_shared(rclcpp::NodeOptions())); + rclcpp::Publisher::SharedPtr rgbPub = + helper()->create_publisher("rgb/image", 10); + rclcpp::Publisher::SharedPtr depthPub = + helper()->create_publisher("depth/image", 10); + rclcpp::Publisher::SharedPtr infoPub = + helper()->create_publisher("rgb/camera_info", 10); + ASSERT_TRUE(waitForSubscriber(rgbPub)); + ASSERT_TRUE(waitForSubscriber(depthPub)); + + rgbPub->publish(makeRgb(1000.0)); + depthPub->publish(makeDepth(1000.0, 2.0f)); + infoPub->publish(makeCameraInfo("camera_link", 1000.0, kWidth, kHeight, 0.0, kFx)); + spinFor(std::chrono::milliseconds(300)); + + std::shared_ptr> late = + collect("cloud"); + spinFor(std::chrono::milliseconds(200)); + EXPECT_TRUE(late->empty()); +} + +//============================================================================ +// rgbd_image +//============================================================================ + +class PointCloudXYZRGBRgbdTest : public NodeTest +{ +protected: + void start(const std::vector & overrides = {}) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + out_ = collect("cloud"); + rgbdPub_ = helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(rgbdPub_)); + ASSERT_TRUE(waitForPublisher(out_->subscription)); + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr rgbdPub_; +}; + +TEST_F(PointCloudXYZRGBRgbdTest, ProjectsAnRgbdImage) +{ + start(); + rgbdPub_->publish(makeRGBDImage("camera_link", 1000.0, kWidth, kHeight)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kWidth*kHeight)); + EXPECT_EQ(cloud.header.frame_id, "camera_link"); + EXPECT_TRUE(hasField(cloud, "rgb")); + EXPECT_NEAR(readXYZ(cloud, kCenter).z, 1.5f, 1e-3) + << "makeRGBDImage() fills the depth image with 1500 mm"; +} + +TEST_F(PointCloudXYZRGBRgbdTest, IgnoresAnInvalidRgbdImage) +{ + // isValid() is false without any image data, and nothing must be published. + start(); + rtabmap_msgs::msg::RGBDImage msg; + msg.header.frame_id = "camera_link"; + msg.header.stamp = stampOf(1000.0); + rgbdPub_->publish(msg); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(out_->empty()); +} + +TEST_F(PointCloudXYZRGBRgbdTest, PublishesAnEmptyCloudForAColorOnlyRgbdImage) +{ + // Depth is optional in an RGBDImage, so color alone must not be treated as a broken + // message: there is simply nothing to project. + start(); + rtabmap_msgs::msg::RGBDImage msg = makeRGBDImage("camera_link", 1000.0, kWidth, kHeight); + msg.depth = sensor_msgs::msg::Image(); + rgbdPub_->publish(msg); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + EXPECT_EQ(out_->back().width * out_->back().height, 0u); +} + +TEST_F(PointCloudXYZRGBRgbdTest, ProjectsAStereoRgbdImage) +{ + // A stereo pair in an RGBDImage is dense-matched instead of read as depth. + start(); + rgbdPub_->publish(makeStereoRGBDImage("camera_link", 1000.0, 160, 120)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + EXPECT_EQ(out_->back().width * out_->back().height, uint32_t(160*120)); + EXPECT_TRUE(hasField(out_->back(), "rgb")); +} + +//============================================================================ +// left/image + disparity + left/camera_info +//============================================================================ + +class PointCloudXYZRGBDisparityTest : public NodeTest +{ +protected: + void start(const std::vector & overrides = {}) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + out_ = collect("cloud"); + leftPub_ = helper()->create_publisher("left/image", 10); + dispPub_ = helper()->create_publisher("disparity", 10); + infoPub_ = helper()->create_publisher("left/camera_info", 10); + ASSERT_TRUE(waitForSubscriber(leftPub_)); + ASSERT_TRUE(waitForSubscriber(dispPub_)); + ASSERT_TRUE(waitForSubscriber(infoPub_)); + ASSERT_TRUE(waitForPublisher(out_->subscription)); + } + + void publishFrame(double stamp, float disparity) + { + leftPub_->publish(makeRgb(stamp)); + dispPub_->publish(makeDisparity(stamp, disparity)); + infoPub_->publish(makeCameraInfo("camera_link", stamp, kWidth, kHeight, 0.0, kFx)); + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr leftPub_; + rclcpp::Publisher::SharedPtr dispPub_; + rclcpp::Publisher::SharedPtr infoPub_; +}; + +TEST_F(PointCloudXYZRGBDisparityTest, ProjectsDisparityIntoAColoredCloud) +{ + start(); + publishFrame(1000.0, 5.0f); // depth = f*t/d = 100*0.1/5 = 2 m + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kWidth*kHeight)); + EXPECT_EQ(cloud.header.frame_id, "camera_link") + << "the cloud takes the disparity image's frame"; + EXPECT_TRUE(hasField(cloud, "rgb")); + EXPECT_NEAR(readXYZ(cloud, kCenter).z, 2.0f, 1e-3); + + const cv::Vec3b rgb = readRGB(cloud, kCenter); + EXPECT_EQ(int(rgb[0]), 30); + EXPECT_EQ(int(rgb[2]), 10); +} + +TEST_F(PointCloudXYZRGBDisparityTest, RejectsUnsupportedDisparityEncoding) +{ + start(); + stereo_msgs::msg::DisparityImage msg = makeDisparity(1000.0, 5.0f); + msg.image = makeImage("camera_link", 1000.0, + cv::Mat(kHeight, kWidth, CV_8UC1, cv::Scalar(5)), "mono8"); + leftPub_->publish(makeRgb(1000.0)); + dispPub_->publish(msg); + infoPub_->publish(makeCameraInfo("camera_link", 1000.0, kWidth, kHeight, 0.0, kFx)); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(out_->empty()) << "only 32FC1 and 16SC1 disparity are supported"; +} + +TEST_F(PointCloudXYZRGBDisparityTest, MaxDepthMarksFarPointsInvalid) +{ + start({rclcpp::Parameter("max_depth", 1.0)}); + publishFrame(1000.0, 5.0f); // 2 m, beyond the limit + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + EXPECT_TRUE(std::isnan(readXYZ(out_->back(), kCenter).z)); +} + +//============================================================================ +// left/image + right/image + both camera_infos +//============================================================================ + +class PointCloudXYZRGBStereoTest : public NodeTest +{ +protected: + static constexpr int kStereoWidth = 160; + static constexpr int kStereoHeight = 120; + static constexpr float kBaseline = 0.12f; + static constexpr int kDisparity = 6; // depth = fx*baseline/d = 100*0.12/6 = 2 m + + void start(const std::vector & overrides = {}) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + out_ = collect("cloud"); + leftPub_ = helper()->create_publisher("left/image", 10); + rightPub_ = helper()->create_publisher("right/image", 10); + leftInfoPub_ = helper()->create_publisher("left/camera_info", 10); + rightInfoPub_ = helper()->create_publisher("right/camera_info", 10); + ASSERT_TRUE(waitForSubscriber(leftPub_)); + ASSERT_TRUE(waitForSubscriber(rightPub_)); + ASSERT_TRUE(waitForSubscriber(leftInfoPub_)); + ASSERT_TRUE(waitForSubscriber(rightInfoPub_)); + ASSERT_TRUE(waitForPublisher(out_->subscription)); + } + + /** + * Publishes a textured pair whose true disparity is kDisparity everywhere: the right + * image is the left one shifted, which is what a plane at a constant depth looks like. + */ + void publishFrame(double stamp) + { + cv::Mat left(kStereoHeight, kStereoWidth + kDisparity, CV_8UC1); + cv::RNG rng(42); + rng.fill(left, cv::RNG::UNIFORM, 0, 256); + + cv::Mat leftBgr; + cv::cvtColor(cv::Mat(left, cv::Rect(0, 0, kStereoWidth, kStereoHeight)), + leftBgr, cv::COLOR_GRAY2BGR); + cv::Mat right(left, cv::Rect(kDisparity, 0, kStereoWidth, kStereoHeight)); + + leftPub_->publish(makeImage("camera_link", stamp, leftBgr, "bgr8")); + rightPub_->publish(makeImage("camera_link", stamp, right.clone(), "mono8")); + leftInfoPub_->publish(makeCameraInfo( + "camera_link", stamp, kStereoWidth, kStereoHeight, 0.0, kFx)); + rightInfoPub_->publish(makeCameraInfo( + "camera_link", stamp, kStereoWidth, kStereoHeight, -kFx*kBaseline, kFx)); + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr leftPub_; + rclcpp::Publisher::SharedPtr rightPub_; + rclcpp::Publisher::SharedPtr leftInfoPub_; + rclcpp::Publisher::SharedPtr rightInfoPub_; +}; + +TEST_F(PointCloudXYZRGBStereoTest, MatchesAStereoPairIntoAColoredCloud) +{ + start({rclcpp::Parameter("StereoBM/NumDisparities", std::string("16")), + rclcpp::Parameter("StereoBM/BlockSize", std::string("9"))}); + publishFrame(1000.0); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })) << "no cloud published"; + + const sensor_msgs::msg::PointCloud2 & cloud = out_->back(); + EXPECT_EQ(cloud.width * cloud.height, uint32_t(kStereoWidth*kStereoHeight)) + << "the cloud stays organized, one point per pixel"; + EXPECT_EQ(cloud.header.frame_id, "camera_link") + << "the cloud takes the left image's frame"; + EXPECT_TRUE(hasField(cloud, "rgb")); + + // The pair is a shifted copy of itself, so the whole matched area sits at one depth. + const size_t center = size_t(kStereoHeight/2) * kStereoWidth + kStereoWidth/2; + EXPECT_NEAR(readXYZ(cloud, center).z, 2.0f, 0.2f); +} + +TEST_F(PointCloudXYZRGBStereoTest, RejectsUnsupportedStereoEncoding) +{ + start(); + leftPub_->publish(makeImage("camera_link", 1000.0, + cv::Mat(kStereoHeight, kStereoWidth, CV_32FC1, cv::Scalar(1.0f)), "32FC1")); + rightPub_->publish(makeImage("camera_link", 1000.0, + cv::Mat(kStereoHeight, kStereoWidth, CV_8UC1, cv::Scalar(0)), "mono8")); + leftInfoPub_->publish(makeCameraInfo( + "camera_link", 1000.0, kStereoWidth, kStereoHeight, 0.0, kFx)); + rightInfoPub_->publish(makeCameraInfo( + "camera_link", 1000.0, kStereoWidth, kStereoHeight, -kFx*kBaseline, kFx)); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(out_->empty()) << "only 8-bit and mono16 stereo images are supported"; +} diff --git a/rtabmap_util/test/test_pointcloud_to_depthimage.cpp b/rtabmap_util/test/test_pointcloud_to_depthimage.cpp new file mode 100644 index 00000000..252797ed --- /dev/null +++ b/rtabmap_util/test/test_pointcloud_to_depthimage.cpp @@ -0,0 +1,370 @@ +/* +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 + +using namespace rtabmap_util_test; + +namespace { +::testing::Environment * const kEnv = registerRclcppEnvironment(); + +constexpr int kWidth = 16; +constexpr int kHeight = 16; +constexpr double kFx = 100.0; + +float pixel32f(const sensor_msgs::msg::Image & img, int row, int col) +{ + return *reinterpret_cast(&img.data[row * img.step + col * sizeof(float)]); +} + +uint16_t pixel16u(const sensor_msgs::msg::Image & img, int row, int col) +{ + return *reinterpret_cast(&img.data[row * img.step + col * sizeof(uint16_t)]); +} +} // namespace + +class PointCloudToDepthImageTest : public NodeTest +{ +protected: + void start(const std::vector & overrides = {}, bool withTf = true) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + if(withTf) + { + publishStaticTf("camera_link", "lidar"); + } + image32_ = collect("image"); + image16_ = collect("image_raw"); + cloudPub_ = helper()->create_publisher("cloud", 10); + infoPub_ = helper()->create_publisher("camera_info", 10); + ASSERT_TRUE(waitForSubscriber(cloudPub_)); + ASSERT_TRUE(waitForSubscriber(infoPub_)); + ASSERT_TRUE(waitForPublisher(image32_->subscription)); + } + + /// Publishes a cloud and its camera info with identical stamps. + void publishFrame(double stamp, const std::vector & points) + { + cloudPub_->publish(makeXYZCloud("lidar", stamp, points)); + infoPub_->publish(makeCameraInfo("camera_link", stamp, kWidth, kHeight, 0.0, kFx)); + } + + /// A block of points straight ahead of the optical axis at @p depth meters. + static std::vector blockAt(float depth) + { + std::vector points; + for(int i=-2; i<=2; ++i) + { + for(int j=-2; j<=2; ++j) + { + points.push_back(cv::Point3f(0.01f*i, 0.01f*j, depth)); + } + } + return points; + } + + std::shared_ptr> image32_; + std::shared_ptr> image16_; + rclcpp::Publisher::SharedPtr cloudPub_; + rclcpp::Publisher::SharedPtr infoPub_; +}; + +TEST_F(PointCloudToDepthImageTest, ProjectsACloudIntoADepthImage) +{ + start(); + publishFrame(1000.0, blockAt(2.0f)); + ASSERT_TRUE(spinUntil([&]() { return !image32_->empty(); })) << "no depth image"; + + const sensor_msgs::msg::Image & img = image32_->back(); + EXPECT_EQ(img.encoding, sensor_msgs::image_encodings::TYPE_32FC1); + EXPECT_EQ(img.width, uint32_t(kWidth)); + EXPECT_EQ(img.height, uint32_t(kHeight)); + EXPECT_EQ(img.header.frame_id, "camera_link") + << "the depth image belongs to the camera, not the cloud"; + + // The points sit on the optical axis, so they land on the principal point. + EXPECT_NEAR(pixel32f(img, kHeight/2, kWidth/2), 2.0f, 1e-3); + // A corner sees nothing. + EXPECT_FLOAT_EQ(pixel32f(img, 0, 0), 0.0f); +} + +TEST_F(PointCloudToDepthImageTest, PublishesMillimetersOnImageRaw) +{ + start(); + publishFrame(1000.0, blockAt(2.0f)); + ASSERT_TRUE(spinUntil([&]() { return !image16_->empty(); })); + + const sensor_msgs::msg::Image & img = image16_->back(); + EXPECT_EQ(img.encoding, sensor_msgs::image_encodings::TYPE_16UC1); + EXPECT_EQ(pixel16u(img, kHeight/2, kWidth/2), 2000) << "2 m expressed in millimeters"; +} + +TEST_F(PointCloudToDepthImageTest, EmptyCloudGivesAnAllZeroImage) +{ + start(); + publishFrame(1000.0, {}); + ASSERT_TRUE(spinUntil([&]() { return !image32_->empty(); })) + << "an empty cloud must still produce an image, not a dropped frame"; + + const sensor_msgs::msg::Image & img = image32_->back(); + EXPECT_EQ(img.width, uint32_t(kWidth)); + for(int row=0; row> infoOut = + collect("image/camera_info"); + ASSERT_TRUE(waitForPublisher(infoOut->subscription)); + + publishFrame(1000.0, blockAt(2.0f)); + ASSERT_TRUE(spinUntil([&]() { return !image32_->empty() && !infoOut->empty(); })); + + EXPECT_EQ(image32_->back().width, uint32_t(kWidth)/2); + EXPECT_EQ(image32_->back().height, uint32_t(kHeight)/2); + EXPECT_NEAR(infoOut->back().p[0], kFx/2.0, 1e-6) + << "the published camera info must match the decimated image"; + EXPECT_EQ(infoOut->back().width, uint32_t(kWidth)/2); +} + +TEST_F(PointCloudToDepthImageTest, FailsWithoutTheCloudToCameraTransform) +{ + start({rclcpp::Parameter("wait_for_transform", 0.0)}, /*withTf=*/false); + publishFrame(1000.0, blockAt(2.0f)); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(image32_->empty()) + << "without TF the cloud cannot be placed in the camera frame"; +} + +TEST_F(PointCloudToDepthImageTest, StaysSilentWithoutASubscriber) +{ + // The projection is skipped entirely when neither image topic is subscribed. + addNode(std::make_shared(rclcpp::NodeOptions())); + publishStaticTf("camera_link", "lidar"); + rclcpp::Publisher::SharedPtr cloudPub = + helper()->create_publisher("cloud", 10); + rclcpp::Publisher::SharedPtr infoPub = + helper()->create_publisher("camera_info", 10); + ASSERT_TRUE(waitForSubscriber(cloudPub)); + + cloudPub->publish(makeXYZCloud("lidar", 1000.0, blockAt(2.0f))); + infoPub->publish(makeCameraInfo("camera_link", 1000.0, kWidth, kHeight, 0.0, kFx)); + spinFor(std::chrono::milliseconds(300)); + + std::shared_ptr> late = + collect("image"); + spinFor(std::chrono::milliseconds(200)); + EXPECT_TRUE(late->empty()); +} + +//============================================================================ +// Motion compensation between the cloud stamp and the camera_info stamp +//============================================================================ + +/** + * With approximate synchronization the cloud and the camera info rarely share a stamp. + * When @c fixed_frame_id is set, the node asks TF how the lidar moved over that interval + * and folds the displacement into the camera's local transform, so the cloud is projected + * from where the camera was at its own stamp instead of where the lidar was. + * + * The frames follow the usual convention, as in rtabmap's own projectCloudToCamera tests: + * the cloud is expressed in a lidar frame with x forward, and the camera is attached to it + * through the optical rotation, so a point straight ahead lands on the principal point. + */ +class PointCloudToDepthImageMotionTest : public NodeTest +{ +protected: + static constexpr double kSpeed = 1.0; ///< m/s + static constexpr double kCloudStamp = 1000.0; + static constexpr double kInfoDelay = 0.04; ///< the camera info lags the cloud by this + static constexpr float kRange = 2.0f; ///< distance to the point, meters + static constexpr int kFrames = 3; ///< see publishFrames() + static constexpr double kPeriod = 0.2; ///< seconds between frames + + /// Distance travelled between the two stamps: what the node has to compensate for. + static double travelled() { return kSpeed * kInfoDelay; } + + /// The same distance seen sideways by the camera, in pixels. + static int shiftInPixels() { return int(kFx * travelled() / double(kRange)); } + + /** + * @param axis 'x' to drive straight at the point, 'y' to drive sideways past it, + * '0' to stand still + */ + void start(const std::vector & overrides, char axis = 'x') + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(overrides))); + // The camera is bolted to the lidar, looking the same way: x right, y down, + // z forward against the lidar's x forward, y left, z up. + publishStaticTfRPY("lidar", "camera_link", -M_PI/2.0, 0.0, -M_PI/2.0); + if(axis != '0') + { + publishOdomMotion(axis); + } + image32_ = collect("image"); + cloudPub_ = helper()->create_publisher("cloud", 10); + infoPub_ = helper()->create_publisher("camera_info", 10); + ASSERT_TRUE(waitForSubscriber(cloudPub_)); + ASSERT_TRUE(waitForSubscriber(infoPub_)); + ASSERT_TRUE(waitForPublisher(image32_->subscription)); + } + + /// Publishes odom -> lidar moving at kSpeed, covering every stamp used below. + void publishOdomMotion(char axis) + { + rclcpp::Publisher::SharedPtr tfPub = + helper()->create_publisher("/tf", rclcpp::QoS(100)); + spinFor(std::chrono::milliseconds(100)); // let the node's listener subscribe + + // One sample per cloud stamp and per camera info stamp, plus a margin on each side + // so that nothing has to be extrapolated. + std::vector elapsedSamples; + elapsedSamples.push_back(-kPeriod); + for(int k=0; kpublish(msg); + } + spinFor(std::chrono::milliseconds(200)); // let the buffer fill + tfPub_ = tfPub; // keep the publisher alive + } + + /** + * @brief Publishes kFrames cloud/camera_info pairs, the info @p delay seconds late. + * + * Each cloud holds a single point straight ahead of the lidar. The speed is constant, + * so every pair needs the same correction and the first output is enough to assert on. + * A burst is needed because ApproximateTime emits nothing for a lone pair whose stamps + * differ: it cannot rule out a better match still to come. + */ + void publishFrames(double delay) + { + for(int k=0; kpublish(makeXYZCloud("lidar", kCloudStamp + elapsed, + {cv::Point3f(kRange, 0.0f, 0.0f)})); + infoPub_->publish(makeCameraInfo( + "camera_link", kCloudStamp + elapsed + delay, kWidth, kHeight, 0.0, kFx)); + } + } + + /// The column the single point landed in on @p row, or -1 if that row is empty. + static int hitColumn(const sensor_msgs::msg::Image & img, int row) + { + for(int col=0; col> image32_; + rclcpp::Publisher::SharedPtr cloudPub_; + rclcpp::Publisher::SharedPtr infoPub_; + rclcpp::Publisher::SharedPtr tfPub_; +}; + +TEST_F(PointCloudToDepthImageMotionTest, NoShiftWhenTheStampsMatch) +{ + // The control case: same frames, same scene, nothing to compensate. It also proves the + // optical rotation is right, since a wrongly oriented camera sees nothing at all. + start({rclcpp::Parameter("fixed_frame_id", std::string("odom"))}); + publishFrames(0.0); + ASSERT_TRUE(spinUntil([&]() { return !image32_->empty(); })) << "no depth image"; + + const sensor_msgs::msg::Image & img = image32_->front(); + EXPECT_EQ(hitColumn(img, kHeight/2), kWidth/2) + << "a point straight ahead belongs at the principal point"; + EXPECT_NEAR(pixel32f(img, kHeight/2, kWidth/2), kRange, 1e-3); +} + +TEST_F(PointCloudToDepthImageMotionTest, ClosesTheGapWhenDrivingAtThePoint) +{ + // The camera info is 40 ms younger than the cloud and the robot closes in at 1 m/s, so + // by the time of the exposure the point is 4 cm nearer than the lidar measured it. + start({rclcpp::Parameter("fixed_frame_id", std::string("odom"))}, 'x'); + publishFrames(kInfoDelay); + ASSERT_TRUE(spinUntil([&]() { return !image32_->empty(); })) << "no depth image"; + + const sensor_msgs::msg::Image & img = image32_->front(); + EXPECT_EQ(hitColumn(img, kHeight/2), kWidth/2) + << "driving straight at the point does not move it across the image"; + EXPECT_NEAR(pixel32f(img, kHeight/2, kWidth/2), kRange - float(travelled()), 1e-3) + << "the depth must be corrected for the distance travelled"; +} + +TEST_F(PointCloudToDepthImageMotionTest, ShiftsThePointWhenDrivingPastIt) +{ + // Moving sideways instead: the point slides across the image by fx*d/Z pixels, and + // stays at the same range. + start({rclcpp::Parameter("fixed_frame_id", std::string("odom"))}, 'y'); + publishFrames(kInfoDelay); + ASSERT_TRUE(spinUntil([&]() { return !image32_->empty(); })) << "no depth image"; + + const sensor_msgs::msg::Image & img = image32_->front(); + EXPECT_EQ(hitColumn(img, kHeight/2), kWidth/2 + shiftInPixels()) + << "the robot moved left, so the point must appear further right"; + EXPECT_NEAR(pixel32f(img, kHeight/2, kWidth/2 + shiftInPixels()), kRange, 1e-3) + << "only the bearing changed, not the range"; + EXPECT_FLOAT_EQ(pixel32f(img, kHeight/2, kWidth/2), 0.0f) + << "and it is no longer at the principal point"; +} + +TEST_F(PointCloudToDepthImageMotionTest, IgnoresTheStampDifferenceWithoutAFixedFrame) +{ + // Without fixed_frame_id there is nothing to measure the motion against, so the cloud + // is projected as if both messages were captured at the same instant. That is why the + // node logs a fatal error when approximate sync is used without one. + start({rclcpp::Parameter("fixed_frame_id", std::string(""))}, 'x'); + publishFrames(kInfoDelay); + ASSERT_TRUE(spinUntil([&]() { return !image32_->empty(); })) << "no depth image"; + + EXPECT_NEAR(pixel32f(image32_->front(), kHeight/2, kWidth/2), kRange, 1e-3) + << "no fixed frame, no compensation"; +} + +TEST_F(PointCloudToDepthImageMotionTest, FailsWhenTheFixedFrameIsUnknown) +{ + // fixed_frame_id is set but odom -> lidar was never published: the displacement cannot + // be measured, and projecting anyway would silently misplace the points. + start({rclcpp::Parameter("fixed_frame_id", std::string("odom")), + rclcpp::Parameter("wait_for_transform", 0.0)}, '0'); + publishFrames(kInfoDelay); + spinFor(std::chrono::milliseconds(400)); + + EXPECT_TRUE(image32_->empty()); +} diff --git a/rtabmap_util/test/test_rgbd_relay.cpp b/rtabmap_util/test/test_rgbd_relay.cpp new file mode 100644 index 00000000..6489bef3 --- /dev/null +++ b/rtabmap_util/test/test_rgbd_relay.cpp @@ -0,0 +1,350 @@ +/* +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(); +} + +class RGBDRelayTest : public NodeTest +{ +protected: + /// Starts the node, wires up the input publisher and the output collector. + void start(bool compress, bool uncompress) + { + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({ + rclcpp::Parameter("compress", compress), + rclcpp::Parameter("uncompress", uncompress)}))); + + out_ = collect("rgbd_image_relay"); + pub_ = helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub_)); + ASSERT_TRUE(waitForPublisher(out_->subscription)); + } + + std::shared_ptr> out_; + rclcpp::Publisher::SharedPtr pub_; +}; + +TEST_F(RGBDRelayTest, RepublishesUnchangedByDefault) +{ + start(/*compress=*/false, /*uncompress=*/false); + + const rtabmap_msgs::msg::RGBDImage in = makeRGBDImage("camera_link", 1000.0); + pub_->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const rtabmap_msgs::msg::RGBDImage & got = out_->back(); + EXPECT_EQ(got.header.frame_id, in.header.frame_id); + EXPECT_EQ(got.rgb.data, in.rgb.data) << "the payload must be passed through untouched"; + EXPECT_EQ(got.depth.data, in.depth.data); + EXPECT_TRUE(got.rgb_compressed.data.empty()); + EXPECT_TRUE(got.depth_compressed.data.empty()); + EXPECT_NEAR(got.rgb_camera_info.p[0], in.rgb_camera_info.p[0], 1e-9); +} + +TEST_F(RGBDRelayTest, CompressesRawImagesWhenAsked) +{ + start(/*compress=*/true, /*uncompress=*/false); + + pub_->publish(makeRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const rtabmap_msgs::msg::RGBDImage & got = out_->back(); + EXPECT_FALSE(got.rgb_compressed.data.empty()) << "rgb must be compressed"; + EXPECT_FALSE(got.depth_compressed.data.empty()) << "depth must be compressed"; + // Depth is lossless png; color is jpg. + EXPECT_EQ(got.depth_compressed.format, "png"); + EXPECT_TRUE(got.rgb.data.empty()) << "the raw image is not carried as well"; +} + +TEST_F(RGBDRelayTest, CompressesAStereoPairAsJpeg) +{ + // When the camera infos describe a stereo pair, the "depth" slot holds the right + // image and is compressed as JPEG, not as a lossless depth PNG. + start(/*compress=*/true, /*uncompress=*/false); + + pub_->publish(makeStereoRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const rtabmap_msgs::msg::RGBDImage & got = out_->back(); + ASSERT_FALSE(got.depth_compressed.data.empty()); + EXPECT_NE(got.depth_compressed.format, "png") + << "a stereo right image must not take the depth PNG path"; + EXPECT_NE(got.depth_compressed.format.find("jp"), std::string::npos) + << "expected a jpeg format, got \"" << got.depth_compressed.format << "\""; + EXPECT_LT(got.depth_camera_info.p[3], 0.0) << "the baseline must survive the relay"; +} + +TEST_F(RGBDRelayTest, CompressesDepthAsLosslessPng) +{ + // The same call with no baseline is treated as color + depth instead. + start(/*compress=*/true, /*uncompress=*/false); + + pub_->publish(makeRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const rtabmap_msgs::msg::RGBDImage & got = out_->back(); + ASSERT_FALSE(got.depth_compressed.data.empty()); + EXPECT_EQ(got.depth_compressed.format, "png") << "depth must stay lossless"; + EXPECT_DOUBLE_EQ(got.depth_camera_info.p[3], 0.0) << "no baseline: not stereo"; +} + +TEST_F(RGBDRelayTest, UncompressRestoresAStereoRightImage) +{ + // The uncompress path branches on the format: "jpg" means a stereo right image and + // goes through cv_bridge, anything else is a depth image and goes through rtabmap. + start(/*compress=*/false, /*uncompress=*/true); + + rtabmap_msgs::msg::RGBDImage in = makeStereoRGBDImage("camera_link", 1000.0); + const cv::Mat right(8, 8, CV_8UC1, cv::Scalar(60)); + cv_bridge::CvImage(std_msgs::msg::Header(), "mono8", right) + .toCompressedImageMsg(in.depth_compressed, cv_bridge::JPG); + in.depth = sensor_msgs::msg::Image(); + + pub_->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const rtabmap_msgs::msg::RGBDImage & got = out_->back(); + ASSERT_FALSE(got.depth.data.empty()) << "the right image must be decompressed"; + EXPECT_EQ(got.depth.encoding, "mono8") << "restored as an 8-bit image, not depth"; + EXPECT_EQ(got.depth.width, 8u); + EXPECT_EQ(got.depth.height, 8u); +} + +TEST_F(RGBDRelayTest, UncompressRestoresRawImages) +{ + start(/*compress=*/false, /*uncompress=*/true); + + // Feed it a message that carries only compressed depth. + rtabmap_msgs::msg::RGBDImage in = makeRGBDImage("camera_link", 1000.0); + const cv::Mat depth(8, 8, CV_16UC1, cv::Scalar(1500)); + in.depth = sensor_msgs::msg::Image(); + in.depth_compressed.header = in.header; + in.depth_compressed.format = "png"; + in.depth_compressed.data = rtabmap::compressImage(depth, ".png"); + + pub_->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + const rtabmap_msgs::msg::RGBDImage & got = out_->back(); + ASSERT_FALSE(got.depth.data.empty()) << "depth must be decompressed"; + EXPECT_EQ(got.depth.encoding, sensor_msgs::image_encodings::TYPE_16UC1); + EXPECT_EQ(got.depth.width, 8u); + EXPECT_EQ(got.depth.height, 8u); +} + +TEST_F(RGBDRelayTest, UncompressPrefersTheRawImageOverTheCompressedOne) +{ + // A message may carry both. The raw image is already usable, so decompressing the + // other copy would be wasted work -- and the two paths must agree, as the depth + // branch below does. + start(/*compress=*/false, /*uncompress=*/true); + + rtabmap_msgs::msg::RGBDImage in = makeRGBDImage("camera_link", 1000.0); + // A compressed copy whose content differs, so it is obvious which one was used. + cv_bridge::CvImage(std_msgs::msg::Header(), "bgr8", + cv::Mat(8, 8, CV_8UC3, cv::Scalar(200, 200, 200))) + .toCompressedImageMsg(in.rgb_compressed, cv_bridge::PNG); + + pub_->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !out_->empty(); })); + + ASSERT_FALSE(out_->back().rgb.data.empty()); + EXPECT_EQ(out_->back().rgb.data, in.rgb.data) + << "the raw image must be forwarded, not the decompressed copy"; +} + +TEST_F(RGBDRelayTest, StaysSilentWithoutASubscriber) +{ + // No collector, so the relay's output has no subscriber and it must not do the work. + addNode(std::make_shared(rclcpp::NodeOptions())); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + + pub->publish(makeRGBDImage("camera_link", 1000.0)); + spinFor(std::chrono::milliseconds(300)); + + // Subscribing only now must not retroactively receive anything. + std::shared_ptr> late = + collect("rgbd_image_relay"); + spinFor(std::chrono::milliseconds(200)); + EXPECT_TRUE(late->empty()); +} + +/// Feeds a compressed right image in @p format through the uncompress path. +class RGBDRelayRightImageTest : public NodeTest +{ +protected: + rtabmap_msgs::msg::RGBDImage relay(cv_bridge::Format format) + { + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("uncompress", true)}))); + + std::shared_ptr> out = + collect("rgbd_image_relay"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + EXPECT_TRUE(waitForSubscriber(pub)); + EXPECT_TRUE(waitForPublisher(out->subscription)); + + rtabmap_msgs::msg::RGBDImage in = makeStereoRGBDImage("camera_link", 1000.0); + cv_bridge::CvImage(std_msgs::msg::Header(), "mono8", + cv::Mat(8, 8, CV_8UC1, cv::Scalar(60))) + .toCompressedImageMsg(in.depth_compressed, format); + in.depth = sensor_msgs::msg::Image(); + + pub->publish(in); + EXPECT_TRUE(spinUntil([&]() { return !out->empty(); })); + return out->empty() ? rtabmap_msgs::msg::RGBDImage() : out->back(); + } +}; + +TEST_F(RGBDRelayRightImageTest, UncompressesAJpegRightImage) +{ + const rtabmap_msgs::msg::RGBDImage got = relay(cv_bridge::JPG); + ASSERT_FALSE(got.depth.data.empty()); + EXPECT_EQ(got.depth.encoding, sensor_msgs::image_encodings::MONO8); + EXPECT_EQ(got.depth.step, 8u); +} + +TEST_F(RGBDRelayRightImageTest, UncompressesAPngRightImage) +{ + // A losslessly compressed right image must not be mistaken for depth and abort. + const rtabmap_msgs::msg::RGBDImage got = relay(cv_bridge::PNG); + ASSERT_FALSE(got.depth.data.empty()); + EXPECT_EQ(got.depth.encoding, sensor_msgs::image_encodings::MONO8); + EXPECT_EQ(got.depth.step, 8u); +} + +/// QoS of the two sides, set independently through qos_sub and qos_pub. +/// +/// A reliable subscription refuses to match a best-effort publisher, while a best-effort +/// subscription matches either. Every assertion below rests on that asymmetry: whether a +/// connection is established at all is what tells us which reliability the node picked. +class RGBDRelayQosTest : public NodeTest +{ +protected: + enum Reliability { kSystemDefault = 0, kReliable = 1, kBestEffort = 2 }; + + void startRelay(const std::vector & params) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(params))); + } + + rclcpp::Publisher::SharedPtr input(Reliability reliability) + { + rclcpp::QoS qos(10); + reliability == kBestEffort ? qos.best_effort() : qos.reliable(); + return helper()->create_publisher("rgbd_image", qos); + } + + std::shared_ptr> output(Reliability reliability) + { + rclcpp::QoS qos(10); + reliability == kBestEffort ? qos.best_effort() : qos.reliable(); + return collect("rgbd_image_relay", qos); + } +}; + +TEST_F(RGBDRelayQosTest, BridgesABestEffortSourceToAReliableConsumer) +{ + // The point of splitting the parameter: a sensor publishing best effort feeding a + // consumer that only accepts reliable. Neither could talk to the other directly. + startRelay({rclcpp::Parameter("qos_sub", int(kBestEffort)), + rclcpp::Parameter("qos_pub", int(kReliable))}); + + std::shared_ptr> out = output(kReliable); + rclcpp::Publisher::SharedPtr pub = input(kBestEffort); + ASSERT_TRUE(waitForSubscriber(pub)) << "a best-effort source must reach the relay"; + ASSERT_TRUE(waitForPublisher(out->subscription)) + << "a reliable consumer must be able to subscribe to the relayed topic"; + + pub->publish(makeRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })); + EXPECT_EQ(out->back().header.frame_id, "camera_link"); +} + +TEST_F(RGBDRelayQosTest, QosSubOverridesQosOnTheInputOnly) +{ + // qos says reliable, which a best-effort source could not match; qos_sub overrides it. + startRelay({rclcpp::Parameter("qos", int(kReliable)), + rclcpp::Parameter("qos_sub", int(kBestEffort))}); + + rclcpp::Publisher::SharedPtr pub = input(kBestEffort); + EXPECT_TRUE(waitForSubscriber(pub)) << "qos_sub must win over qos on the subscription"; + + // The output side kept qos, so a reliable consumer still matches it. + std::shared_ptr> out = output(kReliable); + EXPECT_TRUE(waitForPublisher(out->subscription)) + << "qos_sub must not affect the publisher"; +} + +TEST_F(RGBDRelayQosTest, QosPubOverridesQosOnTheOutputOnly) +{ + // qos says best effort, which no reliable consumer could match; qos_pub overrides it. + startRelay({rclcpp::Parameter("qos", int(kBestEffort)), + rclcpp::Parameter("qos_pub", int(kReliable))}); + + std::shared_ptr> out = output(kReliable); + EXPECT_TRUE(waitForPublisher(out->subscription)) + << "qos_pub must win over qos on the publisher"; + + // The input side kept qos, so it is still best effort and accepts a best-effort source. + rclcpp::Publisher::SharedPtr pub = input(kBestEffort); + EXPECT_TRUE(waitForSubscriber(pub)) << "qos_pub must not affect the subscription"; +} + +TEST_F(RGBDRelayQosTest, BothSidesFallBackToQos) +{ + // Only qos is given, so both sides must be best effort -- as before the split. + startRelay({rclcpp::Parameter("qos", int(kBestEffort))}); + + rclcpp::Publisher::SharedPtr pub = input(kBestEffort); + EXPECT_TRUE(waitForSubscriber(pub)) << "the subscription must have followed qos"; + + std::shared_ptr> out = output(kReliable); + spinFor(std::chrono::milliseconds(500)); + EXPECT_EQ(out->subscription->get_publisher_count(), 0u) + << "the publisher must have followed qos too: best effort, so a reliable " + "consumer cannot match it"; +} + +TEST_F(RGBDRelayQosTest, HonorsTheConfiguredQueueDepths) +{ + // Queue depth is not directly observable from outside, so this only pins down that + // the parameters are accepted and the relay still works with them set. + startRelay({rclcpp::Parameter("queue_sub", 20), rclcpp::Parameter("queue_pub", 10)}); + + std::shared_ptr> out = output(kReliable); + rclcpp::Publisher::SharedPtr pub = input(kReliable); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(out->subscription)); + + pub->publish(makeRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !out->empty(); })); + EXPECT_EQ(out->back().header.frame_id, "camera_link"); +} + +TEST_F(RGBDRelayQosTest, RejectsAZeroQueueDepth) +{ + // rclcpp::QoS(0) is not a meaningful depth, so say so at construction rather than + // leaving the relay silently misconfigured. + EXPECT_THROW( + startRelay({rclcpp::Parameter("queue_sub", 0)}), + UException); +} diff --git a/rtabmap_util/test/test_rgbd_split.cpp b/rtabmap_util/test/test_rgbd_split.cpp new file mode 100644 index 00000000..f032d998 --- /dev/null +++ b/rtabmap_util/test/test_rgbd_split.cpp @@ -0,0 +1,397 @@ +/* +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(); +} + +class RGBDSplitTest : public NodeTest {}; + +TEST_F(RGBDSplitTest, SplitsIntoImageAndCameraInfoTopics) +{ + addNode(std::make_shared(rclcpp::NodeOptions())); + + // The node derives its output topics from the input topic name. + std::shared_ptr> rgb = + collect("rgbd_image/rgb/image"); + std::shared_ptr> depth = + collect("rgbd_image/depth/image"); + std::shared_ptr> rgbInfo = + collect("rgbd_image/rgb/camera_info"); + std::shared_ptr> depthInfo = + collect("rgbd_image/depth/camera_info"); + + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + const rtabmap_msgs::msg::RGBDImage in = makeRGBDImage("camera_link", 1000.0); + pub->publish(in); + ASSERT_TRUE(spinUntil([&]() { + return !rgb->empty() && !depth->empty() && !rgbInfo->empty() && !depthInfo->empty(); + })) << "not all four outputs were published"; + + EXPECT_EQ(rgb->back().encoding, "bgr8"); + EXPECT_EQ(rgb->back().data, in.rgb.data); + EXPECT_EQ(depth->back().encoding, sensor_msgs::image_encodings::TYPE_16UC1); + EXPECT_EQ(depth->back().data, in.depth.data); + + EXPECT_NEAR(rgbInfo->back().p[0], in.rgb_camera_info.p[0], 1e-9); + EXPECT_EQ(rgbInfo->back().width, in.rgb_camera_info.width); + EXPECT_NEAR(depthInfo->back().p[0], in.depth_camera_info.p[0], 1e-9); +} + +TEST_F(RGBDSplitTest, FallsBackToTheInputHeaderForTheDepthCameraInfo) +{ + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> depth = + collect("rgbd_image/depth/image"); + std::shared_ptr> depthInfo = + collect("rgbd_image/depth/camera_info"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + // Depth camera info with no frame id: the node fills it from the message header. + rtabmap_msgs::msg::RGBDImage in = makeRGBDImage("camera_link", 1000.0); + in.depth_camera_info.header.frame_id = ""; + pub->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !depthInfo->empty(); })); + + EXPECT_EQ(depthInfo->back().header.frame_id, "camera_link"); +} + +TEST_F(RGBDSplitTest, PassesAStereoPairThroughUnchanged) +{ + // The node does not distinguish stereo from depth: it forwards whatever is in the + // "depth" slot, so a stereo right image is published on .../depth/image along with + // the right camera info carrying the baseline. + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> right = + collect("rgbd_image/depth/image"); + std::shared_ptr> rightInfo = + collect("rgbd_image/depth/camera_info"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(right->subscription)); + + const rtabmap_msgs::msg::RGBDImage in = makeStereoRGBDImage("camera_link", 1000.0); + pub->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !right->empty() && !rightInfo->empty(); })); + + EXPECT_EQ(right->back().encoding, "mono8") << "the right image is forwarded as-is"; + EXPECT_EQ(right->back().data, in.depth.data); + EXPECT_LT(rightInfo->back().p[3], 0.0) << "the baseline must reach the consumer"; +} + +TEST_F(RGBDSplitTest, DecompressesDepthWithTheCorrectEncoding) +{ + // rtabmap compresses depth as a PNG whose format string cv_bridge cannot interpret. + // The node must decode it itself and label it 16UC1, not mono8: the buffer is two + // bytes per pixel and a wrong encoding makes every consumer misread it. + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> depth = + collect("rgbd_image/depth/image"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + rtabmap_msgs::msg::RGBDImage in = makeRGBDImage("camera_link", 1000.0); + const cv::Mat original(8, 8, CV_16UC1, cv::Scalar(1500)); + in.depth = sensor_msgs::msg::Image(); + in.depth_compressed.header = in.header; + in.depth_compressed.format = "png"; + in.depth_compressed.data = rtabmap::compressImage(original, ".png"); + + pub->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !depth->empty(); })); + + const sensor_msgs::msg::Image & got = depth->back(); + EXPECT_EQ(got.encoding, sensor_msgs::image_encodings::TYPE_16UC1) + << "a 16-bit depth buffer must not be labeled mono8"; + EXPECT_EQ(got.width, 8u); + EXPECT_EQ(got.height, 8u); + ASSERT_EQ(got.step, 16u) << "two bytes per pixel"; + EXPECT_EQ(*reinterpret_cast(&got.data[0]), 1500) + << "and the values must survive the round trip"; +} + +/// Feeds a compressed right image in @p format and returns what lands on depth/image. +class RGBDSplitRightImageTest : public NodeTest +{ +protected: + sensor_msgs::msg::Image split(cv_bridge::Format format) + { + addNode(std::make_shared(rclcpp::NodeOptions())); + + std::shared_ptr> right = + collect("rgbd_image/depth/image"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + EXPECT_TRUE(waitForSubscriber(pub)); + EXPECT_TRUE(waitForPublisher(right->subscription)); + + rtabmap_msgs::msg::RGBDImage in = makeStereoRGBDImage("camera_link", 1000.0); + cv_bridge::CvImage(std_msgs::msg::Header(), "mono8", + cv::Mat(8, 8, CV_8UC1, cv::Scalar(60))) + .toCompressedImageMsg(in.depth_compressed, format); + in.depth = sensor_msgs::msg::Image(); + + pub->publish(in); + EXPECT_TRUE(spinUntil([&]() { return !right->empty(); })) + << "the right image must be decompressed, not rejected"; + return right->empty() ? sensor_msgs::msg::Image() : right->back(); + } +}; + +TEST_F(RGBDSplitRightImageTest, DecompressesAJpegRightImage) +{ + // What stereo_sync emits. + const sensor_msgs::msg::Image got = split(cv_bridge::JPG); + EXPECT_EQ(got.encoding, sensor_msgs::image_encodings::MONO8); + EXPECT_EQ(got.step, 8u) << "one byte per pixel, not mistaken for 16-bit depth"; +} + +TEST_F(RGBDSplitRightImageTest, DecompressesAPngRightImage) +{ + // Nothing forbids a producer from compressing the right image losslessly, and a + // stereo pipeline may prefer it since JPEG artifacts hurt matching. Going by the + // format string alone would send this down the depth path and abort on the assert. + const sensor_msgs::msg::Image got = split(cv_bridge::PNG); + EXPECT_EQ(got.encoding, sensor_msgs::image_encodings::MONO8); + EXPECT_EQ(got.step, 8u); +} + +/// Queue depths and the reach of the qos parameter. +class RGBDSplitQosTest : public NodeTest +{ +protected: + void startSplit(const std::vector & params) + { + addNode(std::make_shared( + rclcpp::NodeOptions().parameter_overrides(params))); + } +}; + +TEST_F(RGBDSplitQosTest, HonorsTheConfiguredQueueDepths) +{ + // Queue depth is not observable from outside, so this pins down that the parameters + // are accepted and the node still splits with them set. + startSplit({rclcpp::Parameter("queue_sub", 20), rclcpp::Parameter("queue_pub", 10)}); + + std::shared_ptr> rgb = + collect("rgbd_image/rgb/image"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + + pub->publish(makeRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !rgb->empty(); })); + EXPECT_EQ(rgb->back().encoding, "bgr8"); +} + +TEST_F(RGBDSplitQosTest, RejectsAZeroQueueDepth) +{ + EXPECT_THROW(startSplit({rclcpp::Parameter("queue_pub", 0)}), UException); +} + +TEST_F(RGBDSplitQosTest, AppliesQosToTheCameraInfoPublishersToo) +{ + // A best-effort node must be best effort on every output, camera infos included: + // a reliable consumer must not match any of them. + startSplit({rclcpp::Parameter("qos", 2)}); + + std::shared_ptr> rgbInfo = + collect( + "rgbd_image/rgb/camera_info", rclcpp::QoS(10).reliable()); + std::shared_ptr> depthInfo = + collect( + "rgbd_image/depth/camera_info", rclcpp::QoS(10).reliable()); + spinFor(std::chrono::milliseconds(500)); + + EXPECT_EQ(rgbInfo->subscription->get_publisher_count(), 0u) + << "the rgb camera info publisher ignored qos"; + EXPECT_EQ(depthInfo->subscription->get_publisher_count(), 0u) + << "the depth camera info publisher ignored qos"; +} + +/// Output topic naming, controlled by the stereo parameter. +class RGBDSplitStereoNamingTest : public NodeTest +{ +protected: + void startSplit(bool stereo) + { + addNode(std::make_shared(rclcpp::NodeOptions() + .parameter_overrides({rclcpp::Parameter("stereo", stereo)}))); + } +}; + +TEST_F(RGBDSplitStereoNamingTest, PublishesOnLeftAndRightWhenStereoIsSet) +{ + startSplit(/*stereo=*/true); + + std::shared_ptr> left = + collect("rgbd_image/left/image"); + std::shared_ptr> right = + collect("rgbd_image/right/image"); + std::shared_ptr> leftInfo = + collect("rgbd_image/left/camera_info"); + std::shared_ptr> rightInfo = + collect("rgbd_image/right/camera_info"); + + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(left->subscription)); + ASSERT_TRUE(waitForPublisher(right->subscription)); + + const rtabmap_msgs::msg::RGBDImage in = makeStereoRGBDImage("camera_link", 1000.0); + pub->publish(in); + ASSERT_TRUE(spinUntil([&]() { + return !left->empty() && !right->empty() && !leftInfo->empty() && !rightInfo->empty(); + })) << "not all four outputs were published"; + + EXPECT_EQ(left->back().data, in.rgb.data) << "the rgb slot feeds the left topic"; + EXPECT_EQ(right->back().data, in.depth.data) << "the depth slot feeds the right topic"; + EXPECT_LT(rightInfo->back().p[3], 0.0) << "the baseline must reach the right camera info"; +} + +TEST_F(RGBDSplitStereoNamingTest, DoesNotPublishOnRgbAndDepthWhenStereoIsSet) +{ + // The two namings are exclusive: nothing must be left publishing the old names. + startSplit(/*stereo=*/true); + + std::shared_ptr> rgb = + collect("rgbd_image/rgb/image"); + std::shared_ptr> depth = + collect("rgbd_image/depth/image"); + spinFor(std::chrono::milliseconds(500)); + + EXPECT_EQ(rgb->subscription->get_publisher_count(), 0u); + EXPECT_EQ(depth->subscription->get_publisher_count(), 0u); +} + +TEST_F(RGBDSplitStereoNamingTest, KeepsRgbAndDepthByDefault) +{ + startSplit(/*stereo=*/false); + + std::shared_ptr> rgb = + collect("rgbd_image/rgb/image"); + std::shared_ptr> left = + collect("rgbd_image/left/image"); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + EXPECT_EQ(left->subscription->get_publisher_count(), 0u) + << "left/right naming must be opt-in"; +} + +TEST_F(RGBDSplitStereoNamingTest, StillPublishesADepthImageOnRightWithStereoSet) +{ + // A depth image with stereo set is a misconfiguration: the node warns (once) but + // keeps forwarding, so an existing pipeline is never silently broken. + startSplit(/*stereo=*/true); + + std::shared_ptr> right = + collect("rgbd_image/right/image"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(right->subscription)); + + // makeRGBDImage carries 16UC1 depth, not a right image. + pub->publish(makeRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !right->empty(); })) + << "the image must still be forwarded, warning or not"; + EXPECT_EQ(right->back().encoding, sensor_msgs::image_encodings::TYPE_16UC1); +} + +TEST_F(RGBDSplitStereoNamingTest, StillPublishesARightImageOnDepthWithStereoUnset) +{ + // The inverse misconfiguration, and the one this node has always allowed: a stereo + // pair with stereo left false. It warns, but the right image must still come out. + startSplit(/*stereo=*/false); + + std::shared_ptr> depth = + collect("rgbd_image/depth/image"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(depth->subscription)); + + const rtabmap_msgs::msg::RGBDImage in = makeStereoRGBDImage("camera_link", 1000.0); + pub->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !depth->empty(); })) + << "the right image must still be forwarded, warning or not"; + EXPECT_EQ(depth->back().encoding, "mono8"); + EXPECT_EQ(depth->back().data, in.depth.data); +} + +TEST_F(RGBDSplitStereoNamingTest, DoesNotWarnOnAnEmptySecondHalf) +{ + // A color-only RGBDImage leaves the depth slot empty, whose encoding is "". That + // must not be mistaken for a right image: nothing is published, nothing to warn about. + startSplit(/*stereo=*/false); + + std::shared_ptr> rgb = + collect("rgbd_image/rgb/image"); + std::shared_ptr> depth = + collect("rgbd_image/depth/image"); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher("rgbd_image", 10); + ASSERT_TRUE(waitForSubscriber(pub)); + ASSERT_TRUE(waitForPublisher(rgb->subscription)); + + rtabmap_msgs::msg::RGBDImage in = makeRGBDImage("camera_link", 1000.0); + in.depth = sensor_msgs::msg::Image(); + pub->publish(in); + ASSERT_TRUE(spinUntil([&]() { return !rgb->empty(); })); + spinFor(std::chrono::milliseconds(300)); + + EXPECT_EQ(rgb->back().encoding, "bgr8") << "the color half is unaffected"; + if(!depth->empty()) + { + EXPECT_TRUE(depth->back().data.empty()) + << "an absent depth image must not turn into a non-empty one"; + } +} + +TEST_F(RGBDSplitQosTest, QosSubAndQosPubOverrideQosPerSide) +{ + // qos says reliable, which a best-effort source could not match; qos_sub overrides + // it, while qos_pub keeps the outputs reliable for a strict consumer. + startSplit({rclcpp::Parameter("qos", 1), + rclcpp::Parameter("qos_sub", 2), + rclcpp::Parameter("qos_pub", 1)}); + + std::shared_ptr> rgb = + collect("rgbd_image/rgb/image", rclcpp::QoS(10).reliable()); + rclcpp::Publisher::SharedPtr pub = + helper()->create_publisher( + "rgbd_image", rclcpp::QoS(10).best_effort()); + ASSERT_TRUE(waitForSubscriber(pub)) << "qos_sub must win over qos on the subscription"; + ASSERT_TRUE(waitForPublisher(rgb->subscription)) << "qos_pub must keep the output reliable"; + + pub->publish(makeRGBDImage("camera_link", 1000.0)); + ASSERT_TRUE(spinUntil([&]() { return !rgb->empty(); })); + EXPECT_EQ(rgb->back().encoding, "bgr8"); +} diff --git a/tools/set_doc_distro.sh b/tools/set_doc_distro.sh new file mode 100755 index 00000000..7e9c07b9 --- /dev/null +++ b/tools/set_doc_distro.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# Rewrites the ROS distro in every docs.ros.org link of the documentation pages. +# +# The distro cannot be resolved at documentation build time: rosdoc2 has no notion of one +# and injects nothing a Markdown page could read, and docs.ros.org has no distro-agnostic +# URL for message types. So the distro is baked into the links, and this script is how it +# gets flipped on a per-distro branch. +# +# Usage: +# tools/set_doc_distro.sh jazzy [path ...] +# +# Rewrites every *.md under the given paths, defaulting to the whole repository. Note that +# it cannot tell a link meant to track the branch from one that deliberately names a +# distro -- the root README's "Humble minimum required" note, for instance -- so pass +# explicit paths when that matters. +# +# On a distro branch, treat the documentation as *derived* rather than hand-edited, and +# the merge from the development branch never conflicts: +# +# git merge ros2 +# git checkout ros2 -- '*/doc' '*/README.md' # always take the upstream pages +# tools/set_doc_distro.sh humble # then re-stamp the distro +# git commit -a +# +set -euo pipefail + +distro=${1:-} +if [ -z "$distro" ]; then + echo "usage: $(basename "$0") e.g. $(basename "$0") jazzy" >&2 + exit 1 +fi + +# An explicit list rather than a wildcard: docs.ros.org also serves /en/api/, which is the +# legacy ROS 1 message documentation and must not be rewritten into a distro. +known='humble|iron|jazzy|kilted|lyrical|rolling' + +root=$(cd "$(dirname "$0")/.." && pwd) +shift || true +paths=("$@") +[ ${#paths[@]} -eq 0 ] && paths=("$root") + +mapfile -t files < <(grep -rlE "docs\.ros\.org/en/($known)/" --include='*.md' "${paths[@]}") + +if [ ${#files[@]} -eq 0 ]; then + echo "no documentation pages with a distro link found" >&2 + exit 0 +fi + +sed -i -E "s#docs\.ros\.org/en/($known)/#docs.ros.org/en/$distro/#g" "${files[@]}" +echo "set distro to '$distro' in ${#files[@]} file(s):" +printf ' %s\n' "${files[@]#$root/}"