Skip to main content

8. tf2 坐标变换管理

运动学函数算出的末端位置只是一个数字,没有人知道它属于哪个坐标系。这一讲用 tf2 统一管理所有坐标系变换,让整个系统共享同一棵坐标系树。


前言:坐标系还散落在代码里

上一讲我们的系统已经能一键启动了。但有一个问题:

运动学函数算出的末端位置 (x, y) 只是一个数字,没有人知道它是在哪个坐标系下的。相机节点、规划节点、可视化节点各自维护自己的坐标系,互相不认识。

tf2 就是解决这个问题的:它是 ROS2 里统一管理所有坐标系变换关系的基础设施。

没有 tf2有 tf2
每个节点自己算坐标变换所有变换统一发布到 tf2
坐标系命名各自为政全局唯一的坐标系树
时间同步靠自己处理tf2 内置时间戳管理
无法可视化坐标系RViz2 直接显示 tf2 树

8.1 tf2 的核心概念

8.1.1 坐标系树

tf2 管理的是一棵有向树,每条边表示"子坐标系相对父坐标系的变换":

world
└── base_link
└── link1 ← 由 θ1 决定
└── link2 ← 由 θ2 决定
└── end_effector

任意两个坐标系之间的变换,tf2 都能自动沿树路径计算出来。

8.1.2 静态变换 vs 动态变换

类型适用场景广播器
静态变换固定不变,如相机安装位置StaticTransformBroadcaster
动态变换随时间变化,如关节转动TransformBroadcaster

两连杆机械臂里:

  • world → base_link:静态(机械臂固定在桌上)
  • base_link → link1:动态(随 θ1 变化)
  • link1 → link2:动态(随 θ2 变化)
  • link2 → end_effector:静态(末端固定偏移)

8.1.3 变换的数学表示

tf2 内部用四元数 + 平移向量表示变换,等价于第 2 讲的齐次变换矩阵:

TransformStamped
├── header.stamp # 时间戳
├── header.frame_id # 父坐标系名称
├── child_frame_id # 子坐标系名称
└── transform
├── translation # 平移 (x, y, z)
└── rotation # 旋转(四元数 x, y, z, w)

2D 平面旋转 θ 对应的四元数:(x=0, y=0, z=sin(θ/2), w=cos(θ/2))


8.2 广播静态变换

8.2.1 用命令行广播(最快验证方式)

# 广播 world → base_link 的静态变换(无旋转,无平移)
ros2 run tf2_ros static_transform_publisher \
--x 0 --y 0 --z 0 \
--roll 0 --pitch 0 --yaw 0 \
--frame-id world \
--child-frame-id base_link

8.2.2 在节点中广播静态变换

vim $ROS_WS/src/my_robot_basics/my_robot_basics/tf_broadcaster_node.py
# my_robot_basics/tf_broadcaster_node.py
# 运行方式:ros2 run my_robot_basics tf_broadcaster_node
#
# ============================================================
# 节点目标:订阅 /joint_states,把关节角转换为坐标系变换并广播
# 到 tf2,让整个系统共享机械臂各连杆的坐标系关系
# 执行逻辑:
# 1. __init__ 创建动态/静态广播器,广播固定变换(world→base_link)
# 2. 订阅 /joint_states,收到关节角后触发 joint_state_callback
# 3. callback 里根据 θ1、θ2 构造 TransformStamped 消息
# 4. sendTransform() 把变换发布到 /tf Topic
# 5. RViz2、tf_listener 等节点从 /tf 读取并使用这些变换
# 注意:本节点是第 9 讲 robot_state_publisher 的手动实现版本
# 理解这里的逻辑,才能明白 RSP 背后在做什么
# ============================================================

import math
import rclpy
from rclpy.node import Node
from geometry_msgs.msg import TransformStamped
# TransformBroadcaster:发布动态变换到 /tf Topic(随时间变化的坐标系关系)
# StaticTransformBroadcaster:发布静态变换到 /tf_static Topic(固定不变的关系)
# 两者的区别:静态变换只需广播一次,会被持久缓存;动态变换需要持续广播
from tf2_ros import TransformBroadcaster, StaticTransformBroadcaster
from sensor_msgs.msg import JointState


class TFBroadcasterNode(Node):
def __init__(self):
super().__init__('tf_broadcaster_node')

