3 · 坐标变换与TF树
做机器人开发,绕不开坐标系。你想想看,激光雷达扫描的点是在雷达自己的坐标系里,里程计数据在底盘坐标系,而导航目标点又是在地图坐标系。这些坐标系怎么对齐?靠的就是 TF 树。说白了,TF 就是机器人的“坐标系翻译官”。
3.1 为什么机器人需要那么多坐标系?
每个传感器都有自己固定的安装位置。比如我把激光雷达装在底盘前方 10 厘米、高 20 厘米的地方。那它测到的障碍物距离,就必须先转换到底盘坐标系,才能用于导航。我在项目里见过有人直接把雷达数据当全局坐标用,结果机器人原地转圈……嗯,这就是没搞懂坐标变换的后果。
核心概念: 每个连杆(link)都有一个自己的坐标系。URDF 里通过 <origin> 标签定义子坐标系相对于父坐标系的偏移和旋转。TF 树就是这些父子关系的“族谱”。
3.2 TF 树:机器人的坐标系家谱
TF 树是一个树形结构,根节点通常是 base_link 或 odom。每个节点代表一个坐标系,边代表变换关系(平移 + 旋转)。
举个例子:
base_link→laser_frame:激光雷达相对底盘的位姿base_link→camera_frame:相机相对底盘的位姿odom→base_link:里程计估计的底盘位姿
我习惯在调试时用 tf2_tools 里的 view_frames 工具,直接生成 PDF 图,一眼就能看出哪个坐标系断开了。
▲ 典型的机器人 TF 树结构:map → odom → base_link → 传感器/轮子
3.3 在 URDF 中定义父子坐标系
URDF 里用 <joint> 来连接两个 link,并指定它们的相对位姿。来看一个实际例子:
<robot name="my_robot">
<link name="base_link">
<visual>
<geometry><box size="0.4 0.3 0.1"/></geometry>
</visual>
</link>
<link name="laser_frame">
<visual>
<geometry><cylinder radius="0.05" length="0.05"/></geometry>
</visual>
</link>
<joint name="laser_joint" type="fixed">
<parent link="base_link"/>
<child link="laser_frame"/>
<origin xyz="0.1 0 0.2" rpy="0 0 0"/>
</joint>
</robot>
这里 laser_frame 是子坐标系,base_link 是父坐标系。origin xyz="0.1 0 0.2" 表示激光雷达安装在底盘前方 0.1 米,上方 0.2 米。rpy 是旋转角,这里没旋转。
💡 我的习惯: 所有传感器都单独用一个 link 表示,哪怕它只是一个虚拟坐标系。这样 TF 树更清晰,后期换传感器位置只需要改 joint 的 origin,不用动其他代码。
3.4 静态 TF 与动态 TF
URDF 里定义的 joint 都是静态变换——机器人造好了,激光雷达和底盘的相对位置就不变了。但像 odom→base_link 这种是动态变换,随着机器人移动不断更新,通常由里程计或定位模块发布。
| 变换类型 | 发布方式 | 示例 |
|---|---|---|
| 静态 TF | URDF 或 static_transform_publisher | base_link → laser_frame |
| 动态 TF | 里程计 / 定位节点实时发布 | odom → base_link |
| 动态 TF | map → odom(AMCL 或 SLAM) | map → odom |
⚠️ 我曾经踩过的坑: 有一次我忘了给轮子单独建坐标系,直接把轮子视觉放在 base_link 下。结果做里程计时,轮子速度没法正确转换到 base_link,导航直接崩了。记住:每个运动关节都要有独立的坐标系。
3.5 检查 TF 树的实用命令
调车的时候,我几乎每五分钟就要用一次这些命令:
ros2 run tf2_tools view_frames—— 生成 TF 树 PDFros2 run tf2_ros tf2_echo base_link laser_frame—— 查看两个坐标系间的实时变换ros2 run tf2_ros tf2_monitor—— 监控所有 TF 的发布频率和延迟
你想想看,如果 tf2_echo 输出一直不变,那肯定是静态 TF 没发布,或者 URDF 没加载成功。
核心总结: TF 树是机器人的坐标系骨架。URDF 定义静态父子关系,动态变换由导航或里程计节点填充。调试时先看 view_frames,确认树是完整的,再查具体变换值。