Soma Zero Tutorials
🔍 搜索功能尚未开启,敬请期待。

3.8 ROS2 TF2 机械臂坐标系实战

一、本章目标

本节课会涉及坐标系、欧拉角、正运动学等知识,建议配套学习《大模型 3D/6D 视觉抓取 6 轴机械臂》 下列内容:

  • 七、运动控制:坐标系·姿态·欧拉角·旋转矩阵·齐次变换矩阵
  • 八、运动控制:DH 参数建模与正逆运动学
  • 九、相机内参、相机标定、手眼标定原理、3D 抓取

在前面的章节中,我们学习了 Topic(数据流)、Service(一问一答)、Action(长时间任务)三种通信方式。但在机器人应用中,还有一个核心问题没有解决:

如何处理不同坐标系之间的关系?

想象以下场景:

  • 相机看到一个物体,坐标是 (0.3, 0.2, 0.5) —— 但这是相机坐标系下的位置
  • 机械臂需要抓取这个物体,但机械臂使用的是基座坐标系
  • 机械臂末端还可能安装了夹爪,夹爪又有自己的坐标系

问题来了

  • 相机坐标怎么转换成机械臂基座坐标?
  • 机械臂的 6 个关节各自形成的坐标系关系是什么?
  • 末端夹爪的位置怎么实时跟踪?

这就是 TF2(Transform Library 2) 要解决的问题:管理和查询机器人系统中所有坐标系之间的变换关系

1.1 学习路线

1步:理解坐标系和变换的概念 ↓ 第2步:发布静态坐标变换(固定关系,如相机与末端) ↓ 第3步:发布动态坐标变换(运动关系,如关节角度) ↓ 第4步:监听和查询坐标变换(获取两个坐标系之间的关系) ↓ 第5步:构建完整机械臂TF树(base → link1-6 → tool0) ↓ 第6步:实战案例:简化的视觉抓取坐标转换

1.2 本章目标