self.declare_parameter('l1', 1.0)
self.declare_parameter('l2', 0.8)
self.L1 = self.get_parameter('l1').value
self.L2 = self.get_parameter('l2').value

# 动态变换广播器:用于随关节角变化的坐标系(link1、link2)
self.tf_broadcaster = TransformBroadcaster(self)

# 静态变换广播器:用于固定不变的坐标系(world→base_link)
# 静态变换只需广播一次,tf2 会永久缓存它
self.static_broadcaster = StaticTransformBroadcaster(self)

# 立即广播静态变换(节点启动时执行一次即可)
self._broadcast_static_transforms()

# 订阅关节角,每次收到新关节角就更新动态变换
# [注意] 订阅 '/kinematics/joint_states' 而不是 '/joint_states',与 kinematics_publisher 保持一致
self.subscription = self.create_subscription(
JointState,
'/kinematics/joint_states',
self.joint_state_callback,
10,
)

self.get_logger().info('TF Broadcaster 启动,等待关节角...')

def _broadcast_static_transforms(self):
"""广播固定不变的坐标系关系:world → base_link"""
t = TransformStamped()
t.header.stamp = self.get_clock().now().to_msg()
t.header.frame_id = 'world' # 父坐标系
t.child_frame_id = 'base_link' # 子坐标系
t.transform.translation.x = 0.0
t.transform.translation.y = 0.0
t.transform.translation.z = 0.0
t.transform.rotation.x = 0.0
t.transform.rotation.y = 0.0
t.transform.rotation.z = 0.0
t.transform.rotation.w = 1.0 # 单位四元数(x=0,y=0,z=0,w=1)表示无旋转
self.static_broadcaster.sendTransform(t)

def joint_state_callback(self, msg):
if len(msg.position) < 2:
return

theta1 = msg.position[0] # 弧度,joint1 的角度
theta2 = msg.position[1] # 弧度,joint2 的角度
now = self.get_clock().now().to_msg()

# 广播 base_link → link1(由 θ1 决定旋转)
# 平面绕 z 轴旋转 θ 的四元数:(x=0, y=0, z=sin(θ/2), w=cos(θ/2))
# 这是从欧拉角到四元数的标准转换公式(绕单轴旋转的简化形式)
t1 = TransformStamped()
t1.header.stamp = now
t1.header.frame_id = 'base_link'
t1.child_frame_id = 'link1'
t1.transform.translation.x = 0.0 # link1 原点与 base_link 原点重合
t1.transform.translation.y = 0.0
t1.transform.translation.z = 0.0
t1.transform.rotation.x = 0.0
t1.transform.rotation.y = 0.0
t1.transform.rotation.z = math.sin(theta1 / 2)
t1.transform.rotation.w = math.cos(theta1 / 2)

# 广播 link1 → link2(由 θ2 决定旋转,原点在 link1 末端)
t2 = TransformStamped()
t2.header.stamp = now
t2.header.frame_id = 'link1'
t2.child_frame_id = 'link2'
# link2 的原点在 link1 坐标系下的 x=L1 处(即 link1 的末端)
t2.transform.translation.x = self.L1
t2.transform.translation.y = 0.0
t2.transform.translation.z = 0.0
t2.transform.rotation.x = 0.0
t2.transform.rotation.y = 0.0
t2.transform.rotation.z = math.sin(theta2 / 2)
t2.transform.rotation.w = math.cos(theta2 / 2)

# 广播 link2 → end_effector(固定偏移,link2 末端,无旋转)
t3 = TransformStamped()
t3.header.stamp = now
t3.header.frame_id = 'link2'
t3.child_frame_id = 'end_effector'
t3.transform.translation.x = self.L2 # end_effector 在 link2 末端
t3.transform.translation.y = 0.0
t3.transform.translation.z = 0.0
t3.transform.rotation.x = 0.0
t3.transform.rotation.y = 0.0
t3.transform.rotation.z = 0.0
t3.transform.rotation.w = 1.0 # 无旋转

# 一次调用发送多个变换,比分三次调用更高效,时间戳也保持一致
self.tf_broadcaster.sendTransform([t1, t2, t3])


def main(args=None):
rclpy.init(args=args)
node = TFBroadcasterNode()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()

8.2.3 注册入口点

