* rtabmap_odom tests and doc * opengv note * added ci checks or humble-latest flaky dep cmake errors * Added real data tests for rgbd_odom and stereo_odom * added real data for icp_odometry's deskewing test * fixing json cmake error on lyrical/rolling * test 2d icp odom deskewing branch * first review of existing OdometryROS tests * testing with imu used as guess * tested imu arrivals sync * Fixed odom reset on right pose when guess frame id is used * fixing header errors in ci >=lyrical * Added support for input rgbd_image topic with features for odom, added multicam rgbd_odometry test * Added stereo odom support for features-only frames. Added multicam stereo tests. * forcing latest rtabmap version * updated OdometryROS API * ci: dont build non-latest docker in pull requests * splitting docker jobs * doc edit * Making publish_null_when_lost:=false continous when guess is provided (using guess covariance when we cannot register yet) * updated stereo doc * ficing rolling ci (rviz Ogre header) * Added test coverage of alll rgbd_image callbacks * fixing rolling ci * making docker ci build/run the tests on pull requests * fixing ros2 ci testing * improved sync callback coverage * improving stereo_odometry test coverage * improved icp_odometry test coverage * lyrical voxel_grid ptr error * make multicam tests working as well without opengv * removing deps of missing packages on rolling * PCL empty cloud conversion compiler errors fix * fixing icp_odometry test failure on ci witohut libpointmatcher * fixing nav2 costmap plugin build on lyrical * joining thread when exiting * updating icp test to work the same on pcl 1.15 (lyrical) * Fix parallel tests seg fault --------- Co-authored-by: mathieu86 <[email protected]>
27 KiB
rtabmap_odom
Odometry for RTAB-Map: where the robot is relative to a local fixed frame, estimated from its own sensors — a pose that moves continuously and never jumps, but drifts over time.
SLAM needs a pose for every measurement it maps. These nodes produce one by registering each new frame against the last — visually from an RGB-D or stereo camera, or geometrically from a lidar — and integrating the result into a nav_msgs/msg/Odometry and a TF.
Contents
Nodes
| Node | Description |
|---|---|
| rgbd_odometry | Visual odometry from a color image and a depth image registered to it. |
| stereo_odometry | Visual odometry from a stereo pair. |
| icp_odometry | Geometric odometry from a 2D or 3D lidar. |
Every node is a composable node as well as a standalone executable.
Choosing a sensor modality for the environment
Which to use is a question about the environment, not about which sensor is better. A camera tracks visual texture; a lidar tracks geometry. Each fails where its own cue is missing, and the two failures do not overlap much.
| Environment | Use | Why |
|---|---|---|
| Visually textured and well lit — offices, cluttered rooms, daylight outdoors | Camera | Plenty of features to match, and appearance gives loop closure for free. |
| Textureless but geometrically rich — bare corridors with doorways and furniture, warehouse aisles | Lidar | Blank walls give a camera nothing; the shape of the space still constrains ICP. |
| Dark, or lighting that changes abruptly | Lidar | A camera is simply blind. Lidar does not care. |
| Geometrically plain but visually rich — a large open hall with a patterned floor, textured flat walls | Camera | Degenerate geometry defeats ICP here, while the texture is exactly what a camera needs. |
| Both plain and textureless — an empty warehouse, a long featureless tunnel | Wheel odometry, with either as a corrector | Neither cue is present. This is the case where wheel odometry carries the pose. |
| Repetitive and self-similar — tiled floors, rows of racking, a long colonnade | Wheel odometry as the guess, with either on top | Both cues are present but ambiguous: a camera matches the wrong copy of a feature, a lidar the wrong bay of shelving. See Repetitive patterns. |
| Outdoors at range | Stereo camera or 3D lidar | RGB-D depth stops working outdoors; both of these keep going. |
Do not underestimate wheel odometry. On a wheeled robot it is locally excellent and only drifts over distance — the opposite failure from both of the above, which are locally noisy but not systematically biased. It is also the only one of the three that keeps working when the environment offers no cue at all — and, because it is indifferent to what the scene looks like, the only one that is not fooled when the scene repeats itself.
Fuse the wheels with an IMU before feeding them in. robot_localization is the standard way: its EKF combines wheel odometry with IMU orientation and angular rates into one filtered odom topic, which is a markedly better guess than the wheels alone. FusionCore is another EKF that does the same job. The IMU fixes exactly what encoders are worst at — yaw through a turn, and wheel slip, which encoders report as motion that never happened. Where the camera or lidar fails outright, that filtered estimate is what carries the robot through, and a pipeline built this way degrades instead of breaking.
Which is why the robust arrangement is rarely one of them alone: feed wheel odometry in as guess_frame_id and the registration starts near the answer every frame. That covers the camera's fast-motion and blank-wall failures and the lidar's degenerate-corridor failure, while the camera or lidar in turn corrects the wheels' drift. See Feeding in an external guess.
For 2D indoor odometry a lidar usually costs less computation. Registering a few hundred scan points is far less CPU than detecting, describing and matching visual features on every frame, and it needs no GPU — which is what decides whether odometry keeps up on the small onboard computers these robots carry.
With both a camera and a lidar, the usual arrangement is icp_odometry for the pose and the camera for appearance — see Combining a camera and a lidar.
Library
The package installs a C++ library, documented in the C++ API reference generated from the headers.
OdometryROS is the base class all three nodes derive from, and it is where most of this package's behaviour actually lives. It owns the RTAB-Map Odometry object, the pose integration, the TF broadcast, the IMU intake, the services and the diagnostics. Each node subclasses it to do one thing: turn its own topics into a rtabmap::SensorData and hand it over. That is why the three nodes share nearly all of their parameters and publish exactly the same topics.
It also runs the registration on its own thread. A frame arriving while the previous one is still being processed does not block the subscription callback; see Update rates and dropped frames.
Conventions
These apply to all three nodes.
Frames and TF
| Parameter | Type | Default | Description |
|---|---|---|---|
frame_id |
string |
"base_link" |
The robot frame being tracked. The pose published is this frame's, not the sensor's — the sensor-to-robot transform is read from TF. |
odom_frame_id |
string |
"odom" |
The fixed frame the pose is expressed in. |
publish_tf |
bool |
true |
Broadcast the pose on TF. Turn this off if something else already publishes that transform, or the two fight and TF alternates between them. What exactly is broadcast depends on guess_frame_id: without it, odom_frame_id → frame_id; with it, a correction odom_frame_id → guess_frame_id (why). |
wait_for_transform |
double |
0.1 |
Seconds to wait for a needed transform before giving up on the frame. |
initial_pose |
string |
"" |
Starting pose, "x y z roll pitch yaw". Also settable at runtime through reset_odom_to_pose. |
ground_truth_frame_id |
string |
"" |
When set, the pose is taken from this TF instead of being computed — for replaying a dataset with a known trajectory. |
ground_truth_base_frame_id |
string |
value of frame_id |
The robot frame within the ground truth TF tree. |
guess_frame_id |
string |
"" |
A frame carrying another odometry source, used as the initial guess for each registration. Documented with its companions under Feeding in an external guess -- the highest-value parameter here for a wheeled robot. |
The sensor must be connected to frame_id in TF before the first frame arrives, or that frame is dropped with a warning. A static publisher is the usual answer.
RTAB-Map's own parameters
Everything in RTAB-Map's odometry parameter set is exposed as a ROS parameter under its RTAB-Map name, so tuning is done directly:
ros2 run rtabmap_odom rgbd_odometry --ros-args \
-p "Odom/Strategy:='1'" \
-p "Vis/MinInliers:='15'" \
-p "Odom/ResetCountdown:='1'"
Note the quoting. Every RTAB-Map parameter is declared as a string, whatever it looks like, because that is how RTAB-Map's own parameter map stores them. Writing -p Odom/Strategy:=1 makes ROS infer an integer, and the node throws on startup rather than starting with the wrong value:
parameter 'Odom/Strategy' has invalid type: Wrong parameter type,
parameter {Odom/Strategy} is of type {string}, setting it to {integer} is not allowed.
The inner quotes are what keeps it a string. Shell quotes alone do not help, since the value is parsed as YAML after the shell is done with it. In a launch file the same rule reads naturally: {'Odom/Strategy': '1'}.
This applies only to RTAB-Map's own parameters. The nodes' ROS parameters -- frame_id, publish_tf, scan_voxel_size, approx_sync -- are declared with their real types and take plain values.
Which parameters exist depends on the node: each declares the set matching its sensor, so Vis/* appears on the visual nodes and Icp/* only on icp_odometry. ros2 param list on a running node is the authoritative list; the meaning of each is in RTAB-Map's parameter reference.
The two worth knowing before anything else:
Odom/Strategyselects the registration algorithm —0frame-to-map (default, more accurate),1frame-to-frame (cheaper), and others for the external VO libraries RTAB-Map can be built against.Odom/ResetCountdownautomatically resets odometry after this many consecutive lost frames instead of staying lost forever.0disables it, which is the default.
config_path loads the same parameters from an INI file; only the odometry ones are taken from it.
Feeding in an external guess
Registration works far better when it starts near the answer. Two ways to supply one:
| Parameter | Type | Default | Description |
|---|---|---|---|
guess_frame_id |
string |
"" |
A TF frame carrying another odometry source — wheels, IMU-integrated, a base driver. Its motion between frames becomes the initial guess. |
guess_min_translation |
double |
0.0 |
Skip frames whose guessed motion is below this, in meters. 0 disables. |
guess_min_rotation |
double |
0.0 |
Same, in radians. |
guess_min_time |
double |
0.0 |
Same, in seconds. |
guess_linear_variance |
double |
0.001 |
Covariance of the published pose when the guess is used directly. |
guess_angular_variance |
double |
0.001 |
Same, rotational. |
guess_frame_id is the single biggest improvement available to a wheeled robot. Wheel odometry is locally excellent and globally hopeless; visual and ICP registration is the reverse. Giving the registration a wheel-odometry guess makes it converge more often, faster, and survive the frames where the camera sees nothing.
The guess_min_* parameters additionally suppress processing while the robot is stationary, which stops a static scene from accumulating drift and saves the CPU.
It also keeps TF alive through a failure
Setting guess_frame_id also changes how the pose is broadcast. This is worth understanding before odometry fails on a real robot, because it decides what the rest of the system sees while it is lost.
With a guess frame configured, the node no longer publishes odom_frame_id → frame_id directly. It publishes a correction instead, odom_frame_id → guess_frame_id.
So the guess source keeps the conventional odom frame -- whatever produces it, the robot's driver or robot_localization -- and this node takes a different name for its own odom_frame_id. The examples in this repository use vo for the visual nodes and icp_odom for the lidar one:
flowchart TD
ODOM(["/vo<br><i>odom_frame_id</i>"])
GUESS(["/odom<br><i>guess_frame_id</i>"])
BASE(["/base_link<br><i>frame_id</i>"])
SENSOR(["/camera or /lidar<br><i>the sensor's header.frame_id</i>"])
ODOM -->|correction, this node<br>e.g. ~10 Hz, ~50 ms delay| GUESS
GUESS -->|robot driver or robot_localization<br>e.g. ~50 Hz, ~1 ms delay| BASE
BASE -->|static| SENSOR
The rates and delays above are examples only — yours depend on the sensor, the base driver and the computer.
That chain keeps being published while registration is lost. The correction freezes at the last successfully computed pose composed with the motion the guess has accumulated since, so base_link keeps moving in TF at the guess source's rate, driven entirely by the guess. Nothing downstream stalls or jumps; the pose just accumulates that source's drift until registration recovers. Without guess_frame_id there is no correction to publish and no TF at all is broadcast while lost, which is what breaks the tree.
Pair it with Odom/ResetCountdown and the recovery is complete. Take a robot turning to face a white wall: visual odometry loses tracking, TF keeps flowing from the wheels, and after the configured number of failed frames the odometry resets — not to where it was when it got lost, but to last computed pose × guess motion, which is where the wheels say the robot has got to in the meantime. Registration restarts from there and the trajectory carries on with only the drift the wheels accumulated.
What it resets to depends on what is available, in this order:
- A guess — resets to the last pose composed with the guess motion, as above.
- No guess, but
odom_frame_id→frame_idexists in TF at the sensor frame's stamp — resets to that pose. This is thepublish_tf:=falsearrangement: this node publishes only its odometry topic,robot_localizationfuses that topic with the wheels and the IMU, and the filter owns the transform. The reset therefore lands on the filter's current estimate — the odometry gets restarted from where the fused solution says the robot is, having contributed to that solution itself while it was working.publish_tfhas to be off for this to mean anything, andpublish_null_when_lostshould be off too: the null pose is a signal for consumers that read it as one, and a filter fusing this topic is not — it would be handed an invalid pose to fuse. With it off the node simply stops publishing while lost, and the filter carries on from its other inputs until registration recovers. - Neither — resets to the last computed pose, so the robot resumes believing it never moved while lost.
After an automatic reset the countdown is left armed, so if odometry still cannot initialize on the next frames it keeps re-resetting to the latest guess rather than getting stuck.
IMU
| Parameter | Type | Default | Description |
|---|---|---|---|
wait_imu_to_init |
bool |
false |
Hold off until an IMU message has arrived, so gravity is known from the first frame. |
imu_queue_size |
int |
200 |
Depth of the IMU buffer. IMUs run far faster than cameras; this is why it is large. |
qos_imu |
int |
value of qos |
Reliability of the imu subscription. |
always_check_imu_tf |
bool |
false |
Re-read the IMU-to-robot transform every message rather than caching it. |
The imu topic is optional on all three nodes. Supplying it lets odometry know which way is down, which constrains roll and pitch — worth doing on any robot that has an IMU, and close to mandatory for a handheld or aerial one.
With no guess_frame_id, the IMU also supplies the rotation half of each frame's guess. The rotation measured between the previous frame and this one becomes the guess's orientation, leaving the translation to the motion model. That is often the difference between tracking a fast turn and losing it, since rotation is what breaks feature matching first. An external guess takes precedence when there is one: guess_frame_id is used whole, and the IMU is not consulted for the guess at all.
Update rates and dropped frames
Registration runs on its own thread, so a slow frame does not block the subscription. What happens to the frames arriving meanwhile is a choice:
| Parameter | Type | Default | Description |
|---|---|---|---|
always_process_most_recent_frame |
bool |
true |
Drop frames that arrive while registration is still running and take the newest. false registers every frame in order, on the subscription thread. |
expected_update_rate |
double |
0.0 |
The rate frames are expected at, in Hz. Used only when max_update_rate is unset, and it is a ceiling: a frame arriving sooner than 1/expected_update_rate after the last one is skipped, with a warning that the input is faster than expected. 0 disables. |
max_update_rate |
double |
0.0 |
Throttle registration to at most this rate, skipping frames silently. Takes precedence over expected_update_rate. 0 disables. |
min_update_rate |
double |
0.0 |
Treat odometry as lost and reset it when more than 1/min_update_rate passes between updates — the motion assumption no longer holds across a gap that long. 0 disables. |
always_process_most_recent_frame already bounds the delay. A frame arriving while registration is still running is dropped on the spot rather than queued, so the worker always picks up the newest frame and the published pose is at most one registration behind the sensor. No backlog ever forms. /diagnostics reports how many frames went this way.
That is why max_update_rate is about CPU, not latency: given the skipping above, the worst-case delay is roughly the same whether it is set or not. What it changes is how many frames get registered at all. Set it to give the rest of the robot its cores back — not to make the pose fresher, which it will not do.
Setting always_process_most_recent_frame:=false is the opposite trade: every frame is registered, in order, on the subscription thread. That is what you want when replaying a bag, where dropping frames loses data you meant to process.
Lost frames, resets and new maps
A frame that cannot be registered is lost: the node publishes an all-zero pose with 9999 down the diagonal of both covariance matrices, which says there is no pose here to use.
The first frame after a reset — from reset_odom, reset_odom_to_pose or Odom/ResetCountdown — carries the same 9999 for a different reason. It is an initialization rather than a registration: nothing to measure against, no velocity to carry over. Its pose is real and meant to be used; what the covariance says is that it does not continue the last valid one.
| Parameter | Type | Default | Description |
|---|---|---|---|
publish_null_when_lost |
bool |
true |
Publish a null pose, with 9999 on the covariance diagonals, when a frame cannot be registered. false publishes nothing. |
Leave it on, unless a filter is consuming this topic. A consumer that sees the null message knows odometry is lost; one that sees nothing cannot tell that apart from a node that died or a topic that was never connected. rtabmap relies on it to know the frame should not be mapped.
rtabmap reads an identity pose, or both covariances at 9999, as a discontinuity, and starts a new map rather than deforming the graph across a jump the robot never made:
Odometry is reset (identity pose or high variance detected). Increment map id!
While it is lost, publish_null_when_lost:=true publishes a null pose and no velocity for every frame, both marked 9999. With :=false it publishes nothing — except that a guess frame keeps it going: every frame that re-initialises the map, which Odom/ResetCountdown makes frequent, is published with the guess's pose and confidence, so the topic has no gap for as long as the guess is there. What differs between configurations is the first frame after the reset, and where it restarts from:
| First frame after the reset | Second frame | TF while lost | rtabmap |
|
|---|---|---|---|---|
publish_null_when_lost:=true (default), with or without a guess |
recovered pose, 9999 on both pose and velocity |
registered, from the recovered pose | unbroken with a guess, absent without one | new map |
publish_null_when_lost:=false with guess_frame_id |
recovered pose and the guess's velocity, both with the guess's covariance | registered, from the recovered pose | unbroken | one session |
publish_null_when_lost:=false, publish_tf:=false, another node publishing odom → base_link |
not published | registered, from the recovered pose | unbroken, published by the other node | one session |
publish_null_when_lost:=false with neither |
not published | registered, from the pose held before the loss | absent until it recovers | one session, across the gap |
Registered is the ordinary case: a pose and a velocity measured against the previous valid frame, with the covariance the registration computed.
The two middle rows are the ones to build on. Either an external source is named through guess_frame_id, and the restarting frame is published as a continuation of the trajectory — the poses and the covariances staying continuous for as long as that guess is published — or a filter such as robot_localization owns odom → base_link, and the reset adopts whatever pose it holds.
The last row is a trap. With nothing to say where the robot went while the odometry was lost, the reset resumes at the pose from before it, that motion is dropped from the trajectory, and since no 9999 ever reaches rtabmap the map is deformed across the gap rather than split at it. The node reports that combination as an error at startup.
For finer control, put an intermediate node between this one and rtabmap and let it set the covariances itself. It decides what counts as a discontinuity, instead of that being inferred from the reset alone — starting a new map when the guess frame has gone quiet, say, and the newly computed pose may be wrong even though registration reported success.
Recovering from lost is what Odom/ResetCountdown is for, or the reset_odom service. Combined with guess_frame_id it also keeps the TF tree intact throughout — see It also keeps TF alive through a failure.
Services
| Service | Type | Description |
|---|---|---|
reset_odom |
std_srvs/srv/Empty |
Drop the internal local map and restart the pose — at the identity, or, with guess_frame_id configured, at whatever pose the guess frame currently holds, so that odometry restarts where the other source says the robot is. |
reset_odom_to_pose |
rtabmap_msgs/srv/ResetPose |
Reset to a given x y z roll pitch yaw. |
pause_odom |
std_srvs/srv/Empty |
Stop processing incoming frames. |
resume_odom |
std_srvs/srv/Empty |
Resume. |
log_debug, log_info, log_warning, log_error |
std_srvs/srv/Empty |
Change RTAB-Map's own log level at runtime. |
Published topics
Common to all three nodes. Every one of them, odom included, is published only when something is subscribed -- the work of building each message is skipped otherwise. The TF broadcast is not gated this way and happens whenever publish_tf is on -- except while registration is lost with no guess frame configured, when there is nothing to broadcast.
| Topic | Type | Description |
|---|---|---|
odom |
nav_msgs/msg/Odometry |
The pose and velocity. Covariance is meaningful: it grows with registration uncertainty, and is 9999 on the diagonal when lost. |
odom_info |
rtabmap_msgs/msg/OdomInfo |
Everything about how the frame was registered — inlier count, matches, features, timings. The first thing to look at when odometry misbehaves. |
odom_info_lite |
rtabmap_msgs/msg/OdomInfo |
The same without the per-feature arrays, for logging or a slow link. |
odom_local_map |
sensor_msgs/msg/PointCloud2 |
The feature map the current frame was registered against. Visual paths only — built from the frame's visual words, so icp_odometry never fills it. |
odom_local_scan_map |
sensor_msgs/msg/PointCloud2 |
The scan map, the ICP path's equivalent of odom_local_map. |
odom_last_frame |
sensor_msgs/msg/PointCloud2 |
The current frame's features, in the odom frame — not its scan or its pixels. Visual paths only, for the same reason as odom_local_map; for the filtered scan see odom_sensor_data/*. |
odom_rgbd_image |
rtabmap_msgs/msg/RGBDImage |
The frame as odometry processed it, not the input as it arrived. See Outputting filtered scans and features. |
odom_sensor_data/raw, /features, /compressed |
rtabmap_msgs/msg/SensorData |
The same frame as SensorData. /features strips the images and scan and keeps only the extracted features; /compressed carries JPEG/PNG images instead of raw. |
Outputting filtered scans and features
odom_rgbd_image and odom_sensor_data/* republish the frame after odometry has worked on it, which is the point of them — they are what odometry actually registered, not a copy of the input:
- Features are included. Registration writes the keypoints, their 3D positions and their descriptors back into the frame, so these topics carry them.
odom_sensor_data/featuresis that alone, with the images and scan removed. - The scan is the filtered one.
icp_odometrybuilds the frame after deskewing, voxelization, range filtering and normal estimation, so what comes out here is the decimated cloud ICP saw — not the raw sweep the lidar published. Subscribe to the driver's topic if you want the original. - Images are converted.
rgbd_odometryhands over grayscale unlesskeep_coloris set, so that is what these carry too.
Diagnostics
All three publish to /diagnostics: the input rate, the output rate, and how many frames were processed versus dropped. A healthy input rate with a low output rate means frames are arriving but not registering — check odom_info before touching anything else.
License
BSD-3-Clause. See the repository root.