完成本章后,你将能够:

  • 理解 TF2 的坐标系树(TF Tree)结构
  • 使用 StaticTransformBroadcaster 发布固定坐标关系
  • 使用 TransformBroadcaster 发布动态坐标关系(关节运动)
  • 使用 BufferTransformListener 查询坐标变换
  • 构建机械臂的完整 TF 树(base_link → link1 → link2 → ... → link6 → tool0
  • 实现简单的坐标转换案例(相机坐标 → 机械臂基座坐标)

1.3 与前几章的关系

章节 通信方式 本章中的角色
第 4 章 Topic 发布关节角度 作为 TF2 的数据源(订阅角度消息来计算坐标变换)
第 5 章 Service 查询当前状态 可以提供"查询某坐标系相对某坐标系的位置"服务
第 6 章 Action 长时间运动任务 运动过程中持续更新 TF 树
第 8 章 TF2 坐标变换 所有模块的空间关系基础

核心理念:TF2 不是一种新的通信方式,而是一个坐标系管理系统,它基于 Topic 实现(发布到 /tf/tf_static 话题)。

二、TF2 核心概念

2.1 三个核心概念

概念 1:Frame(坐标系)

Frame 是一个带有名称的坐标系。每个 Frame 都有:

  • 名称(如 worldbase_linkcamera_link
  • 原点位置
  • 坐标轴方向X、Y、Z

机械臂中的常见 Frame

Frame 名称 含义 特点
world 世界坐标系 通常是固定的全局参考系
base_link 机械臂基座 机械臂的根坐标系
link1 ~ link6 各关节坐标系 随关节运动而变化
tool0 末端法兰 机械臂第 6 轴输出位置
gripper_link 夹爪坐标系 夹爪中心或指尖位置
camera_link 相机坐标系 相机光学中心

概念 2:Transform(变换)

Transform 描述两个 Frame 之间的空间关系,包含两部分:

组成部分 内容 数据格式
Translation(平移) X、Y、Z 方向的偏移 (x, y, z) 单位:米
Rotation(旋转) 姿态差异 四元数 (x, y, z, w) 或欧拉角 (roll, pitch, yaw)

生活类比

问题:"客厅"相对于"卧室"在哪里? 答案: - 平移:向前走5米,向左2米(Translation) - 旋转:面朝西方(Rotation) 这就是两个坐标系之间的 Transform。

概念 3:TF Tree(坐标树)

TF Tree 是由 Frame 和 Transform 构成的树状结构

world (根节点) ├── base_link (机械臂底座) │ ├── link1 (第1关节) │ │ ├── link2 (第2关节) │ │ │ ├── link3 (第3关节) │ │ │ │ ├── link4 (第4关节) │ │ │ │ │ ├── link5 (第5关节) │ │ │ │ │ │ ├── link6 (第6关节) │ │ │ │ │ │ │ ├── tool0 (末端法兰) │ │ │ │ │ │ │ │ ├── gripper_link (夹爪) │ │ │ │ │ │ │ │ └── camera_link (相机) └── map (全局地图,可选)

在前序课程中,我们使用 rqt 也展示过 TF tree

TF Tree 的规则

规则 说明 示例
树结构 每个节点只有一个父节点 link2 的父节点只能是 link1
不允许闭环 不能形成循环依赖 A→B→C→A
单根或多根 可以有多个独立的树 worldmap 可以并存

2.2 TF2 与 Topic 的关系

TF2 本质上是基于 Topic 实现的

TF2 组件 底层实现 话题名称
StaticTransformBroadcaster 发布到静态变换话题 /tf_static
TransformBroadcaster 发布到动态变换话题 /tf
TransformListener 订阅变换话题 /tf + /tf_static

查看 TF 话题

# 查看当前发布的 TF 数据 ros2 topic echo /tf ros2 topic echo /tf_static # 查看 TF 消息类型 ros2 topic info /tf # 类型: tf2_msgs/msg/TFMessage
  • 打开 Episode 1 ROS2 包,启动 ros2 run episode_controller interface,使用 ros2 topic list

    # 只有角度发布的Topic /joint_states /parameter_events /rosout

    如果没有 Episode1 机械臂,请将 src/episode1_urdf_1113/launch/launch.pyld.add_action(start_joint_state_publisher_cmd) 取消注释,启动 Rviz 后可以看到关节滑块区域:

  • 然后启动 RVIZ 仿真:ros2 launch episode1_urdf_1113 launch.py,再次查看

    /clicked_point /goal_pose /initialpose /joint_states /parameter_events /robot_description /rosout # 可以看到出现了/tf和/tf_static,这便是URDF + robot_state_publisher发布的 /tf /tf_static
  • 调试:

    # 查看动态tf ros2 topic echo /tf # 输出 ... - header: stamp: sec: 1769154502 nanosec: 506771888 frame_id: link4 child_frame_id: link5 transform: translation: x: 0.0 y: 0.0 z: -0.192 rotation: x: 0.5000872588433475 y: -0.4999127259190354 z: 0.4999145628454793 w: 0.5000854225647451 - header: stamp: sec: 1769154502 nanosec: 506771888 frame_id: link5 child_frame_id: link6 ... # 查看静态tf ros2 topic echo /tf_static # 输出:map和odom transforms: - header: stamp: sec: 1769154403 nanosec: 393914403 frame_id: map child_frame_id: odom transform: translation: x: 0.0 y: 0.0 z: 0.0 rotation: x: 0.0 y: 0.0 z: 0.0 w: 1.0 --- transforms: [] --- # 查看消息类型 ros2 topic info /tf # 输出 Type: tf2_msgs/msg/TFMessage Publisher count: 1 Subscription count: 1

2.3 两种变换类型对比

特性 Static Transform(静态变换) Dynamic Transform(动态变换)
发布频率 只发布一次(或低频发布) 持续高频发布(如 30Hz)
使用场景 固定不变的关系 随时间变化的关系
典型例子 • 相机与末端法兰的位置• 机械臂底座与世界坐标 • 各关节角度引起的坐标变化• 移动机器人的位置
话题 /tf_static /tf
Python 类 StaticTransformBroadcaster TransformBroadcaster

2.4 外卖系统类比

继续用外卖系统来理解 TF2:

TF2 概念 外卖类比 说明
Frame 地点(家、餐厅、配送站) 每个地点是一个坐标系
Transform "餐厅相对于家的位置" 描述两地点之间的关系
Static Transform "小区门口相对于家的位置" 固定不变
Dynamic Transform "骑手相对于餐厅的实时位置" 持续变化
TF Tree 整个配送网络的位置关系图 树状结构:配送站 → 餐厅 → 骑手
lookup_transform "查询骑手相对于我家的位置" 跨多个节点计算

2.5 TF2 工具一览

工具命令 功能 输出
ros2 run tf2_tools view_frames 生成 TF 树的 PDF 可视化 frames.pdf
ros2 run tf2_ros tf2_echo 实时显示两个坐标系之间的变换 平移 + 旋转
ros2 run tf2_ros tf2_monitor 监控 TF 延迟 平均延迟时间
ros2 run rviz2 rviz2 可视化所有坐标系 3D 显示坐标轴
rqt 动态查看 TF 树 GUI 界面

工具演示(后续实战中使用)

# 生成 TF 树 PDF(等待5秒收集数据) ros2 run tf2_tools view_frames # 输出:frames.pdf # 实时查看 base_link 到 tool0 的变换 ros2 run tf2_ros tf2_echo base_link link6 # 输出 At time 1769154878.941803011 - Translation: [0.302, -0.000, 0.422] - Rotation: in Quaternion (xyzw) [0.708, 0.001, 0.706, 0.001] - Rotation: in RPY (radian) [2.158, -1.568, 0.984] - Rotation: in RPY (degree) [123.634, -89.820, 56.366] - Matrix: 0.002 0.000 1.000 0.302 0.003 -1.000 -0.000 -0.000 1.000 0.003 -0.002 0.422 0.000 0.000 0.000 1.000 # 监控 TF 延迟 ros2 run tf2_ros tf2_monitor # 输出 Gathering data on all frames for 10 seconds... RESULTS: for all Frames Frames: Frame: link1, published by <no authority available>, Average Delay: 0.00186811, Max Delay: 0.146899 Frame: link2, published by <no authority available>, Average Delay: 0.00186895, Max Delay: 0.1469 Frame: link3, published by <no authority available>, Average Delay: 0.00186991, Max Delay: 0.146902 Frame: link4, published by <no authority available>, Average Delay: 0.00187049, Max Delay: 0.146906 Frame: link5, published by <no authority available>, Average Delay: 0.00187102, Max Delay: 0.146907 Frame: link6, published by <no authority available>, Average Delay: 0.00187174, Max Delay: 0.146908 Frame: odom, published by <no authority available>, Average Delay: 373.826, Max Delay: 373.826 All Broadcasters: Node: <no authority available> 19.0211 Hz, Average Delay: 1.90912 Max Delay: 373.826 # 在 rviz2 中可视化 ros2 run rviz2 rviz2 # 综合工具 rqt
Rviz2 rqt

三、静态坐标变换:固定关系

3.1 使用场景

什么时候用静态变换?

场景 说明 示例
传感器安装位置 相机、激光雷达相对于机器人的位置 camera_linkbase_link
机械臂底座位置 机械臂相对于世界坐标的固定位置 base_linkworld
手眼标定结果 相机与末端法兰的标定关系 camera_linktool0
固定工装 工作台、料盒相对于机器人的位置 workbenchbase_link

3.2 三种发布方式对比

方式 适用场景 优点 缺点
命令行工具 临时测试、快速验证 无需编写代码 不持久,需要手动启动
Python 节点 固定的静态关系 可集成到启动文件 需要编写代码
URDF + robot_state_publisher 复杂机器人模型 自动发布所有静态链接 需要学习 URDF(后续章节)

3.3 方式一:命令行工具(快速验证)

示例 1:发布世界坐标到机械臂基座

为避免干扰,请关闭之前打开的程序。

# 格式:static_transform_publisher x y z yaw pitch roll frame_id child_frame_id ros2 run tf2_ros static_transform_publisher \ --x 0.0 --y 0.0 --z 0.0 \ --yaw 0 --pitch 0 --roll 0 \ --frame-id world --child-frame-id base_link

参数说明

参数 含义 单位 示例值
--x, --y, --z 平移 0.0 0.0 0.0
--yaw, --pitch, --roll 欧拉角旋转 弧度 0 0 0(无旋转)
--frame-id 父坐标系 - world
--child-frame-id 子坐标系 - base_link

查看发布结果

# 查看 TF 树 ros2 run tf2_tools view_frames # 打开生成的 frames.pdf # 实时查看变换 ros2 run tf2_ros tf2_echo world base_link # 输出: At time 0.0 - Translation: [0.000, 0.000, 0.000] - Rotation: in Quaternion (xyzw) [0.000, 0.000, 0.000, 1.000] - Rotation: in RPY (radian) [0.000, -0.000, 0.000] - Rotation: in RPY (degree) [0.000, -0.000, 0.000] - Matrix: 1.000 0.000 0.000 0.000 0.000 1.000 0.000 0.000 0.000 0.000 1.000 0.000 0.000 0.000 0.000 1.000

示例 2:发布相机到末端法兰(手眼标定结果)

# 假设相机在末端法兰前方10cm,向下5cm,无旋转 ros2 run tf2_ros static_transform_publisher \ --x 0.1 --y 0.0 --z -0.05 \ --yaw 0 --pitch 0 --roll 0 \ --frame-id tool0 --child-frame-id camera_link

查看发布结果

# 查看 TF 树 ros2 run tf2_tools view_frames # 打开生成的 frames.pdf # 实时查看变换 ros2 run tf2_ros tf2_echo tool0 camera_link # 输出 At time 0.0 - Translation: [0.100, 0.000, -0.050] - Rotation: in Quaternion (xyzw) [0.000, 0.000, 0.000, 1.000] - Rotation: in RPY (radian) [0.000, -0.000, 0.000] - Rotation: in RPY (degree) [0.000, -0.000, 0.000] - Matrix: 1.000 0.000 0.000 0.100 0.000 1.000 0.000 0.000 0.000 0.000 1.000 -0.050 0.000 0.000 0.000 1.000
PDF RVIZ

3.4 方式二:Python 节点

版本 0:最基础的静态变换发布器

目标:发布 worldbase_link 的固定关系。

创建文件 ~/ros2_ws/src/py_episode/py_episode/static_tf_publisher.py

#!/usr/bin/env python3 """ 静态坐标变换发布器 - 版本 0 发布 world → base_link 的固定关系 """ import rclpy from rclpy.node import Node from geometry_msgs.msg import TransformStamped from tf2_ros.static_transform_broadcaster import StaticTransformBroadcaster class StaticTFPublisher(Node): """静态坐标变换发布节点""" def __init__(self): super().__init__('static_tf_publisher') # 创建静态变换广播器 self.tf_broadcaster = StaticTransformBroadcaster(self) # 发布变换 self.publish_static_transform() self.get_logger().info('静态 TF 发布器已启动') def publish_static_transform(self): """发布 world → base_link 的静态变换""" # 创建 TransformStamped 消息 t = TransformStamped() # 设置时间戳(静态变换通常用当前时间) t.header.stamp = self.get_clock().now().to_msg() # 设置父坐标系(frame_id)和子坐标系(child_frame_id) t.header.frame_id = 'world' # 父坐标系 t.child_frame_id = 'base_link' # 子坐标系 # 设置平移(单位:米) # base_link 相对于 world 的位置 t.transform.translation.x = 0.0 t.transform.translation.y = 0.0 t.transform.translation.z = 0.0 # 假设机械臂底座在地面上 # 设置旋转(四元数,无旋转时 w=1, x=y=z=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 # 无旋转 # 发布变换 self.tf_broadcaster.sendTransform(t) self.get_logger().info( f'已发布静态变换: {t.header.frame_id}{t.child_frame_id}') def main(args=None): rclpy.init(args=args) node = StaticTFPublisher() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

关键知识点

  • TransformStamped:描述两个坐标系之间关系的"信封"
    • 包含:父坐标系、子坐标系、时间戳、平移、旋转
    • 命名含义:Transform(变换)+ Stamped(带时间戳,ROS2 惯例)
    • 类似命名:PointStampedPoseStamped
  • StaticTransformBroadcaster:发布静态变换的"邮递员"
    • 用于发布不会变化的坐标关系(如传感器安装位置)
    • 只需发布一次,TF2 会永久记住
    • 发布到 /tf_static 话题
  • 平移:transform.translation.x/y/z,单位是
  • 旋转:transform.rotation.x/y/z/w(四元数),无旋转时 w=1, x=y=z=0
  • 时间戳:header.stamp,使用 get_clock().now().to_msg()
  • 坐标系名称:header.frame_id(父)+ child_frame_id(子)

查看消息类型

# 检查TransformStamped类型具体信息 ros2 interface show geometry_msgs/msg/TransformStamped # 输出 # The frame id in the header is used as the reference frame of this transform. std_msgs/Header header builtin_interfaces/Time stamp int32 sec uint32 nanosec string frame_id # The frame id of the child frame to which this transform points. string child_frame_id # Translation and rotation in 3-dimensions of child_frame_id from header.frame_id. Transform transform Vector3 translation float64 x float64 y float64 z Quaternion rotation float64 x 0 float64 y 0 float64 z 0 float64 w 1

关于消息类型的疑问解答

你可能会有疑问:代码中使用的是 geometry_msgs/msg/TransformStamped,但查看话题类型时却不一样?

# 查看 TF 话题 ros2 topic list # 输出包含: /tf_static # 查看话题类型 ros2 topic info /tf_static # 输出: Type: tf2_msgs/msg/TFMessage ← 为什么不是 TransformStamped?

原因解释

层级 消息类型 作用
代码层 geometry_msgs/msg/TransformStamped 描述单个坐标变换(父坐标系 → 子坐标系)
话题层 tf2_msgs/msg/TFMessage 包含多个 TransformStamped 的数组,用于话题传输

TFMessage 的结构

# 检查 ros2 interface show tf2_msgs/msg/TFMessage # 输出: geometry_msgs/TransformStamped[] transforms # ← 是一个数组! # # std_msgs/Header header builtin_interfaces/Time stamp int32 sec uint32 nanosec string frame_id string child_frame_id Transform transform Vector3 translation float64 x float64 y float64 z Quaternion rotation float64 x 0 float64 y 0 float64 z 0 float64 w 1

工作原理

你的代码StaticTransformBroadcaster/tf_staticTransformStamped (单个变换)TFMessage (打包成数组发布)你的代码StaticTransformBroadcaster/tf_static

简单理解

  • TransformStamped一个快递包裹(单个变换)
  • TFMessage快递车(可以装多个包裹)
  • StaticTransformBroadcaster.sendTransform() 会自动把你的包裹装车发送

这也是为什么 sendTransform() 可以接受单个 TransformStamped 或列表 [t1, t2, t3] 的原因。

版本 1:添加欧拉角转四元数

改进:使用欧拉角(roll, pitch, yaw)更直观地设置旋转。

#!/usr/bin/env python3 """ 静态坐标变换发布器 - 版本 1 使用欧拉角设置旋转 """ import math import rclpy from rclpy.node import Node from geometry_msgs.msg import TransformStamped from tf2_ros.static_transform_broadcaster import StaticTransformBroadcaster import tf_transformations class StaticTFPublisherV1(Node): """静态坐标变换发布节点 - 支持欧拉角""" def __init__(self): super().__init__('static_tf_publisher_v1') self.tf_broadcaster = StaticTransformBroadcaster(self) self.publish_static_transform() self.get_logger().info('静态 TF 发布器 V1 已启动(支持欧拉角)') def euler_to_quaternion(self, roll, pitch, yaw): """ 欧拉角转四元数 Args: roll: 绕 X 轴旋转(弧度) pitch: 绕 Y 轴旋转(弧度) yaw: 绕 Z 轴旋转(弧度) Returns: (qx, qy, qz, qw): 四元数 """ # tf_transformations 返回顺序是 (x, y, z, w) qx, qy, qz, qw = tf_transformations.quaternion_from_euler(roll, pitch, yaw, 'sxyz') return qx, qy, qz, qw def publish_static_transform(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.1 # 10cm 高度 # 使用欧拉角设置旋转 # 示例:绕 Z 轴旋转 45 度 roll = 0.0 # 绕 X 轴 pitch = 0.0 # 绕 Y 轴 yaw = math.radians(45.0) # 绕 Z 轴,45度转弧度 qx, qy, qz, qw = self.euler_to_quaternion(roll, pitch, yaw) t.transform.rotation.x = qx t.transform.rotation.y = qy t.transform.rotation.z = qz t.transform.rotation.w = qw self.tf_broadcaster.sendTransform(t) self.get_logger().info( f'已发布静态变换: {t.header.frame_id}{t.child_frame_id}' f' (yaw={math.degrees(yaw):.1f}°)') def main(args=None): rclpy.init(args=args) node = StaticTFPublisherV1() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

新增知识点

  • 导入 tf_transformations 库:sudo apt install ros-jazzy-tf-transformations
  • 欧拉角转四元数:quaternion_from_euler(roll, pitch, yaw, 'sxyz') 返回 (x, y, z, w) 顺序
  • 欧拉角约定:ROS 使用 RPY(Roll-Pitch-Yaw) 顺序,即先绕 X 轴、再绕 Y 轴、最后绕 Z 轴
  • 角度转换:使用 math.radians() 将度转换为弧度

版本 2:同时发布多个静态变换

改进:一个节点发布多个固定关系(世界 → 基座、末端 → 相机)。

#!/usr/bin/env python3 """ 静态坐标变换发布器 - 版本 2 发布多个静态变换: 1. world → base_link(机械臂底座位置) 2. tool0 → camera_link(手眼标定结果) 3. tool0 → gripper_link(夹爪安装位置) """ import math import rclpy from rclpy.node import Node from geometry_msgs.msg import TransformStamped from tf2_ros.static_transform_broadcaster import StaticTransformBroadcaster import tf_transformations class StaticTFPublisherV2(Node): """静态坐标变换发布节点 - 发布多个变换""" def __init__(self): super().__init__('static_tf_publisher_v2') self.tf_broadcaster = StaticTransformBroadcaster(self) # 发布所有静态变换 self.publish_all_static_transforms() self.get_logger().info('静态 TF 发布器 V2 已启动(多变换)') def euler_to_quaternion(self, roll, pitch, yaw): """欧拉角转四元数""" qx, qy, qz, qw = tf_transformations.quaternion_from_euler(roll, pitch, yaw, 'sxyz') return qx, qy, qz, qw def create_transform(self, parent_frame, child_frame, x, y, z, roll=0.0, pitch=0.0, yaw=0.0): """ 创建 TransformStamped 消息的辅助函数 Args: parent_frame: 父坐标系名称 child_frame: 子坐标系名称 x, y, z: 平移(米) roll, pitch, yaw: 欧拉角(弧度) Returns: TransformStamped 消息 """ t = TransformStamped() t.header.stamp = self.get_clock().now().to_msg() t.header.frame_id = parent_frame t.child_frame_id = child_frame # 设置平移 t.transform.translation.x = float(x) t.transform.translation.y = float(y) t.transform.translation.z = float(z) # 设置旋转 qx, qy, qz, qw = self.euler_to_quaternion(roll, pitch, yaw) t.transform.rotation.x = qx t.transform.rotation.y = qy t.transform.rotation.z = qz t.transform.rotation.w = qw return t def publish_all_static_transforms(self): """发布所有静态变换""" transforms = [] # 变换 1: world → base_link(机械臂底座在世界坐标原点) t1 = self.create_transform( parent_frame='world', child_frame='base_link', x=0.0, y=0.0, z=0.1, # 底座高度10cm roll=0.0, pitch=0.0, yaw=math.radians(45) # 绕Z轴旋转45度 ) transforms.append(t1) self.get_logger().info('静态变换: world → base_link') # 变换 2: tool0 → camera_link(相机安装在末端法兰上) # 假设相机在末端法兰前方 8cm,向下 5cm,向下倾斜 30 度 t2 = self.create_transform( parent_frame='tool0', child_frame='camera_link', x=0.08, y=0.0, z=-0.05, # 前方8cm,下方5cm roll=0.0, pitch=math.radians(30), yaw=0.0 # 向下倾斜30度 ) transforms.append(t2) self.get_logger().info('静态变换: tool0 → camera_link') # 变换 3: tool0 → gripper_link(夹爪安装在末端法兰上) # 假设夹爪中心在末端法兰前方 12cm t3 = self.create_transform( parent_frame='tool0', child_frame='gripper_link', x=0.12, y=0.0, z=0.0, # 前方12cm roll=0.0, pitch=0.0, yaw=0.0 # 无旋转 ) transforms.append(t3) self.get_logger().info('静态变换: tool0 → gripper_link') # 一次性发布所有静态变换 self.tf_broadcaster.sendTransform(transforms) self.get_logger().info(f'已发布 {len(transforms)} 个静态变换') def main(args=None): rclpy.init(args=args) node = StaticTFPublisherV2() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

新增知识点

  • sendTransform() 可以接受列表:sendTransform([t1, t2, t3])
  • 一次性发布多个静态变换,减少代码重复
  • 使用辅助函数 create_transform() 简化消息创建
world tool0

3.5 添加依赖

编辑 ~/ros2_ws/src/py_episode/package.xml,添加 TF2 相关依赖:

<!-- TF2 相关依赖 --> <exec_depend>tf2_ros</exec_depend> <exec_depend>tf2_geometry_msgs</exec_depend> <exec_depend>geometry_msgs</exec_depend> <exec_depend>tf_transformations</exec_depend> <!-- 欧拉角/四元数转换 -->

注意:如果 rosdep install --from-paths src --ignore-src -r -y 没有自动安装 tf_transformations,可以手动安装:

sudo apt install ros-jazzy-tf-transformations

3.6 添加入口点

编辑 ~/ros2_ws/src/py_episode/setup.py,在 entry_points 中添加静态 TF 发布器:

entry_points={ 'console_scripts': [ # ... 之前的入口点 ... 'static_tf_pub = py_episode.static_tf_publisher:main', # 静态TF发布器 ], },

3.7 测试与验证

# 编译 cd ~/ros2_ws colcon build --packages-select py_episode --symlink-install source install/setup.bash # 运行静态 TF 发布器 ros2 run py_episode static_tf_pub # 验证(另一个终端) ros2 topic echo /tf_static ros2 run tf2_tools view_frames ros2 run tf2_ros tf2_echo world base_link

四、动态坐标变换:运动关系

4.1 动态变换 vs 静态变换

特性 静态变换 动态变换
关系是否变化 永远固定 持续变化
发布频率 一次或低频 高频(如 30Hz)
典型场景 传感器安装位置 关节角度、机器人位置
Python 类 StaticTransformBroadcaster TransformBroadcaster
话题 /tf_static /tf

4.2 机械臂动态变换的来源

问题:机械臂的关节在运动,各 link 之间的坐标关系怎么计算?

答案:通过 正运动学(Forward Kinematics) 计算。

正运动学简介

输入:关节角度 θ1,θ2,...,θ6

输出:末端执行器相对于基座的位姿(位置 + 姿态)

计算方法

  1. DH 参数法(Denavit-Hartenberg):标准的机器人学方法
  2. 简化模型:对于教学机械臂,可以用固定偏移 + 旋转矩阵

本章处理方式

简化模型示例

假设我们的 6 轴机械臂各关节之间的固定偏移如下:

关节 父坐标系 子坐标系 Z 方向偏移(米) 旋转轴
1 base_link link1 0.15 Z 轴(Yaw)
2 link1 link2 0.10 Y 轴(Pitch)
3 link2 link3 0.20 Y 轴(Pitch)
4 link3 link4 0.15 X 轴(Roll)
5 link4 link5 0.10 Y 轴(Pitch)
6 link5 link6 0.08 X 轴(Roll)
- link6 tool0 0.05 固定(无旋转)

4.3 版本迭代:动态 TF 发布器

目标:使用周期函数模拟关节角度变化,发布第一个关节的动态坐标变换。

创建文件 ~/ros2_ws/src/py_episode/py_episode/dynamic_tf_publisher.py

#!/usr/bin/env python3 """ 动态坐标变换发布器 - 版本 0 使用周期函数模拟角度变化,发布 base_link → link1 """ import math import time import rclpy from rclpy.node import Node from geometry_msgs.msg import TransformStamped from tf2_ros import TransformBroadcaster import tf_transformations class DynamicTFPublisherV0(Node): """动态坐标变换发布器 - 单关节版本""" def __init__(self): super().__init__('dynamic_tf_publisher_v0') # 创建动态变换广播器(注意:不是 StaticTransformBroadcaster) self.tf_broadcaster = TransformBroadcaster(self) # 关节1的固定偏移(基座到关节1的Z方向偏移) self.link1_z_offset = 0.15 # 15cm # 记录启动时间(用于周期函数) self.start_time = time.time() # 创建定时器,30Hz 发布频率 self.timer = self.create_timer(1.0 / 30.0, self.timer_callback) self.get_logger().info('动态 TF 发布器 V0 已启动(单关节,周期运动)') def euler_to_quaternion(self, roll, pitch, yaw): """欧拉角转四元数""" qx, qy, qz, qw = tf_transformations.quaternion_from_euler(roll, pitch, yaw, 'sxyz') return qx, qy, qz, qw def generate_joint_angle(self): """ 使用周期函数生成关节角度 正弦函数让关节在 -90° 到 +90° 之间往复运动 周期约 4 秒 """ elapsed = time.time() - self.start_time # 正弦函数:振幅 90 度,周期 4 秒 angle = 90.0 * math.sin(2 * math.pi * elapsed / 4.0) return angle def timer_callback(self): """ 定时器回调,周期性发布 TF 这是动态 TF 的核心:高频率持续发布坐标变换 """ # 生成当前角度 joint1_angle = self.generate_joint_angle() # 发布 base_link → link1 的变换 self.publish_link1_transform(joint1_angle) def publish_link1_transform(self, joint1_angle): """发布 base_link → link1 的动态变换""" t = TransformStamped() # 时间戳:使用当前时间 t.header.stamp = self.get_clock().now().to_msg() # 父子坐标系 t.header.frame_id = 'base_link' t.child_frame_id = 'link1' # 平移:关节1在基座正上方 15cm t.transform.translation.x = 0.0 t.transform.translation.y = 0.0 t.transform.translation.z = self.link1_z_offset # 旋转:关节1绕 Z 轴旋转(Yaw) # 将角度从度转换为弧度 yaw = math.radians(joint1_angle) qx, qy, qz, qw = self.euler_to_quaternion(0.0, 0.0, yaw) t.transform.rotation.x = qx t.transform.rotation.y = qy t.transform.rotation.z = qz t.transform.rotation.w = qw # 发布变换 self.tf_broadcaster.sendTransform(t) def main(args=None): rclpy.init(args=args) node = DynamicTFPublisherV0() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

关键知识点

  • 导入 TransformBroadcaster(注意不是 Static):从 tf2_ros 直接导入
  • 使用定时器周期性发布:create_timer(1.0/30.0, callback) 实现 30Hz 发布
  • 周期函数生成角度:90 * sin(2π * t / 4) 让关节在 ±90° 之间往复
  • 在定时器回调中调用 sendTransform():每次触发就发布新的坐标变换
  • 根据关节 1 角度计算旋转(绕 Z 轴,Yaw)
tf2

版本 1:发布完整 6 关节链

改进:发布所有 6 个关节的坐标变换,每个关节使用不同频率的周期函数,构建完整的 TF 树。

#!/usr/bin/env python3 """ 动态坐标变换发布器 - 版本 1 发布完整的6关节链:base_link → link1 → ... → link6 → tool0 使用周期函数模拟各关节运动 """ import math import time import rclpy from rclpy.node import Node from geometry_msgs.msg import TransformStamped from tf2_ros import TransformBroadcaster import tf_transformations class DynamicTFPublisherV1(Node): """动态坐标变换发布器 - 完整6关节链""" def __init__(self): super().__init__('dynamic_tf_publisher_v1') self.tf_broadcaster = TransformBroadcaster(self) # ============== 机械臂运动学参数(简化模型) ============== # 各关节之间的固定偏移(单位:米) # 格式:(parent, child, x, y, z, rotation_axis) # 旋转轴:'z' = Yaw, 'y' = Pitch, 'x' = Roll self.joint_params = [ ('base_link', 'link1', 0.0, 0.0, 0.15, 'z'), # 关节1: Z轴旋转 ('link1', 'link2', 0.0, 0.0, 0.10, 'y'), # 关节2: Y轴旋转 ('link2', 'link3', 0.0, 0.0, 0.20, 'y'), # 关节3: Y轴旋转 ('link3', 'link4', 0.0, 0.0, 0.15, 'x'), # 关节4: X轴旋转 ('link4', 'link5', 0.0, 0.0, 0.10, 'y'), # 关节5: Y轴旋转 ('link5', 'link6', 0.0, 0.0, 0.08, 'x'), # 关节6: X轴旋转 ('link6', 'tool0', 0.0, 0.0, 0.05, None), # tool0: 固定偏移,无旋转 ] # 各关节的运动参数:(振幅, 周期, 相位偏移) # 让各关节以不同速度和相位运动,看起来更自然 self.motion_params = [ (60.0, 4.0, 0.0), # 关节1: ±60°, 周期4秒 (45.0, 3.0, 0.5), # 关节2: ±45°, 周期3秒, 相位0.5 (30.0, 2.5, 1.0), # 关节3: ±30°, 周期2.5秒 (90.0, 5.0, 0.2), # 关节4: ±90°, 周期5秒 (45.0, 3.5, 0.8), # 关节5: ±45°, 周期3.5秒 (180.0, 6.0, 0.0), # 关节6: ±180°, 周期6秒 ] # 记录启动时间 self.start_time = time.time() # 创建定时器,30Hz 发布频率 self.timer = self.create_timer(1.0 / 30.0, self.timer_callback) self.get_logger().info('动态 TF 发布器 V1 已启动(完整6关节链,周期运动)') def euler_to_quaternion(self, roll, pitch, yaw): """欧拉角转四元数""" qx, qy, qz, qw = tf_transformations.quaternion_from_euler(roll, pitch, yaw, 'sxyz') return qx, qy, qz, qw def generate_joint_angles(self): """ 使用周期函数生成所有关节角度 每个关节使用不同的振幅、周期和相位,产生自然的运动效果 """ elapsed = time.time() - self.start_time angles = [] for amplitude, period, phase in self.motion_params: # 正弦函数:angle = amplitude * sin(2π * (t + phase) / period) angle = amplitude * math.sin(2 * math.pi * (elapsed + phase) / period) angles.append(angle) return angles def timer_callback(self): """定时器回调,周期性发布所有关节的 TF""" # 生成当前各关节角度 joint_angles = self.generate_joint_angles() # 发布所有关节的坐标变换 self.publish_all_transforms(joint_angles) def publish_all_transforms(self, joint_angles): """发布所有关节的动态变换""" transforms = [] now = self.get_clock().now().to_msg() for i, (parent, child, x, y, z, rot_axis) in enumerate(self.joint_params): t = TransformStamped() t.header.stamp = 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 # 设置旋转 if rot_axis is not None and i < 6: # 获取对应关节的角度,转换为弧度 angle_rad = math.radians(joint_angles[i]) # 根据旋转轴计算四元数 if rot_axis == 'z': # Yaw qx, qy, qz, qw = self.euler_to_quaternion(0, 0, angle_rad) elif rot_axis == 'y': # Pitch qx, qy, qz, qw = self.euler_to_quaternion(0, angle_rad, 0) elif rot_axis == 'x': # Roll qx, qy, qz, qw = self.euler_to_quaternion(angle_rad, 0, 0) else: qx, qy, qz, qw = 0.0, 0.0, 0.0, 1.0 else: # 无旋转(tool0) qx, qy, qz, qw = 0.0, 0.0, 0.0, 1.0 t.transform.rotation.x = qx t.transform.rotation.y = qy t.transform.rotation.z = qz t.transform.rotation.w = qw transforms.append(t) # 批量发布所有变换 for t in transforms: self.tf_broadcaster.sendTransform(t) def main(args=None): rclpy.init(args=args) node = DynamicTFPublisherV1() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

新增知识点

  • 多关节周期运动:每个关节使用不同的振幅、周期、相位,产生自然的运动效果
  • 链式坐标变换:每个 link 相对于上一个 link,形成链式结构
  • 使用循环批量发布变换:遍历 joint_params 参数表
  • 角度单位转换:math.radians() 将度转换为弧度
  • 根据不同关节的旋转轴(X/Y/Z)计算对应的四元数
tf2

4.4 添加入口点

编辑 ~/ros2_ws/src/py_episode/setup.py,添加动态 TF 发布器入口点:

entry_points={ 'console_scripts': [ # ... 之前的入口点 ... 'static_tf_pub = py_episode.static_tf_publisher:main', # 静态TF发布器 'dynamic_tf_pub = py_episode.dynamic_tf_publisher:main', # 动态TF发布器 ], },

4.5 测试与验证

# 编译 cd ~/ros2_ws colcon build --packages-select py_episode --symlink-install source install/setup.bash # 运行动态 TF 发布器 ros2 run py_episode dynamic_tf_pub # 验证(另一个终端) ros2 topic echo /tf ros2 run tf2_tools view_frames ros2 run tf2_ros tf2_echo base_link tool0 # 在 rviz2 中可视化 rviz2 # 添加 TF 显示,观察各关节的周期运动

版本 2:集成到机械臂接口节点

改进:将 TF 发布功能集成到 RobotInterfaceNode,与 Topic/Service/Action 共存,使用 DH 参数计算正运动学

核心改动

  • 基于第 7 章的 robot_interface_with_action.py 添加 TF2 功能
  • 使用 cached_motor_angles 缓存角度计算正运动学
  • 使用 Episode 1 的 DH 参数,发布真实的坐标变换

创建文件 ~/ros2_ws/src/py_episode/py_episode/robot_interface_with_tf.py

#!/usr/bin/env python3 """ 机械臂接口节点 - 集成 TF2 功能: - Topic: 发布电机角度 - Service: 夹爪控制 - Action: 角度模式运动 - TF2: 发布机械臂坐标树(使用 DH 正运动学) """ import math import time import numpy as np import rclpy from rclpy.node import Node from rclpy.action import ActionServer, CancelResponse, GoalResponse from rclpy.callback_groups import ReentrantCallbackGroup from rclpy.executors import MultiThreadedExecutor # TF2 相关导入 from geometry_msgs.msg import TransformStamped from tf2_ros import TransformBroadcaster from tf2_ros.static_transform_broadcaster import StaticTransformBroadcaster import tf_transformations # 自定义接口 from robot_arm_interfaces.msg import MotorAngles from robot_arm_interfaces.srv import ServoGripper from robot_arm_interfaces.action import AngleMode # 电机控制核心库 from episode_controller_core.controller_core import MotorControl class RobotInterfaceWithTF(Node): """机械臂接口节点 - 包含 Topic + Service + Action + TF2""" def __init__(self): super().__init__('robot_interface_with_tf') # ============== 可重入回调组(支持 Action 取消) ============== self.callback_group = ReentrantCallbackGroup() # ============== 参数声明 ============== self.declare_parameter('usb_index', 1) self.declare_parameter('init_mode', 1) self.declare_parameter('publish_rate', 30.0) self.usb_index = self.get_parameter('usb_index').get_parameter_value().integer_value self.init_mode = self.get_parameter('init_mode').get_parameter_value().integer_value self.publish_rate = self.get_parameter('publish_rate').get_parameter_value().double_value self.home_position = [180, 90, 83, 30, 110, 30] self.calibration_timeout = 120.0 self.cached_motor_angles = [0.0] * 6 # ============== DH 参数(Episode 1 机械臂) ============== # 单位:毫米 → 转换为米时除以1000 self.a1, self.a2, self.a3 = 55 / 1000, 200 / 1000, 56 / 1000 # 米 self.d1, self.d4, self.d6 = 166 / 1000, 192 / 1000, 55 / 1000 # 米 # ============== 初始化电机控制 ============== self.motor_control = None self._initialize_robot() # ============== 创建 TF 广播器 ============== self.tf_broadcaster = TransformBroadcaster(self) self.static_tf_broadcaster = StaticTransformBroadcaster(self) # 发布静态变换 self.publish_static_transforms() # ============== 创建 Topic Publisher ============== self.publisher = self.create_publisher(MotorAngles, 'motor_angles', 10) self.get_logger().info('Topic 发布器已创建: /motor_angles') # ============== 创建 Service Server ============== self.servo_gripper_srv = self.create_service( ServoGripper, 'servo_gripper', self.servo_gripper_callback, callback_group=self.callback_group) self.get_logger().info('Service 服务已创建: /servo_gripper') # ============== 创建 Action Server ============== self.angle_mode_action_server = ActionServer( self, AngleMode, 'angle_mode', self.angle_mode_callback, callback_group=self.callback_group, goal_callback=self.goal_callback, cancel_callback=self.cancel_callback) self.get_logger().info('Action 服务已创建: /angle_mode') # ============== 创建定时器 ============== timer_period = 1.0 / self.publish_rate self.timer = self.create_timer(timer_period, self.timer_callback) self.get_logger().info('=' * 50) self.get_logger().info('机械臂接口节点已启动(集成 TF2 + DH 正运动学)') self.get_logger().info(f' - Topic: /motor_angles ({self.publish_rate} Hz)') self.get_logger().info(f' - Service: /servo_gripper') self.get_logger().info(f' - Action: /angle_mode') self.get_logger().info(f' - TF2: base_link → link1-6 → tool0') self.get_logger().info('=' * 50) # ==================== 初始化相关方法(同第7章) ==================== def _initialize_robot(self): """初始化机械臂""" mode_name = "回零校准" if self.init_mode == 0 else "从当前位置恢复" self.get_logger().info(f'开始初始化机械臂,usb_index={self.usb_index}, init_mode={self.init_mode} ({mode_name})') self.motor_control = MotorControl(self.usb_index) if not self.motor_control.initialized: self.get_logger().error('CAN 设备初始化失败') raise RuntimeError('CAN 设备初始化失败') self.get_logger().info('CAN 通信初始化成功') if self.init_mode == 1: self._recover_from_current() else: self._perform_calibration() def _recover_from_current(self): """从当前位置恢复""" self.get_logger().info('从当前位置恢复中...') current_angles = self.motor_control.read_motor_angles() if not current_angles: raise RuntimeError('无法读取电机角度') self.motor_control.last_degrees = [ current_angles[i] * self.motor_control.motor_ratios[i] for i in range(6) ] self.cached_motor_angles = list(current_angles) self.get_logger().info(f'成功从当前位置恢复: {current_angles}') def _perform_calibration(self): """执行回零校准""" self.get_logger().info('开始回零校准流程...') for joint_index, addr in enumerate(self.motor_control.motor_address): self.get_logger().info(f'正在校准关节 {joint_index + 1}/6 ...') self.motor_control.trigger_home(addr, 0x02) time.sleep(0.05) start_time = time.perf_counter() while True: if self.motor_control.check_home_status(addr): self.get_logger().info(f'关节 {joint_index + 1} 校准完成') break if time.perf_counter() - start_time > self.calibration_timeout: raise RuntimeError(f'关节 {joint_index + 1} 校准超时') time.sleep(0.5) self.get_logger().info(f'校准完成,正在移动到 Home 位置: {self.home_position}') time.sleep(1.0) move_time = self.motor_control.execute_motors_degrees_normal(self.home_position) time.sleep(move_time) self.cached_motor_angles = list(self.home_position) self.get_logger().info('机械臂初始化完成') # ==================== TF2 + 正运动学相关方法 ==================== def convert_to_dh_angles(self, motor_angles): """ 将电机角度转换为 DH 模型角度 Episode 1 机械臂的角度偏移量: 电机角度 → DH 角度的转换关系 """ q1 = motor_angles[0] - 180 q2 = 180 - motor_angles[1] q3 = 83 - motor_angles[2] q4 = motor_angles[3] - 30 q5 = 110 - motor_angles[4] q6 = motor_angles[5] - 30 return [q1, q2, q3, q4, q5, q6] def dh_transform(self, theta, d, a, alpha): """ 计算单个 DH 变换矩阵 Args: theta: 关节角度(弧度) d: 连杆偏移 a: 连杆长度 alpha: 连杆扭转角(弧度) Returns: 4x4 齐次变换矩阵 """ ct, st = np.cos(theta), np.sin(theta) ca, sa = np.cos(alpha), np.sin(alpha) return np.array([ [ct, -st * ca, st * sa, a * ct], [st, ct * ca, -ct * sa, a * st], [0, sa, ca, d ], [0, 0, 0, 1 ] ]) def compute_forward_kinematics(self, motor_angles): """ 计算正运动学,返回各关节的齐次变换矩阵 Args: motor_angles: 6个电机角度(度) Returns: 列表,包含 T_0_1, T_0_2, ..., T_0_6 共6个变换矩阵 每个矩阵表示该关节相对于基座 (base_link) 的位姿 """ # 转换为 DH 角度 dh_angles = self.convert_to_dh_angles(motor_angles) # 转换为弧度 theta = np.deg2rad(dh_angles) # 计算各关节的单独变换矩阵 T_0_1 = self.dh_transform(theta[0], self.d1, self.a1, np.pi / 2) T_1_2 = self.dh_transform(theta[1], 0, self.a2, 0) T_2_3 = self.dh_transform(theta[2], 0, self.a3, np.pi / 2) T_3_4 = self.dh_transform(theta[3], self.d4, 0, -np.pi / 2) T_4_5 = self.dh_transform(theta[4], 0, 0, np.pi / 2) T_5_6 = self.dh_transform(theta[5], self.d6, 0, 0) # 累积变换:各关节相对于基座的变换 transforms = [] transforms.append(T_0_1) # link1 相对于 base_link transforms.append(T_0_1 @ T_1_2) # link2 相对于 base_link transforms.append(T_0_1 @ T_1_2 @ T_2_3) # link3 相对于 base_link transforms.append(T_0_1 @ T_1_2 @ T_2_3 @ T_3_4) # link4 相对于 base_link transforms.append(T_0_1 @ T_1_2 @ T_2_3 @ T_3_4 @ T_4_5) # link5 相对于 base_link transforms.append(T_0_1 @ T_1_2 @ T_2_3 @ T_3_4 @ T_4_5 @ T_5_6) # link6 相对于 base_link return transforms def matrix_to_transform_stamped(self, matrix, parent_frame, child_frame, stamp): """ 将 4x4 齐次变换矩阵转换为 TransformStamped 消息 Args: matrix: 4x4 numpy 数组 parent_frame: 父坐标系名称 child_frame: 子坐标系名称 stamp: 时间戳 Returns: TransformStamped 消息 """ t = TransformStamped() t.header.stamp = stamp t.header.frame_id = parent_frame t.child_frame_id = child_frame # 提取平移 t.transform.translation.x = float(matrix[0, 3]) t.transform.translation.y = float(matrix[1, 3]) t.transform.translation.z = float(matrix[2, 3]) # 提取旋转(矩阵 → 四元数) # tf_transformations.quaternion_from_matrix 需要 4x4 矩阵 q = tf_transformations.quaternion_from_matrix(matrix) t.transform.rotation.x = float(q[0]) t.transform.rotation.y = float(q[1]) t.transform.rotation.z = float(q[2]) t.transform.rotation.w = float(q[3]) return t def publish_static_transforms(self): """发布静态变换""" transforms = [] now = self.get_clock().now().to_msg() # world → base_link(机械臂底座在世界原点) t1 = TransformStamped() t1.header.stamp = now t1.header.frame_id = 'world' t1.child_frame_id = 'base_link' t1.transform.translation.x = 0.0 t1.transform.translation.y = 0.0 t1.transform.translation.z = 0.0 t1.transform.rotation.w = 1.0 transforms.append(t1) # link6 → tool0(末端法兰,固定偏移) t2 = TransformStamped() t2.header.stamp = now t2.header.frame_id = 'link6' t2.child_frame_id = 'tool0' t2.transform.translation.x = 0.0 t2.transform.translation.y = 0.0 t2.transform.translation.z = 0.02 # 2cm 偏移 t2.transform.rotation.w = 1.0 transforms.append(t2) # tool0 → camera_link(相机安装位置,手眼标定结果) t3 = TransformStamped() t3.header.stamp = now t3.header.frame_id = 'tool0' t3.child_frame_id = 'camera_link' t3.transform.translation.x = 0.08 t3.transform.translation.y = 0.0 t3.transform.translation.z = -0.05 q = tf_transformations.quaternion_from_euler(0, math.radians(30), 0, 'sxyz') t3.transform.rotation.x = q[0] t3.transform.rotation.y = q[1] t3.transform.rotation.z = q[2] t3.transform.rotation.w = q[3] transforms.append(t3) # tool0 → gripper_link(夹爪安装位置) t4 = TransformStamped() t4.header.stamp = now t4.header.frame_id = 'tool0' t4.child_frame_id = 'gripper_link' t4.transform.translation.x = 0.12 t4.transform.translation.y = 0.0 t4.transform.translation.z = 0.0 t4.transform.rotation.w = 1.0 transforms.append(t4) self.static_tf_broadcaster.sendTransform(transforms) self.get_logger().info('已发布静态变换: world→base_link, link6→tool0, tool0→camera/gripper') def publish_dynamic_transforms(self): """ 使用正运动学发布所有关节的动态变换 基于 cached_motor_angles 计算各 link 相对于 base_link 的位姿 """ now = self.get_clock().now().to_msg() # 计算正运动学 fk_transforms = self.compute_forward_kinematics(self.cached_motor_angles) # 发布各关节变换,使得 base_link 是 link1 的父亲,link1 是 link2 的父亲,依此类推 link_names = ['link1', 'link2', 'link3', 'link4', 'link5', 'link6'] parent_names = ['base_link'] + link_names[:-1] # ['base_link', 'link1', 'link2', ...] for i, (matrix, parent_name, child_name) in enumerate(zip(fk_transforms, parent_names, link_names)): t = self.matrix_to_transform_stamped(matrix if i == 0 else np.linalg.inv(fk_transforms[i-1]) @ matrix, parent_name, child_name, now) self.tf_broadcaster.sendTransform(t) # ==================== 定时器回调(发布角度 + TF) ==================== def timer_callback(self): """定时器回调:发布电机角度和 TF""" if self.motor_control is None: return msg = MotorAngles() msg.timestamp = self.get_clock().now().to_msg() try: angles = self.motor_control.read_motor_angles() if angles and all(angle is not None for angle in angles): msg.angles = angles msg.success = True msg.message = "读取成功" self.cached_motor_angles = list(angles) else: msg.angles = self.cached_motor_angles msg.success = False msg.message = "读取失败" except Exception as e: msg.angles = self.cached_motor_angles msg.success = False msg.message = f"读取异常: {str(e)}" self.publisher.publish(msg) # 发布动态 TF(使用正运动学) self.publish_dynamic_transforms() # ==================== Service 回调(同第6章) ==================== def servo_gripper_callback(self, request, response): """舵机夹爪服务回调函数""" try: if self.motor_control is None: response.success = False response.message = "电机控制未初始化" response.actual_angle = 0 return response target_angle = request.angle clamped_angle = max(0, min(target_angle, 110)) self.motor_control.servo_gripper(clamped_angle) response.success = True response.actual_angle = clamped_angle response.message = f'夹爪已设置到 {clamped_angle}°' self.get_logger().info(f'[Service] {response.message}') except Exception as e: response.success = False response.message = f'控制失败: {str(e)}' response.actual_angle = 0 self.get_logger().error(f'[Service] {response.message}') return response # ==================== Action 回调(同第7章) ==================== def goal_callback(self, goal_request): """目标接收回调""" self.get_logger().info('[Action] 收到目标请求') if len(goal_request.angles) != 6: self.get_logger().warn(f'[Action] 拒绝目标:角度数量错误') return GoalResponse.REJECT return GoalResponse.ACCEPT def cancel_callback(self, goal_handle): """取消回调""" self.get_logger().info('[Action] 收到取消请求,执行紧急停止') if self.motor_control is not None: try: self.motor_control.set_emergency_stop(active=True) self.motor_control.last_degrees = [ self.cached_motor_angles[i] * self.motor_control.motor_ratios[i] for i in range(6) ] except Exception as e: self.get_logger().error(f'[Action] 紧急停止失败: {str(e)}') return CancelResponse.ACCEPT def angle_mode_callback(self, goal_handle): """角度模式 Action 执行回调""" result = AngleMode.Result() if self.motor_control is None: result.success = False result.message = "电机控制未初始化" result.execution_time = 0.0 result.final_angles = self.cached_motor_angles goal_handle.abort() return result angles = list(goal_handle.request.angles) speed_ratio = goal_handle.request.speed_ratio if not (0.0 < speed_ratio <= 1.0): speed_ratio = max(0.1, min(speed_ratio, 1.0)) self.get_logger().info(f'[Action] 目标角度: {angles}, 速度: {speed_ratio}') start_time = time.perf_counter() try: execution_time = float(self.motor_control.execute_motors_degrees_normal(angles, speed_ratio)) steps = 10 for i in range(steps + 1): if goal_handle.is_cancel_requested: if self.motor_control is not None: self.motor_control.set_emergency_stop(active=False) result.success = False result.message = "任务被取消" result.execution_time = float(time.perf_counter() - start_time) result.final_angles = self.cached_motor_angles goal_handle.canceled() return result feedback = AngleMode.Feedback() feedback.elapsed_time = float(time.perf_counter() - start_time) feedback.progress = float(i / steps) feedback.current_angles = self.cached_motor_angles feedback.status_message = f"运动中... {int(i / steps * 100)}%" goal_handle.publish_feedback(feedback) if i < steps: time.sleep(execution_time / steps) result.success = True result.message = "角度模式运动完成" result.execution_time = float(time.perf_counter() - start_time) result.final_angles = self.cached_motor_angles goal_handle.succeed() return result except Exception as e: result.success = False result.message = f"执行失败: {str(e)}" result.execution_time = float(time.perf_counter() - start_time) result.final_angles = self.cached_motor_angles goal_handle.abort() return result def main(args=None): rclpy.init(args=args) try: node = RobotInterfaceWithTF() executor = MultiThreadedExecutor(num_threads=4) executor.add_node(node) try: executor.spin() finally: executor.shutdown() node.destroy_node() except RuntimeError as e: print(f'启动失败: {e}') except KeyboardInterrupt: pass finally: rclpy.shutdown() if __name__ == '__main__': main()

核心改动说明

新增功能 说明
DH 参数 使用 Episode 1 的 DH 参数:a1=55mm, a2=200mm, a3=56mm, d1=166mm, d4=192mm, d6=55mm
角度转换 convert_to_dh_angles() 将电机角度转换为 DH 模型角度
正运动学 compute_forward_kinematics() 使用 DH 变换矩阵累乘
矩阵 →TF matrix_to_transform_stamped() 将齐次矩阵转换为 ROS2 消息
定时发布 timer_callback() 中同时发布角度和 TF

TF 树结构

world (静态) └── base_link ├── link1 (动态,正运动学) ├── link2 (动态,正运动学) ├── link3 (动态,正运动学) ├── link4 (动态,正运动学) ├── link5 (动态,正运动学) └── link6 (动态,正运动学) └── tool0 (静态) ├── camera_link (静态) └── gripper_link (静态)

4.6 添加入口点

编辑 ~/ros2_ws/src/py_episode/setup.py

entry_points={ 'console_scripts': [ # ... 之前的入口点 ... 'static_tf_pub = py_episode.static_tf_publisher:main', 'dynamic_tf_pub = py_episode.dynamic_tf_publisher:main', 'robot_interface_tf = py_episode.robot_interface_with_tf:main', # 新增 ], },

4.7 测试与验证

# 编译 cd ~/ros2_ws colcon build --packages-select py_episode --symlink-install source install/setup.bash # 启动机械臂接口节点(带 TF) ros2 run py_episode robot_interface_tf --ros-args -p usb_index:=1 -p init_mode:=1 # 另一个终端:查看 TF 树 ros2 run tf2_tools view_frames # 实时查看 base_link 到 link6 的变换 ros2 run tf2_ros tf2_echo base_link link6 # 查看 base_link 到 tool0 的变换 ros2 run tf2_ros tf2_echo base_link tool0 # RViz 可视化 rviz2

预期效果

  • 控制机械臂运动时,RViz 中看到 link1 ~ link6 的坐标轴实时更新
  • tf2_echo 输出的位置应与 RoboDK 等仿真软件一致(验证正运动学正确性)

4.8 TF2 坐标系联动机制

通过观察可以发现,虽然 tool0camera_linkgripper_link 这三个坐标系是通过静态 TF 发布器发布的,但当它们的父坐标系 link6 发生旋转时,这些子坐标系与父坐标系之间的相对位置关系依然保持不变,同时 TF2 会自动计算并更新它们在全局坐标系中的新姿态。

这正是 TF2 坐标变换系统的强大之处:自动维护整个坐标系树的一致性。无论父坐标系如何运动,子坐标系都能自动跟随并保持相对关系。

你可以通过以下命令让机械臂运动到指定角度,观察坐标系的联动效果:

ros2 run py_episode angle_cli 180.0 90.0 83.0 30.0 110.0 30.0 1.0

五、查询坐标变换:Buffer + Listener

5.1 为什么需要查询变换?

前面我们学习了如何发布变换,但在实际应用中,更常见的是查询变换:

场景 查询目标 说明
视觉抓取 物体在相机坐标系中的位置 → 基座坐标系 相机看到物体,机械臂需要知道相对自己的位置
碰撞检测 夹爪相对于障碍物的距离 实时查询两个坐标系之间的距离
示教再现 当前末端位姿相对于基座的位置 记录运动轨迹

5.2 查询变换的核心组件

组件 作用 类比
Buffer 缓存最近的变换数据(默认 10 秒) 数据库
TransformListener 订阅 /tf/tf_static,自动填充 Buffer 数据采集器
lookup_transform() 从 Buffer 中查询两个坐标系之间的变换 数据库查询

5.3 版本迭代:查询变换

版本 0:查询固定坐标系之间的变换

目标:查询 base_linktool0 的变换。

创建文件 ~/ros2_ws/src/py_episode/py_episode/tf_listener.py

#!/usr/bin/env python3 """ 坐标变换监听器 - 版本 0 查询 base_link 到 tool0 的变换 """ import rclpy from rclpy.node import Node # TF2 监听器核心组件 from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener from tf2_ros import TransformException class TFListenerV0(Node): """坐标变换监听器 - 基础版""" def __init__(self): super().__init__('tf_listener_v0') # ============== 创建 Buffer 和 Listener ============== # Buffer: 缓存最近的变换数据(默认10秒) self.tf_buffer = Buffer() # TransformListener: 订阅 /tf 和 /tf_static,自动填充 Buffer self.tf_listener = TransformListener(self.tf_buffer, self) # 创建定时器,定时查询变换 self.timer = self.create_timer(1.0, self.timer_callback) # 1Hz self.get_logger().info('TF 监听器 V0 已启动') def timer_callback(self): """定时查询 base_link 到 tool0 的变换""" # 定义要查询的坐标系 source_frame = 'base_link' # 源坐标系 target_frame = 'tool0' # 目标坐标系 try: # 查询变换 # 第三个参数 Time() 表示获取"最新可用"的变换 transform = self.tf_buffer.lookup_transform( target_frame, # 目标坐标系 source_frame, # 源坐标系 rclpy.time.Time() # 时间(Time() = 最新) ) # 提取变换数据 trans = transform.transform.translation rot = transform.transform.rotation # 计算距离 import math distance = math.sqrt(trans.x**2 + trans.y**2 + trans.z**2) self.get_logger().info( f'变换: {source_frame}{target_frame}\n' f' 位置: ({trans.x:.3f}, {trans.y:.3f}, {trans.z:.3f})\n' f' 旋转: ({rot.x:.3f}, {rot.y:.3f}, {rot.z:.3f}, {rot.w:.3f})\n' f' 距离: {distance:.3f} m') except TransformException as ex: # 查询失败(坐标系不存在、时间戳问题等) self.get_logger().warn(f'无法查询变换: {ex}') def main(args=None): rclpy.init(args=args) node = TFListenerV0() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

关键知识点

  • 导入 BufferTransformListener:从 tf2_ros.buffertf2_ros.transform_listener 导入
  • 创建 Buffer:self.tf_buffer = Buffer(),缓存最近 10 秒的变换数据
  • 创建 Listener:self.tf_listener = TransformListener(self.tf_buffer, self),自动订阅 TF 话题
  • 查询变换:self.tf_buffer.lookup_transform(target_frame, source_frame, time)
  • 时间参数:rclpy.time.Time() 表示"最新可用"
  • 异常处理:TransformException 捕获查询失败

Buffer + Listener 工作流程

⑤ 应用层
④ 查询接口层
③ 数据缓存层
② 数据接收端
① 数据发布端
发布
发布
订阅
订阅
写入
查询
返回结果
用户代码
• 获取 translation (位置)
• 获取 rotation (旋转)
• 坐标转换、路径规划
lookup_transform()
输入: target_frame, source_frame, time
处理: 在 TF 树中查找路径并计算
输出: TransformStamped
Buffer 缓冲区
• 缓存最近 10 秒数据
• 维护完整 TF 树结构
• 支持时间插值
TransformListener
• 自动订阅 /tf 和 /tf_static
• 接收 TFMessage 消息
• 解析变换数据
TF Broadcaster
(机械臂驱动等)
/tf 话题
动态变换
/tf_static 话题
静态变换

测试运行

确保机械臂驱动节点正在运行,然后直接执行脚本:

python3 src/py_episode/py_episode/tf_listener.py

运行结果:

[INFO] [1769243388.818903367] [tf_listener_v0]: TF 监听器 V0 已启动 [INFO] [1769243389.805405969] [tf_listener_v0]: 变换: base_linktool0 位置: (-0.422, -0.001, -0.321) 旋转: (0.708, 0.001, 0.706, -0.001) 距离: 0.530 m

可以看到,程序成功查询到了从 base_linktool0 的坐标变换关系,包括位置、旋转(四元数)和距离信息。

lookup_transform 参数详解

transform = buffer.lookup_transform( target_frame, # 目标坐标系 source_frame, # 源坐标系 time, # 时间点 timeout # 超时(可选) )
参数 类型 说明 常用值
target_frame str 目标坐标系(你想要的坐标系) 'base_link'
source_frame str 源坐标系(数据原始坐标系) 'camera_link'
time Time 查询时间点 rclpy.time.Time()(最新)
timeout Duration 等待超时 Duration(seconds=1.0)

时间参数的三种用法

用法 代码 说明
最新变换 rclpy.time.Time() 获取最新可用的变换
当前时刻 node.get_clock().now() 查询当前时刻的变换
指定时刻 rclpy.time.Time(seconds=123) 查询历史某时刻的变换

版本 1:处理查询失败

改进:添加超时和异常处理,避免程序崩溃。

#!/usr/bin/env python3 """ 坐标变换监听器 - 版本 1 添加超时和异常处理 """ import rclpy from rclpy.node import Node from rclpy.duration import Duration from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener from tf2_ros import TransformException class TFListenerV1(Node): """坐标变换监听器 - 带超时和异常处理""" def __init__(self): super().__init__('tf_listener_v1') self.tf_buffer = Buffer() self.tf_listener = TransformListener(self.tf_buffer, self) # 参数配置 self.source_frame = 'base_link' self.target_frame = 'tool0' self.timeout_sec = 1.0 # 超时时间(秒) self.timer = self.create_timer(1.0, self.timer_callback) self.get_logger().info('TF 监听器 V1 已启动(带超时处理)') def timer_callback(self): """带超时的变换查询""" try: # 使用超时的查询方式 # 如果变换暂时不可用,会等待最多 timeout 时间 transform = self.tf_buffer.lookup_transform( self.target_frame, self.source_frame, rclpy.time.Time(), # 最新变换 timeout=Duration(seconds=self.timeout_sec) # 超时 ) # 提取数据 trans = transform.transform.translation rot = transform.transform.rotation # 计算到原点的距离 import math distance = math.sqrt(trans.x**2 + trans.y**2 + trans.z**2) self.get_logger().info( f'变换: {self.source_frame}{self.target_frame}\n' f' 位置: ({trans.x:.3f}, {trans.y:.3f}, {trans.z:.3f})\n' f' 旋转: ({rot.x:.3f}, {rot.y:.3f}, {rot.z:.3f}, {rot.w:.3f})\n' f' 距离: {distance:.3f} m') except TransformException as ex: # 详细的错误分类处理 error_msg = str(ex) if 'does not exist' in error_msg: self.get_logger().error( f'坐标系不存在: 请检查 "{self.source_frame}" 或 "{self.target_frame}" 是否已发布') elif 'extrapolation' in error_msg.lower(): self.get_logger().warn( f'时间戳问题: {error_msg}') else: self.get_logger().warn(f'查询失败: {error_msg}') def main(args=None): rclpy.init(args=args) node = TFListenerV1() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

新增知识点

  • 设置超时:Duration(seconds=1.0),从 rclpy.duration 导入
  • 捕获异常:try-except TransformException
  • 常见错误分类:坐标系不存在、extrapolation into the future、超时等

测试运行

python3 src/py_episode/py_episode/tf_listener_v1.py

正常运行结果:

[INFO] [1769243444.623790936] [tf_listener_v1]: 变换: base_linktool0 位置: (-0.422, -0.001, -0.321) 旋转: (0.708, 0.001, 0.706, -0.001) 距离: 0.530 m

模拟异常案例

案例 修改参数 预期错误
目标坐标系不存在 target_frame = 'tool0' 改为 target_frame = 'link_not_exist' 坐标系不存在
源坐标系不存在 source_frame = 'base_link' 改为 source_frame = 'fake_frame' 坐标系不存在
TF 未发布 停止机械臂驱动节点后运行 坐标系不存在

关于超时参数

你可能注意到,即使设置了超时,当坐标系不存在时仍然报"坐标系不存在"而非"超时"错误,这是正常的。超时解决的是时机问题,不是存在问题

场景 不加超时 加超时 (1 秒)
程序启动,TF 还没收到第一条数据 立即失败 等 1 秒,数据来了就成功
坐标系存在,但网络延迟了 0.5 秒 立即失败 等到数据到达,成功
坐标系根本不存在(从未发布) 立即失败 等 1 秒后仍然失败

简单理解:超时 = "给数据一点时间到达",而不是"让不存在的东西变存在"。

版本 2:实战案例 - 查询物体在基座坐标系中的位置

场景:相机检测到物体在 camera_link 中的位置为 (0.3, 0.2, 0.5),求物体在 base_link 中的位置。

#!/usr/bin/env python3 """ 坐标变换监听器 - 版本 2 将相机坐标转换为基座坐标 对比 do_transform_point 和 numpy 齐次矩阵相乘两种方法 """ import rclpy from rclpy.node import Node from rclpy.duration import Duration from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener from tf2_ros import TransformException # 用于坐标点变换 from geometry_msgs.msg import PointStamped import tf2_geometry_msgs # 必须导入,提供 do_transform_point import numpy as np # 用于齐次矩阵运算 def quaternion_to_rotation_matrix(q): """四元数转旋转矩阵 (q = [x, y, z, w])""" x, y, z, w = q return np.array([ [1 - 2*(y**2 + z**2), 2*(x*y - w*z), 2*(x*z + w*y)], [ 2*(x*y + w*z), 1 - 2*(x**2 + z**2), 2*(y*z - w*x)], [ 2*(x*z - w*y), 2*(y*z + w*x), 1 - 2*(x**2 + y**2)] ]) def transform_to_matrix(transform): """将 TransformStamped 转换为 4x4 齐次变换矩阵""" # 提取平移 t = transform.transform.translation translation = np.array([t.x, t.y, t.z]) # 提取旋转(四元数) r = transform.transform.rotation quaternion = [r.x, r.y, r.z, r.w] rotation = quaternion_to_rotation_matrix(quaternion) # 构建 4x4 齐次变换矩阵 T = np.eye(4) T[:3, :3] = rotation T[:3, 3] = translation return T class TFListenerV2(Node): """坐标变换监听器 - 坐标点变换版""" def __init__(self): super().__init__('tf_listener_v2') self.tf_buffer = Buffer() self.tf_listener = TransformListener(self.tf_buffer, self) # 模拟相机检测到的物体位置(在 camera_link 坐标系中) self.object_in_camera = PointStamped() self.object_in_camera.header.frame_id = 'camera_link' self.object_in_camera.point.x = 0.3 # 相机前方 30cm self.object_in_camera.point.y = 0.1 # 相机左侧 10cm self.object_in_camera.point.z = 0.5 # 相机下方 50cm self.timer = self.create_timer(1.0, self.timer_callback) self.get_logger().info('TF 监听器 V2 已启动(坐标点变换)') self.get_logger().info( f'物体在 camera_link 中的位置: ' f'({self.object_in_camera.point.x:.2f}, ' f'{self.object_in_camera.point.y:.2f}, ' f'{self.object_in_camera.point.z:.2f})') def timer_callback(self): """将物体从 camera_link 转换到 base_link""" # 更新时间戳 self.object_in_camera.header.stamp = self.get_clock().now().to_msg() try: # 查询变换 transform = self.tf_buffer.lookup_transform( 'base_link', # 目标坐标系 'camera_link', # 源坐标系 rclpy.time.Time(), timeout=Duration(seconds=1.0) ) # ========== 方法 1:使用 do_transform_point ========== object_in_base_1 = tf2_geometry_msgs.do_transform_point( self.object_in_camera, transform ) # ========== 方法 2:使用 numpy 齐次矩阵相乘 ========== # 1. 将变换转为 4x4 齐次矩阵 T = transform_to_matrix(transform) # 2. 将点转为齐次坐标 [x, y, z, 1] p_camera = np.array([ self.object_in_camera.point.x, self.object_in_camera.point.y, self.object_in_camera.point.z, 1.0 # 齐次坐标 ]) # 3. 矩阵相乘:p_base = T @ p_camera p_base = T @ p_camera # 输出对比结果 self.get_logger().info( f'\n========== 坐标转换对比 ==========' f'\n物体在 camera_link: ' f'({self.object_in_camera.point.x:.6f}, ' f'{self.object_in_camera.point.y:.6f}, ' f'{self.object_in_camera.point.z:.6f})' f'\n' f'\n方法1 (do_transform_point):' f'\n 物体在 base_link: ' f'({object_in_base_1.point.x:.6f}, ' f'{object_in_base_1.point.y:.6f}, ' f'{object_in_base_1.point.z:.6f})' f'\n' f'\n方法2 (numpy 齐次矩阵 T @ p):' f'\n 物体在 base_link: ' f'({p_base[0]:.6f}, {p_base[1]:.6f}, {p_base[2]:.6f})' f'\n' f'\n差异 (方法1 - 方法2):' f'\n dx={object_in_base_1.point.x - p_base[0]:.2e}, ' f'dy={object_in_base_1.point.y - p_base[1]:.2e}, ' f'dz={object_in_base_1.point.z - p_base[2]:.2e}' f'\n===================================') except TransformException as ex: self.get_logger().warn(f'坐标转换失败: {ex}') def main(args=None): rclpy.init(args=args) node = TFListenerV2() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

测试结果

========== 坐标转换对比 ========== 物体在 camera_link: (0.300000, 0.100000, 0.500000) 方法1 (do_transform_point): 物体在 base_link: (0.557278, -0.098765, 1.010522) 方法2 (numpy 齐次矩阵 T @ p): 物体在 base_link: (0.557278, -0.098765, 1.010522) 差异 (方法1 - 方法2): dx=0.00e+00, dy=0.00e+00, dz=0.00e+00

新增知识点

  • do_transform_point():对点进行坐标变换,需要导入 tf2_geometry_msgs

  • PointStamped 消息:带坐标系信息的点,包含 header.frame_idpoint

    # 检查 ros2 interface show geometry_msgs/msg/PointStamped # 输出 # This represents a Point with reference coordinate frame and timestamp std_msgs/Header header builtin_interfaces/Time stamp int32 sec uint32 nanosec string frame_id Point point float64 x float64 y float64 z
  • 完整流程:相机坐标 → 查询变换 → 应用变换 → 基座坐标

do_transform_point 的本质

do_transform_point() 本质上就是齐次矩阵相乘。代码中对比了两种方法:

方法 实现 特点
do_transform_point() ROS2 封装好的 API 简洁,自动处理消息类型
T @ p (numpy) 手动构建齐次矩阵并相乘 直观理解原理

齐次坐标变换公式

[xyz1]=[R3×3t3×101×31][xyz1]=T4×4p4×1

其中 R 是旋转矩阵(由四元数转换),t 是平移向量。两种方法的结果完全一致(差异在浮点精度范围内,约 $10^{-16}$)。

5.4 添加入口点

编辑 ~/ros2_ws/src/py_episode/setup.py,添加 TF 监听器入口点:

entry_points={ 'console_scripts': [ # ... 之前的入口点 ... 'static_tf_pub = py_episode.static_tf_publisher:main', 'dynamic_tf_pub = py_episode.dynamic_tf_publisher:main', 'robot_interface_tf = py_episode.robot_interface_with_tf:main', 'tf_listener = py_episode.tf_listener:main', # 新增 ], },

编译:

cd ~/ros2_ws colcon build --packages-select py_episode --symlink-install source install/setup.bash

5.5 测试查询功能

# 终端 1:启动机械臂接口(发布 TF) ros2 run py_episode robot_interface_tf --ros-args -p usb_index:=1 -p init_mode:=1 # 终端 2:运行 TF 监听器 ros2 run py_episode tf_listener # 观察输出

六、简化视觉抓取案例

6.1 场景描述

任务:相机检测到一个物体,机械臂需要移动到该位置进行抓取。

坐标转换流程

相机检测 → 物体在 camera_link 中的位置 (x_c, y_c, z_c) ↓ 查询 TFcamera_linkbase_link 的变换 ↓ 坐标转换 → 物体在 base_link 中的位置 (x_b, y_b, z_b) ↓ 运动规划 → 机械臂移动到 (x_b, y_b, z_b)

6.2 模拟相机检测节点

创建文件 ~/ros2_ws/src/py_episode/py_episode/simple_vision_node.py

#!/usr/bin/env python3 """ 简化视觉检测节点 模拟相机检测到物体,发布物体在 camera_link 中的位置 """ import math import rclpy from rclpy.node import Node from geometry_msgs.msg import PointStamped class SimpleVisionNode(Node): """简化视觉检测节点""" def __init__(self): super().__init__('simple_vision_node') # 创建发布器:发布检测到的物体位置 self.publisher = self.create_publisher( PointStamped, '/detected_object', 10 ) # 模拟检测结果:物体在 camera_link 中的位置 self.object_x = 0.30 # 相机前方 30cm self.object_y = 0.10 # 相机左侧 10cm self.object_z = 0.50 # 相机下方 50cm # 可选:让物体位置稍微晃动,模拟真实检测 self.enable_noise = True self.time_counter = 0.0 # 定时发布(20Hz) self.timer = self.create_timer(0.05, self.timer_callback) self.get_logger().info('=' * 50) self.get_logger().info('简化视觉检测节点已启动') self.get_logger().info(f'发布话题: /detected_object') self.get_logger().info(f'基准位置: ({self.object_x}, {self.object_y}, {self.object_z})') self.get_logger().info('=' * 50) def timer_callback(self): """定时发布模拟检测结果""" msg = PointStamped() # 设置 header msg.header.stamp = self.get_clock().now().to_msg() msg.header.frame_id = 'camera_link' # 关键:指定坐标系 # 设置物体位置 if self.enable_noise: # 添加小幅晃动,模拟真实检测的器噪声 self.time_counter += 0.2 noise_x = 0.5 * math.sin(self.time_counter * 2) noise_y = 0.3 * math.cos(self.time_counter * 3) noise_z = 0.4 * math.sin(self.time_counter * 1.5) msg.point.x = self.object_x + noise_x msg.point.y = self.object_y + noise_y msg.point.z = self.object_z + noise_z else: msg.point.x = self.object_x msg.point.y = self.object_y msg.point.z = self.object_z # 发布消息 self.publisher.publish(msg) self.get_logger().info( f'检测到物体 @ camera_link: ' f'({msg.point.x:.3f}, {msg.point.y:.3f}, {msg.point.z:.3f})') def main(args=None): rclpy.init(args=args) node = SimpleVisionNode() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

功能

  • 定时发布一个模拟的物体位置(带小幅晃动模拟真实检测)
  • 发布为 PointStamped 消息(包含坐标系信息 camera_link
  • 话题:/detected_object

6.3 坐标转换与抓取节点

创建文件 ~/ros2_ws/src/py_episode/py_episode/simple_grasp_node.py

#!/usr/bin/env python3 """ 简化抓取节点 订阅物体位置,转换坐标,打印机械臂应该去的位置 """ import rclpy from rclpy.node import Node from rclpy.duration import Duration from geometry_msgs.msg import PointStamped, TransformStamped from visualization_msgs.msg import Marker from tf2_ros.buffer import Buffer from tf2_ros.transform_listener import TransformListener from tf2_ros import TransformException, TransformBroadcaster import tf2_geometry_msgs # 提供 do_transform_point class SimpleGraspNode(Node): """简化抓取节点 - 坐标转换演示""" def __init__(self): super().__init__('simple_grasp_node') # ============== TF2 组件 ============== self.tf_buffer = Buffer() self.tf_listener = TransformListener(self.tf_buffer, self) # ============== 控制变量 ============== self.publish_object_frame = True # 是否发布物体 TF 帧到 RViz self.publish_object_marker = True # 是否发布物体 Marker 到 RViz # ============== TF 广播器(用于发布物体帧) ============== self.tf_broadcaster = TransformBroadcaster(self) # ============== Marker 发布器 ============== self.marker_pub = self.create_publisher(Marker, '/detected_object_marker', 10) # ============== 订阅检测结果 ============== self.subscription = self.create_subscription( PointStamped, '/detected_object', self.object_callback, 10 ) # 目标坐标系 self.target_frame = 'base_link' # 计数器(用于限制打印频率) self.callback_count = 0 self.get_logger().info('=' * 50) self.get_logger().info('简化抓取节点已启动') self.get_logger().info(f'订阅话题: /detected_object') self.get_logger().info(f'目标坐标系: {self.target_frame}') self.get_logger().info(f'发布物体 TF 帧: {self.publish_object_frame}') self.get_logger().info(f'发布物体 Marker: {self.publish_object_marker}') self.get_logger().info('=' * 50) def object_callback(self, msg: PointStamped): """ 物体检测回调 1. 接收物体在 camera_link 中的位置 2. 转换到 base_link 坐标系 3. 打印转换后的位置(实际项目中会发送给机械臂) """ self.callback_count += 1 # 限制打印频率(每5次打印一次) if self.callback_count % 5 != 1: return source_frame = msg.header.frame_id # camera_link try: # 查询 camera_link → base_link 的变换 transform = self.tf_buffer.lookup_transform( self.target_frame, # 目标坐标系 source_frame, # 源坐标系 rclpy.time.Time(), # 最新变换 timeout=Duration(seconds=1.0) ) # 使用 do_transform_point 将物体坐标转换到 base_link object_in_base = tf2_geometry_msgs.do_transform_point(msg, transform) # 打印结果 self.get_logger().info( f'\n━━━━━ 坐标转换结果 ━━━━━' f'\n 物体在 {source_frame}:' f'\n ({msg.point.x:.3f}, {msg.point.y:.3f}, {msg.point.z:.3f})' f'\n 物体在 {self.target_frame}:' f'\n ({object_in_base.point.x:.3f}, ' f'{object_in_base.point.y:.3f}, ' f'{object_in_base.point.z:.3f})' f'\n━━━━━━━━━━━━━━━━━━━━━' f'\n → 机械臂应移动到: ' f'({object_in_base.point.x:.3f}, ' f'{object_in_base.point.y:.3f}, ' f'{object_in_base.point.z:.3f})') # ============================================== # 在实际项目中,这里会调用 Action Client # 发送运动指令给机械臂 # 例如: # self.send_move_command( # object_in_base.point.x, # object_in_base.point.y, # object_in_base.point.z # ) # ============================================== # ============== 发布物体 TF 帧到 RViz ============== if self.publish_object_frame: self.publish_object_tf(object_in_base) # ============== 发布物体 Marker 到 RViz ============== if self.publish_object_marker: self.publish_marker(object_in_base) except TransformException as ex: self.get_logger().warn( f'坐标转换失败: {ex}\n' f'请确保 TF 发布器正在运行') def publish_object_tf(self, point_stamped: PointStamped): """发布物体位置作为 TF 帧""" t = TransformStamped() t.header.stamp = self.get_clock().now().to_msg() t.header.frame_id = self.target_frame # base_link t.child_frame_id = 'detected_object' t.transform.translation.x = point_stamped.point.x t.transform.translation.y = point_stamped.point.y t.transform.translation.z = point_stamped.point.z t.transform.rotation.w = 1.0 # 无旋转 self.tf_broadcaster.sendTransform(t) def publish_marker(self, point_stamped: PointStamped): """发布物体位置作为可视化 Marker(球体)""" marker = Marker() marker.header.frame_id = self.target_frame marker.header.stamp = self.get_clock().now().to_msg() marker.ns = 'detected_object' marker.id = 0 marker.type = Marker.SPHERE marker.action = Marker.ADD marker.pose.position.x = point_stamped.point.x marker.pose.position.y = point_stamped.point.y marker.pose.position.z = point_stamped.point.z marker.pose.orientation.w = 1.0 # 球体大小(直径 5cm) marker.scale.x = 0.1 marker.scale.y = 0.1 marker.scale.z = 0.1 # 颜色(绿色) marker.color.r = 0.0 marker.color.g = 1.0 marker.color.b = 0.0 marker.color.a = 0.8 # 生命周期(0 表示永久,直到被覆盖) marker.lifetime.sec = 0 self.marker_pub.publish(marker) def main(args=None): rclpy.init(args=args) node = SimpleGraspNode() try: rclpy.spin(node) except KeyboardInterrupt: pass finally: node.destroy_node() rclpy.shutdown() if __name__ == '__main__': main()

功能

  1. 订阅 /detected_object(物体在相机坐标系中的位置)
  2. 使用 lookup_transform() 查询 camera_linkbase_link
  3. 使用 do_transform_point() 进行坐标转换
  4. 打印目标位置(实际项目中会调用 Action Client 发送运动指令)

6.4 完整测试流程

# 终端 1:启动机械臂接口(带 TF) ros2 run py_episode robot_interface_tf --ros-args -p usb_index:=1 -p init_mode:=1 # 终端 2:模拟视觉检测 python3 src/py_episode/py_episode/simple_vision_node.py # 终端 3:抓取节点(坐标转换 + 运动控制) python3 src/py_episode/py_episode/simple_grasp_node.py # 观察输出: ━━━━━━━━━━━━━━━━━━━━━ → 机械臂应移动到: (0.559, -0.097, 1.015) [INFO] [1769261192.655663591] [simple_grasp_node]: ━━━━━ 坐标转换结果 ━━━━━ 物体在 camera_link: (0.302, 0.102, 0.498) 物体在 base_link: (0.555, -0.101, 1.011) ━━━━━━━━━━━━━━━━━━━━━

tf2

6.5 真实视觉抓取的改进方向

本章的简化案例不涉及以下内容(留给后续章节):

内容 本章处理 后续完整实现
相机驱动 模拟固定位置 真实相机节点(RealSense、USB 摄像头)
目标检测 固定坐标 YOLO、深度学习、传统视觉算法
运动规划 打印坐标 MoveIt、轨迹规划、碰撞检测
手眼标定 假设已知 标定算法

七、调试技巧

7.1 常见错误及解决方案

错误信息 原因 解决方案
"xxx" does not exist 坐标系名称拼写错误或未发布 检查坐标系名称,使用 view_frames 查看
Extrapolation into the future 请求的时间晚于最新数据 使用 Time() 获取最新变换
Lookup would require extrapolation into the past Buffer 中没有足够历史数据 增加 Buffer 缓存时间或降低查询频率
变换不更新 Broadcaster 未持续发布 确认发布频率,检查定时器回调

7.2 调试工具使用

工具 1:view_frames

# 生成 TF 树 PDF(等待5秒收集数据) ros2 run tf2_tools view_frames # 输出文件:frames.pdf # 包含:所有坐标系、连接关系、发布频率

工具 2:tf2_echo

# 实时显示两个坐标系之间的变换 ros2 run tf2_ros tf2_echo <source_frame> <target_frame> # 示例 ros2 run tf2_ros tf2_echo base_link tool0 # 输出: # Translation: [0.450, 0.120, 0.680] # Rotation: in Quaternion [0.000, 0.000, 0.259, 0.966] # Rotation: in RPY (radian) [0.000, -0.000, 0.524]

工具 3:tf2_monitor

# 监控 TF 延迟 ros2 run tf2_ros tf2_monitor # 输出: # RESULTS: for all # Frame: link1, published by <unknown>, Average Delay: 0.0028, Max Delay: 0.0050

工具 4:RViz TF 显示

rviz2 # 在 RViz 中: # 1. 添加 "TF" 显示类型 # 2. 勾选 "Show Names"(显示坐标系名称) # 3. 勾选 "Show Axes"(显示坐标轴) # 4. 调整 "Marker Scale"(坐标轴大小)

7.3 系统性调试流程

1. 确认需求 - 要查询哪两个坐标系之间的变换? ↓ 2. 检查坐标系是否存在 - 运行 view_frames,查看 frames.pdf - 确认坐标系名称拼写正确 ↓ 3. 检查发布频率 - ros2 topic hz /tf - 确认 Broadcaster 正常运行 ↓ 4. 检查时间戳 - ros2 run tf2_ros tf2_monitor - 确认没有异常延迟 ↓ 5. 检查查询代码 - 使用 Time() 获取最新变换 - 添加超时处理 ↓ 6. RViz 可视化验证 - 观察坐标轴是否符合预期

八、本章总结

8.1 核心知识点回顾

知识点 要点 Python 类/函数
Frame 坐标系,有名称和位置 -
Transform 两个坐标系之间的关系(平移 + 旋转) TransformStamped
Static Transform 固定关系(传感器位置) StaticTransformBroadcaster
Dynamic Transform 运动关系(关节角度) TransformBroadcaster
TF Tree 树状坐标系结构 -
Buffer 缓存变换数据 Buffer()
Listener 订阅 TF 话题 TransformListener(buffer, node)
查询变换 获取两个坐标系之间的变换 buffer.lookup_transform()

8.2 机械臂 TF 树完整结构

world └── base_link ← 静态 └── link1 ← 动态 └── link2 ← 动态 └── link3 ← 动态 └── link4 ← 动态 └── link5 ← 动态 └── link6 ← 动态 └── tool0 ← 动态或静态 ├── gripper_link ← 静态 └── camera_link ← 静态

8.3 四种通信方式完整对比

通信方式 特点 典型场景 底层实现
Topic 单向、持续、异步 传感器数据流 Publisher/Subscriber
Service 双向、一次性、同步 配置读写、快速操作 Server/Client
Action 双向、持续反馈、可取消 长时间任务 ActionServer/ActionClient
TF2 坐标系管理、查询 空间关系、坐标转换 Topic(/tf + /tf_static