setup.pyconsole_scripts 里追加(当前完整内容):

'console_scripts': [
'hello_node = my_robot_basics.hello_node:main',
'kinematics_node = my_robot_basics.kinematics_node:main',
'kinematics_publisher = my_robot_basics.kinematics_publisher:main',
'kinematics_subscriber = my_robot_basics.kinematics_subscriber:main',
'ik_service_node = my_robot_basics.ik_service_node:main',
'ik_client_node = my_robot_basics.ik_client_node:main',
'move_to_action_server = my_robot_basics.move_to_action_server:main',
'move_to_action_client = my_robot_basics.move_to_action_client:main',
'tf_broadcaster_node = my_robot_basics.tf_broadcaster_node:main', # 新增
],

8.3 监听变换——查询末端位置

8.3.1 监听节点

vim $ROS_WS/src/my_robot_basics/my_robot_basics/tf_listener_node.py
# my_robot_basics/tf_listener_node.py
# 运行方式:ros2 run my_robot_basics tf_listener_node
#
# ============================================================
# 节点目标:每秒查询一次末端执行器在 world 坐标系下的位置,
# 验证 tf2 广播的坐标系变换与 FK 计算结果一致
# 执行逻辑:
# 1. __init__ 创建 Buffer 和 TransformListener(开始缓存 tf2 数据)
# 2. 创建 1 秒定时器,每秒触发一次查询
# 3. timer_callback 调用 lookup_transform() 查询变换
# 4. 从变换结果中提取末端位置并打印
# ============================================================

import rclpy
from rclpy.node import Node
# Buffer:在内存中缓存一段时间内的所有 tf2 变换数据
# TransformListener:订阅 /tf 和 /tf_static,把收到的变换存入 Buffer
# 两者必须配合使用:Listener 负责收数据,Buffer 负责存和查
from tf2_ros import TransformListener, Buffer
from rclpy.duration import Duration


class TFListenerNode(Node):
def __init__(self):
super().__init__('tf_listener_node')

# Buffer 默认缓存最近 10 秒的变换数据
# 查询历史时刻的变换时,数据必须在缓存窗口内
self.tf_buffer = Buffer()

# TransformListener(buffer, node):
# 内部会创建对 /tf 和 /tf_static 的订阅
# 收到变换消息后自动存入 tf_buffer,不需要手动处理
self.tf_listener = TransformListener(self.tf_buffer, self)

# 每秒查询一次末端位置
self.timer = self.create_timer(1.0, self.timer_callback)
self.get_logger().info('TF Listener 启动,每秒查询末端位置...')

def timer_callback(self):
try:
# lookup_transform(目标坐标系, 源坐标系, 时间点)
# 含义:返回"把源坐标系的点变换到目标坐标系"所需的变换矩阵
# 即:end_effector 原点在 world 坐标系下的位置和姿态
# rclpy.time.Time():查询最新时刻的变换(时间戳为 0 表示"最新")
# 如果 tf2 还没收到数据,会抛出异常(见错误处理章节)
t = self.tf_buffer.lookup_transform(
'world', # 目标坐标系(想要结果表示在哪个坐标系下)
'end_effector', # 源坐标系(想要查询哪个坐标系的位置)
rclpy.time.Time(), # 查询最新时刻
)
# t.transform.translation 就是 end_effector 原点在 world 坐标系下的坐标
x = t.transform.translation.x
y = t.transform.translation.y
self.get_logger().info(f'末端位置 (world 坐标系): x={x:.4f}, y={y:.4f}')

except Exception as e:
# 常见异常:
# - LookupException:坐标系不存在(广播节点未启动)
# - ExtrapolationException:查询时间超出缓存范围
self.get_logger().warn(f'查询失败: {e}')


def main(args=None):
rclpy.init(args=args)
node = TFListenerNode()
rclpy.spin(node)
node.destroy_node()
rclpy.shutdown()

8.3.2 注册入口点

setup.pyconsole_scripts 里追加(当前完整内容):

