3、坐标系与TF变换:机器人坐标系定义(base_link、odom、map)、TF树的理解与发布、静态与动态TF变换

坐标系这东西,说白了就是给机器人一个「空间定位」的基准。没有坐标系,你让机器人往左走,它都不知道往哪边才是左。我刚开始做机器人那会儿,觉得坐标系就是个数学概念,随便搞搞就行。结果有一次,机器人明明看到障碍物在左边,却一头撞上去了——后来才发现,是激光雷达的坐标系和底盘的坐标系没对齐。

嗯,从那以后,我对坐标系和TF变换就再也不敢马虎了。

3.1 机器人坐标系定义

在ROS2的导航体系中,有三个核心坐标系你必须搞明白:base_link、odom、map。它们各自承担不同的角色,共同构成了机器人的空间认知框架。

3.1.1 base_link:机器人的身体坐标系

base_link 是附着在机器人本体上的坐标系。你可以把它想象成机器人的「肚脐眼」——通常位于机器人底盘的几何中心,X轴指向正前方,Y轴指向左侧,Z轴指向上方。

我个人习惯把 base_link 作为所有传感器坐标系的父节点。激光雷达、IMU、轮式里程计,它们的坐标系最终都要通过TF变换关联到 base_link 上。

关键点:base_link 是机器人自身的参考系,它随着机器人移动而移动。所有与机器人本体相关的数据(如速度、加速度、激光扫描)都在这个坐标系下表达。

3.1.2 odom:里程计坐标系

odom 坐标系是机器人通过里程计(轮式编码器、IMU等)推算出来的位置参考系。它的特点是:连续、平滑、无跳变。

你想想看,轮子转了多少圈,IMU积分了多少角度,这些数据累积起来,就形成了 odom 坐标系下的位姿估计。但问题是,里程计有累积误差——走久了,位置就会漂移。

我在项目中遇到过这样的情况:机器人沿着走廊走了50米,里程计显示走了50.3米,误差0.3米。这在短距离内可以接受,但长距离导航就不行了。

3.1.3 map:全局地图坐标系

map 坐标系是全局固定的参考系,通常与建图时的原点对齐。它不随时间漂移,是导航决策的最终依据。

说白了,map 坐标系就是「真实世界」的坐标。激光SLAM算法会不断修正 map 到 odom 的变换,消除里程计的累积误差。

坐标系 特点 漂移 用途
base_link 随机器人移动 无(本体参考) 传感器数据、控制指令
odom 连续平滑 有(累积误差) 局部规划、里程计融合
map 全局固定 无(参考) 全局路径规划、定位

3.2 TF树的理解与发布

TF(Transform)是ROS2中管理坐标系变换的核心机制。它本质上是一棵树,每个节点代表一个坐标系,每条边代表两个坐标系之间的变换关系。

为什么要用树结构?因为坐标系之间的关系是层级化的。比如:

  • map → odom:由SLAM或定位模块发布
  • odom → base_link:由里程计发布
  • base_link → laser_frame:由机器人URDF模型定义

TF树必须是一棵树,不能有环。如果出现环,TF系统会报错,机器人就不知道该怎么变换了。

注意:TF树中每个坐标系只能有一个父节点,但可以有多个子节点。这是ROS2的硬性约束。

3.2.1 发布TF变换

在ROS2中,发布TF变换有两种方式:

  1. 使用 tf2_ros::TransformBroadcaster:手动发布变换
  2. 使用 robot_state_publisher:通过URDF模型自动发布

下面是一个手动发布 odom 到 base_link 变换的示例:

// C++ 示例:发布 odom → base_link 变换
#include <tf2_ros/transform_broadcaster.h>
#include <geometry_msgs/msg/transform_stamped.h>

tf2_ros::TransformBroadcaster broadcaster(this);
geometry_msgs::msg::TransformStamped transform;

transform.header.stamp = this->get_clock()->now();
transform.header.frame_id = "odom";
transform.child_frame_id = "base_link";