'console_scripts': [
'hello_node = my_robot_basics.hello_node:main',
'kinematics_node = my_robot_basics.kinematics_node:main',
'kinematics_publisher = my_robot_basics.kinematics_publisher:main',
'kinematics_subscriber = my_robot_basics.kinematics_subscriber:main',
'ik_service_node = my_robot_basics.ik_service_node:main',
'ik_client_node = my_robot_basics.ik_client_node:main',
'move_to_action_server = my_robot_basics.move_to_action_server:main',
'move_to_action_client = my_robot_basics.move_to_action_client:main',
'tf_broadcaster_node = my_robot_basics.tf_broadcaster_node:main',
'tf_listener_node = my_robot_basics.tf_listener_node:main', # 新增
],

8.4 构建并在 RViz2 中验证

8.4.1 构建

cd $ROS_WS
colcon build --packages-select my_robot_basics
source install/setup.bash

8.4.2 启动系统

终端 1:发布关节角

ros2 run my_robot_basics kinematics_publisher

终端 2:广播 TF

ros2 run my_robot_basics tf_broadcaster_node

终端 3:监听末端位置

ros2 run my_robot_basics tf_listener_node

终端 4:打开 RViz2

rviz2

8.4.3 在 RViz2 中配置

RViz2 TF 可视化效果

步骤 1:设置参考坐标系

RViz2 启动后,左侧面板顶部有 Global Options,展开它,找到 Fixed Frame 字段,默认值是 map。点击它,手动输入 world 并回车。

如果输入后变红,说明 tf_broadcaster_node 还没启动,或者 world 坐标系还没发布。先确认终端 2 的广播节点在运行。

步骤 2:添加 TF 显示插件

点击左侧面板最底部的 Add 按钮,弹出对话框:

  • 选择 By display type 标签页
  • 在列表里找到 TF,双击或点 OK

左侧面板会出现一个新的 TF 条目。

步骤 3:调整 TF 显示参数

展开左侧的 TF 条目,找到以下参数并调整:

参数建议值说明
Show Names勾选在坐标系旁显示名称
Show Axes勾选显示 RGB 三轴箭头(红=X,绿=Y,蓝=Z)
Show Arrows可关闭父子连线箭头,关掉更清晰
Marker Scale0.3默认 1 太大,坐标轴会互相遮挡,改小

步骤 4:调整视角

机械臂总长约 1.8 米(L1=1.0 + L2=0.8),默认视角可能太近。

  • 滚轮向后滚,缩小视图,直到能看到整条臂
  • 按住鼠标左键拖动旋转视角
  • 按住鼠标中键拖动平移

预期效果

调整完后,3D 视口里应该能看到:

world / base_link(重叠在原点,因为两者无偏移)
↓ 沿 X 轴约 1.0 米处
link2(随 θ1 旋转)
↓ 再沿 X 轴约 0.8 米处
end_effector

关节角在持续变化(kinematics_publisher 在发布正弦波),坐标系应该能看到缓慢摆动。

如果坐标系还是扎堆

# 确认末端位置确实在变化
ros2 run tf2_ros tf2_echo world end_effector

Translation 的 x 值是否在 1.6~1.8 之间变化。如果是,说明数据正常,只需继续调小 Marker Scale


8.5 用 CLI 工具排查 TF

# 查看当前所有坐标系
ros2 run tf2_tools view_frames

# 生成坐标系树的 PDF(在当前目录生成 frames.pdf)
# 运行后用 evince frames.pdf 查看

# 实时查询两个坐标系之间的变换
ros2 run tf2_ros tf2_echo world end_effector

# 查看 /tf topic 的原始数据
ros2 topic echo /tf

命令行执行, 可以查看具体的frames的结果

xdg-open frames_2026-04-06_18.25.53.pdf
view_frames 输出示例

view_frames 是排查 TF 树断链问题的最快手段,能直观看到哪些坐标系连通、哪些断开。


8.6 tf2 与运动学的关系

这是一个重要的理解点:

运动学函数(第 3 讲):
输入:θ1, θ2, L1, L2
输出:末端坐标 (x, y)
作用:数学计算

tf2(本讲):
输入:TransformStamped(带时间戳的变换)
输出:全系统共享的坐标系树
作用:系统级坐标管理

tf2 不替代运动学,而是让运动学结果变成系统共享资源。

后面的 robot_state_publisher 会根据 URDF 自动做这件事,不需要手写每个变换。但手写一遍,能让你真正理解 URDF + RSP 背后在做什么。


8.7 常见错误

8.7.1 错误一:RViz2 看不到坐标系

排查步骤

# 1. 确认 TF 在发布
ros2 topic hz /tf

# 2. 确认坐标系存在
ros2 run tf2_tools view_frames

# 3. 确认 Fixed Frame 设置正确(必须是树的根节点)

8.7.2 错误二:lookup_transformExtrapolationException

ExtrapolationException: Lookup would require extrapolation into the future

原因:查询时间比 TF 数据的时间戳更新,缓存里还没有数据。

解决:查询最新时刻用 rclpy.time.Time(),或者加超时等待:

t = self.tf_buffer.lookup_transform(
'world',
'end_effector',
rclpy.time.Time(),
timeout=Duration(seconds=1.0), # 等待最多 1 秒
)

8.7.3 错误三:坐标系树断链

view_frames 输出里看到两棵独立的树,说明某个变换没有发布。

常见原因:

  • 父坐标系名字拼写错误(base_link vs base-link
  • 广播节点没有启动
  • 静态变换只广播了一次但节点已退出

8.8 练习题

8.8.1 练习 1:验证 tf2 末端位置与 FK 一致

同时运行 kinematics_publishertf_broadcaster_nodetf_listener_node,对比:

  • kinematics_publisher 日志里打印的末端坐标
  • tf_listener_node 查询到的末端坐标

两者应该完全一致。


8.8.2 练习 2:用 tf2_echo 实时观察变换

ros2 run tf2_ros tf2_echo base_link end_effector

观察输出中的 translation 字段,和 FK 公式的结果对比。


8.8.3 练习 3:添加一个传感器坐标系

tf_broadcaster_node.py 里增加一个静态变换:end_effector → camera,表示末端安装了一个相机,相机相对末端偏移 (0.05, 0, 0),无旋转。

view_frames 验证坐标系树包含 camera 节点。

参考答案

t_cam = TransformStamped()
t_cam.header.stamp = self.get_clock().now().to_msg()
t_cam.header.frame_id = 'end_effector'
t_cam.child_frame_id = 'camera'
t_cam.transform.translation.x = 0.05
t_cam.transform.translation.y = 0.0
t_cam.transform.translation.z = 0.0
t_cam.transform.rotation.w = 1.0
self.static_broadcaster.sendTransform(t_cam)

本讲核心总结

概念一句话理解
tf2全系统统一的坐标系树管理器,所有模块共享
静态变换固定不变的坐标系关系,用 StaticTransformBroadcaster 广播一次
动态变换随时间变化的关系,用 TransformBroadcaster 持续广播
lookup_transform查询任意两坐标系之间的变换,tf2 自动沿树路径计算
四元数tf2 用四元数表示旋转,2D 绕 z 轴旋转 θ 对应 (0, 0, sin(θ/2), cos(θ/2))
view_frames生成坐标系树 PDF,排查断链问题的第一手段

参考代码

本讲对应的完整参考代码位于:

ros_ws/reference/src/my_robot_basics/my_robot_basics/
├── tf_broadcaster_node.py ← 8.2 广播静态/动态变换
└── tf_listener_node.py ← 8.3 查询末端位置

使用方式

cp $ROS_WS/reference/src/my_robot_basics/my_robot_basics/tf_broadcaster_node.py \
$ROS_WS/src/my_robot_basics/my_robot_basics/
cp $ROS_WS/reference/src/my_robot_basics/my_robot_basics/tf_listener_node.py \
$ROS_WS/src/my_robot_basics/my_robot_basics/

代码注释中详细解释了四元数与旋转角的转换关系(z=sin(θ/2), w=cos(θ/2))、静态变换与动态变换的区别,以及 Buffer + TransformListener 的配合原理。


下一讲预告

9. URDF 机器人建模与 robot_state_publisher

本讲我们手写了每个坐标系变换,代码量不小。真实工程里,机器人结构用 URDF 文件描述,robot_state_publisher 会自动根据 URDF 和关节角发布整棵 TF 树——不需要手写任何 TransformStamped

下一讲我们会:

  • 用 XML 写出两连杆机械臂的 URDF 模型
  • 启动 robot_state_publisher,让它替代本讲的 tf_broadcaster_node
  • joint_state_publisher_gui 拖动滑块,在 RViz2 里看到机械臂动起来
  • 理解 URDF 是 MoveIt 2 的入场券