// 设置平移(x, y, z)
transform.transform.translation.x = 1.0;
transform.transform.translation.y = 0.5;
transform.transform.translation.z = 0.0;

// 设置旋转(四元数)
transform.transform.rotation.x = 0.0;
transform.transform.rotation.y = 0.0;
transform.transform.rotation.z = 0.0;
transform.transform.rotation.w = 1.0;

broadcaster.sendTransform(transform);

这段代码看起来简单,但有个坑:时间戳。如果你发布的时间戳比当前时间早太多,TF系统会认为这个变换已经过期,直接丢弃。我曾经因为这个原因,调试了一整个下午才发现问题。

3.3 静态与动态TF变换

TF变换按更新频率分为两类:静态变换和动态变换。

3.3.1 静态TF变换

静态变换是指两个坐标系之间的相对关系固定不变。比如激光雷达安装在机器人底盘上,它们之间的位置关系是固定的。

在ROS2中,静态变换可以通过 tf2_ros::StaticTransformBroadcaster 发布,或者直接在启动文件中声明:

# 启动文件中声明静态变换
node:
  package: tf2_ros
  executable: static_transform_publisher
  arguments:
    - "--x"
    - "0.2"
    - "--y"
    - "0.0"
    - "--z"
    - "0.1"
    - "--yaw"
    - "0.0"
    - "--pitch"
    - "0.0"
    - "--roll"
    - "0.0"
    - "--frame-id"
    - "base_link"
    - "--child-frame-id"
    - "laser_frame"

静态变换只需要发布一次,TF系统会自动缓存。我个人习惯把所有传感器到 base_link 的变换都做成静态的,省心。

3.3.2 动态TF变换

动态变换是随时间变化的。最常见的例子就是 odom 到 base_link 的变换——机器人每走一步,这个变换都在变。

动态变换的发布频率通常与里程计更新频率一致,一般是50Hz到100Hz。频率太低,导航算法会跟不上;频率太高,CPU吃不消。

经验之谈:动态变换的发布频率建议与里程计数据频率一致。如果里程计是50Hz,TF就50Hz。不要为了省CPU而降低频率,否则导航效果会大打折扣。

3.4 TF树结构图

下面这张图展示了典型的机器人TF树结构。你可以看到,map 在最顶层,下面是 odom,再下面是 base_link,然后分支出各个传感器。

机器人TF树结构图 机器人TF树结构图 map odom base_link laser_frame imu_frame camera_frame 说明:map → odom 由SLAM发布(动态),odom → base_link 由里程计发布(动态),base_link → 传感器由URDF定义(静态)

3.5 避坑指南

坐标系和TF变换是导航系统中最容易出问题的地方。我总结几个常见坑:

  • TF树不完整:缺少某个坐标系变换,导致导航节点找不到路径。用 tf2_tools view_frames.py 查看TF树,一目了然。
  • 时间戳不一致:传感器数据的时间戳和TF变换的时间戳对不上,导致数据被丢弃。确保所有节点使用同一个时钟源。
  • 坐标系命名混乱:有人把激光雷达坐标系叫 laser,有人叫 lidar,还有人叫 scan_frame。统一命名规范,避免混淆。

曾经踩过的坑:有一次我在调试导航时,机器人原地打转,就是不往前走。查了半天,发现是 base_link 和 odom 的变换方向反了——里程计说往东走,TF却说往西走。嗯,从那以后,我每次发布TF变换都会先用手动模式验证一下方向。

3.6 本章小结

坐标系和TF变换是机器人导航的基石。你需要记住三点:

  1. 三个核心坐标系:map(全局固定)、odom(连续平滑)、base_link(随机器人移动)
  2. TF树是层级结构:每个坐标系只有一个父节点,不能有环
  3. 静态变换 vs 动态变换:固定关系用静态,变化关系用动态

把这些搞明白了,后面的导航实战才能顺利推进。