ROS2系列教程:TF树与多坐标系管理(C++/Python)
本文摘要
上一篇文章里,每条变换是"父 → 子"单向的。当你把所有变换按父子关系连起来,就得到一棵TF 树(TF Tree)map│odom│base_link/…
本文是 ROS2 系列教程的第 15 篇
本文是 ROS2 系列教程的第 15 篇:TF 树与多坐标系管理。上一篇我们学会了广播与查询单条变换,但真实机器人从不只有一对坐标系:地图、里程计、底盘、雷达、相机、两个轮子、机械臂每个关节……它们共同组成一棵 TF 树。本篇系统讲解多坐标系管理:TF 树的结构与规则(不允许环)、经典
map/odom/base_link三件套架构、如何用tf2_ros::MessageFilter做时间同步、坐标系命名与多机器人隔离,以及批量查询的工程实践。学完你将能设计出规范、可排障的多坐标系系统。
一、TF 树:从一对到一片
1.1 为什么是“树”而不是“网”
上一篇文章里,每条变换是“父 → 子”单向的。当你把所有变换按父子关系连起来,就得到一棵 TF 树(TF Tree):
map │ odom │ base_link / \ laser camera │ lens树的规则(必须遵守):
- 每个坐标系只有一个父坐标系(
base_link只能有一个父)。 - 整棵树的父子关系不能成环(A→B→C→A 是禁止的)。
- 树可以有多棵独立子树,但子树之间无法互查变换(会报 “Could not find transform”)。
为什么是树不是网?因为 tf2 要靠“沿树做矩阵链乘法”来推导任意两点间的变换。如果有环或一个点有多个父,推导结果就不唯一,系统无法工作。
1.2 查看 TF 树的方法
# 文本树ros2 run tf2_ros view_frames
# 实时可视化rqt_tf_tree
# RViz 3D 显示rviz2 # 添加 TF 显示view_frames 输出会在当前目录生成 frames.pdf,顶部还有统计:广播频率、平均延迟、最大延迟。调试 TF 第一步永远是看树。
1.3 树与查询的关系
查询 lookupTransform(target, source) 时,tf2 做的是:
- 在树中找到
target和source的最近公共祖先。 - 从
source沿树向上到祖先(取逆变换),再向下到target。 - 链式相乘得到最终矩阵(第 13 篇的矩阵乘法)。
所以树连得通,查询就成功;树断了,查询就报错。这是所有 TF 排障的总纲。
二、经典三件套:map / odom / base_link
2.1 为什么要三个坐标系
很多新手困惑:map 和 odom 都连到 base_link,为什么不直接 map → base_link?
因为 map(世界地图)和 odom(里程计)是两种不同精度的坐标系:
| odom(里程计坐标系) | map(地图坐标系) | |
|---|---|---|
| 来源 | 轮速积分/IMU 推算 | 定位模块(AMCL 等) |
| 精度 | 短时准、长时间漂移 | 全局准、可能有跳变 |
| 更新 | 高频连续(100 Hz) | 低频(1-10 Hz),可能跳变 |
| 用途 | 控制、局部规划 | 全局规划、导航 |
标准架构:
map ──(定位修正)──→ odom ──(里程计)──→ base_link ──→ sensor...- odom → base_link:里程计发布,高频平滑,控制依赖它。
- map → odom:定位模块发布,低频修正,把“里程计世界”拉回“地图世界”。
- map → base_link:下游(如导航)查询时由 tf2 自动链式推导,无需单独发布。
好处:定位跳变时只改 map→odom 一小段,odom→base_link 保持平滑,控制器不会因定位跳变而抖动。
2.2 搭建三件套(可运行)
# 终端 1:map -> odom(静态,定位模块占位)ros2 run tf2_ros static_transform_publisher 0 0 0 0 0 0 map odom
# 终端 2:odom -> base_link(动态,里程计占位)ros2 run tf2_ros static_transform_publisher 0 0 0 0 0 0 odom base_link查看树:
ros2 run tf2_ros view_frames你会看到 map → odom → base_link 三段树。虽然目前都是静态的,但结构已经正确。
2.3 用 C++ 搭建三件套
// tf_tree_demo.cpp:同时发布 map->odom 静态 与 odom->base_link 动态#include <rclcpp/rclcpp.hpp>#include <tf2_ros/static_transform_broadcaster.h>#include <tf2_ros/transform_broadcaster.h>#include <geometry_msgs/msg/transform_stamped.hpp>#include <tf2/LinearMath/Quaternion.h>#include <chrono>
class TfTreeDemo : public rclcpp::Node{public: TfTreeDemo() : Node("tf_tree_demo"), x_(0.0), yaw_(0.0) { static_tf_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(this); dynamic_tf_ = std::make_shared<tf2_ros::TransformBroadcaster>(this);
// map -> odom 静态(定位模块占位:无偏移) geometry_msgs::msg::TransformStamped map_odom; map_odom.header.stamp = this->now(); map_odom.header.frame_id = "map"; map_odom.child_frame_id = "odom"; map_odom.transform.translation.x = 0.0; map_odom.transform.translation.y = 0.0; map_odom.transform.translation.z = 0.0; map_odom.transform.rotation.w = 1.0; static_tf_->sendTransform(map_odom);
timer_ = this->create_wall_timer( std::chrono::milliseconds(100), std::bind(&TfTreeDemo::timer_cb, this)); }
private: void timer_cb() { x_ += 0.01; yaw_ += 0.005; // 模拟里程计运动
geometry_msgs::msg::TransformStamped odom_base; odom_base.header.stamp = this->now(); odom_base.header.frame_id = "odom"; odom_base.child_frame_id = "base_link"; odom_base.transform.translation.x = x_; odom_base.transform.translation.y = 0.0; odom_base.transform.translation.z = 0.0; tf2::Quaternion q; q.setRPY(0.0, 0.0, yaw_); odom_base.transform.rotation.x = q.x(); odom_base.transform.rotation.y = q.y(); odom_base.transform.rotation.z = q.z(); odom_base.transform.rotation.w = q.w(); dynamic_tf_->sendTransform(odom_base); }
std::shared_ptr<tf2_ros::StaticTransformBroadcaster> static_tf_; std::shared_ptr<tf2_ros::TransformBroadcaster> dynamic_tf_; rclcpp::TimerBase::SharedPtr timer_; double x_, yaw_;};
int main(int argc, char** argv){ rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<TfTreeDemo>()); rclcpp::shutdown(); return 0;}2.4 用 Python 搭建三件套
#!/usr/bin/env python3"""tf_tree_demo_py.py:map->odom 静态 + odom->base_link 动态"""import rclpyfrom rclpy.node import Nodefrom tf2_ros import StaticTransformBroadcaster, TransformBroadcasterfrom geometry_msgs.msg import TransformStampedfrom tf_transformations import quaternion_from_euler
class TfTreeDemo(Node): def __init__(self): super().__init__('tf_tree_demo_py') self.static_tf = StaticTransformBroadcaster(self) self.dynamic_tf = TransformBroadcaster(self)
# map -> odom 静态 map_odom = TransformStamped() map_odom.header.stamp = self.get_clock().now().to_msg() map_odom.header.frame_id = 'map' map_odom.child_frame_id = 'odom' map_odom.transform.translation.x = 0.0 map_odom.transform.translation.y = 0.0 map_odom.transform.translation.z = 0.0 map_odom.transform.rotation.w = 1.0 self.static_tf.sendTransform(map_odom)
self.x = 0.0 self.yaw = 0.0 self.timer = self.create_timer(0.1, self.timer_cb)
def timer_cb(self): self.x += 0.01 self.yaw += 0.005
odom_base = TransformStamped() odom_base.header.stamp = self.get_clock().now().to_msg() odom_base.header.frame_id = 'odom' odom_base.child_frame_id = 'base_link' odom_base.transform.translation.x = self.x odom_base.transform.translation.y = 0.0 odom_base.transform.translation.z = 0.0 q = quaternion_from_euler(0.0, 0.0, self.yaw) odom_base.transform.rotation.x = q[0] odom_base.transform.rotation.y = q[1] odom_base.transform.rotation.z = q[2] odom_base.transform.rotation.w = q[3] self.dynamic_tf.sendTransform(odom_base)
def main(args=None): rclpy.init(args=args) node = TfTreeDemo() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown()
if __name__ == '__main__': main()关键观察:把静态广播和动态广播放在同一个节点没问题——tf2 不关心谁发布,只关心树结构。但同一对父子只允许一个发布者(否则数据互相覆盖,数值跳变)。
三、传感器树的搭建
3.1 挂传感器:静态变换
传感器(激光/相机/IMU)相对底盘固定,用静态变换挂到 base_link 下:
# laser 在 base_link 前方 0.15m、高 0.1m,俯仰 0ros2 run tf2_ros static_transform_publisher 0.15 0 0.1 0 0 0 base_link laser
# camera 在 base_link 前方 0.1m、高 0.2m,绕 x 转 90°(朝下看)ros2 run tf2_ros static_transform_publisher 0.1 0 0.2 1.5708 0 0 base_link camera树变为:
map → odom → base_link → laser └→ camera注意相机俯仰用弧度:1.5708 ≈ π/2。搞错单位是静态变换最常见错误。
3.2 挂关节:动态变换
机械臂/舵机关节运动,用动态变换。每个关节是一对父子:
base_link → joint1 → joint2 → joint3 → ... → gripper每个关节的变换 = 上一个关节的位姿 × 当前关节角度产生的旋转。这是第 35-40 篇机械臂篇章的主线,本篇先建立概念。
3.3 C++ 批量广播多对静态变换
// multi_static.cpp:一次性广播 3 对静态变换#include <rclcpp/rclcpp.hpp>#include <tf2_ros/static_transform_broadcaster.h>#include <geometry_msgs/msg/transform_stamped.hpp>
class MultiStatic : public rclcpp::Node{public: MultiStatic() : Node("multi_static") { tf_ = std::make_shared<tf2_ros::StaticTransformBroadcaster>(this); std::vector<geometry_msgs::msg::TransformStamped> transforms;
// base_link -> laser transforms.push_back(make_tf("base_link", "laser", 0.15, 0.0, 0.1, 0, 0, 0)); // base_link -> camera transforms.push_back(make_tf("base_link", "camera", 0.1, 0.0, 0.2, 1.5708, 0, 0)); // base_link -> imu transforms.push_back(make_tf("base_link", "imu", 0.0, 0.0, 0.05, 0, 0, 0));
tf_->sendTransform(transforms); // 批量发送 RCLCPP_INFO(this->get_logger(), "已广播 3 对静态变换"); }
private: geometry_msgs::msg::TransformStamped make_tf( const std::string& parent, const std::string& child, double x, double y, double z, double roll, double pitch, double yaw) { geometry_msgs::msg::TransformStamped t; t.header.stamp = this->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; tf2::Quaternion q; q.setRPY(roll, pitch, yaw); t.transform.rotation.x = q.x(); t.transform.rotation.y = q.y(); t.transform.rotation.z = q.z(); t.transform.rotation.w = q.w(); return t; }
std::shared_ptr<tf2_ros::StaticTransformBroadcaster> tf_;};
int main(int argc, char** argv){ rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<MultiStatic>()); rclcpp::shutdown(); return 0;}批量 vs 逐个:sendTransform(vector) 一次发多条,比循环逐个发更高效(一次网络消息)。静态变换也可以这样批量。
3.4 Python 批量广播
#!/usr/bin/env python3"""multi_static_py.py:批量广播多对静态变换"""import rclpyfrom rclpy.node import Nodefrom tf2_ros import StaticTransformBroadcasterfrom geometry_msgs.msg import TransformStampedfrom tf_transformations import quaternion_from_euler
class MultiStatic(Node): def __init__(self): super().__init__('multi_static_py') self.tf = StaticTransformBroadcaster(self) transforms = [ self.make_tf('base_link', 'laser', 0.15, 0.0, 0.1, 0, 0, 0), self.make_tf('base_link', 'camera', 0.1, 0.0, 0.2, 1.5708, 0, 0), self.make_tf('base_link', 'imu', 0.0, 0.0, 0.05, 0, 0, 0), ] self.tf.sendTransform(transforms) self.get_logger().info('已广播 3 对静态变换')
def make_tf(self, parent, child, x, y, z, roll, pitch, yaw): t = TransformStamped() t.header.stamp = self.get_clock().now().to_msg() 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 q = quaternion_from_euler(roll, pitch, yaw) t.transform.rotation.x = q[0] t.transform.rotation.y = q[1] t.transform.rotation.z = q[2] t.transform.rotation.w = q[3] return t
def main(args=None): rclpy.init(args=args) node = MultiStatic() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown()
if __name__ == '__main__': main()四、MessageFilter:带时间戳的同步查询
4.1 问题:数据到达时 TF 可能还没就绪
传感器数据(如激光点云)带 header.stamp 时间戳,但处理它的时刻,Buffer 里可能还没有“那个时刻”的 TF(数据先到、TF 后到,或反之)。直接 lookupTransform 会抛 extrapolation/timeout 异常。
解决方案:tf2_ros::MessageFilter——它把“数据消息”和“TF 可用性”绑在一起:只有当数据时间戳对应的 TF 可用时,才触发回调。
4.2 C++ 使用 MessageFilter
// message_filter_demo.cpp:TF 就绪后才处理 LaserScan#include <rclcpp/rclcpp.hpp>#include <tf2_ros/transform_listener.h>#include <tf2_ros/buffer.h>#include <tf2_ros/message_filter.h>#include <message_filters/subscriber.h>#include <sensor_msgs/msg/laser_scan.hpp>
class MessageFilterDemo : public rclcpp::Node{public: MessageFilterDemo() : Node("message_filter_demo"), tf_buffer_(this->get_clock()) { tf_listener_ = std::make_shared<tf2_ros::TransformListener>(tf_buffer_); scan_sub_.subscribe(this, "scan"); tf_filter_ = std::make_shared<tf2_ros::MessageFilter<sensor_msgs::msg::LaserScan>>( scan_sub_, tf_buffer_, "map", 10, this->get_node_base_interface(), this->get_node_logging_interface(), this->get_node_clock_interface()); tf_filter_->registerCallback( std::bind(&MessageFilterDemo::scan_cb, this, std::placeholders::_1)); }
private: void scan_cb(const sensor_msgs::msg::LaserScan::SharedPtr scan) { RCLCPP_INFO(this->get_logger(), "收到 scan (stamp=%.3f),TF 已就绪,可以安全查询", scan->header.stamp.sec + scan->header.stamp.nanosec * 1e-9); }
tf2_ros::Buffer tf_buffer_; std::shared_ptr<tf2_ros::TransformListener> tf_listener_; message_filters::Subscriber<sensor_msgs::msg::LaserScan> scan_sub_; std::shared_ptr<tf2_ros::MessageFilter<sensor_msgs::msg::LaserScan>> tf_filter_;};
int main(int argc, char** argv){ rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<MessageFilterDemo>()); rclcpp::shutdown(); return 0;}MessageFilter 构造参数:(订阅器, buffer, target_frame, 队列长度, node 接口...)。target_frame 是最终要变换到的坐标系(这里是 map)——filter 会等待“该消息时间戳下、map 方向的 TF 可用”。
4.3 Python 使用 MessageFilter
#!/usr/bin/env python3"""message_filter_demo_py.py:TF 就绪后处理 LaserScan"""import rclpyfrom rclpy.node import Nodefrom tf2_ros import Buffer, TransformListener, MessageFilterfrom message_filters import Subscriberfrom sensor_msgs.msg import LaserScan
class MessageFilterDemo(Node): def __init__(self): super().__init__('message_filter_demo_py') self.tf_buffer = Buffer() self.tf_listener = TransformListener(self.tf_buffer, self) self.scan_sub = Subscriber(self, LaserScan, 'scan') self.tf_filter = MessageFilter( self.scan_sub, self.tf_buffer, 'map', 10) self.tf_filter.registerCallback(self.scan_cb)
def scan_cb(self, scan): self.get_logger().info( f'收到 scan (stamp={scan.header.stamp.sec}.' f'{scan.header.stamp.nanosec:09d}),TF 已就绪')
def main(args=None): rclpy.init(args=args) node = MessageFilterDemo() try: rclpy.spin(node) finally: node.destroy_node() rclpy.shutdown()
if __name__ == '__main__': main()对比直接查询:无 filter 时你要在回调里 try-catch + 重试;有 filter 后回调触发即保证 TF 可用,代码干净很多。处理带时间戳的传感器数据(激光/相机/点云)时,MessageFilter 是标准姿势。
五、坐标系命名与多机器人隔离
5.1 命名规范
- 小写 + 下划线:
base_link、laser、camera_link(ROS 风格)。 - 避免空格和特殊字符:TF 名字是字符串,但空格会带来解析麻烦。
- 语义清晰:
camera_link(安装支架)与camera_optical(光学中心)分开命名是常见的做法。 - 一个坐标系一个语义:不要复用名字表示不同位置。
5.2 多机器人命名空间
多机器人系统(如双车协作)中,两台机器人各有自己的 TF 树。直接拼树会导致坐标系名字冲突。标准方案:
- 坐标系名加前缀:
robot1/base_link、robot2/base_link。 - 各自独立 /tf 话题:重映射
tf := robot1/tf,tf_static := robot1/tf_static。 - 地图合并时:额外发布
map → robot1/odom与map → robot2/odom,把两棵子树接到同一棵树上。
示例(launch 重映射):
<node pkg="tf2_ros" exec="static_transform_publisher" args="0 0 0 0 0 0 map robot1/odom" output="screen"/><node pkg="tf2_ros" exec="static_transform_publisher" args="0 0 0 0 0 0 map robot2/odom" output="screen"/>5.3 命名空间与 TF 的常见坑
| 现象 | 原因 | 解决 |
|---|---|---|
帧名带 robot1/ 但查询没带 |
坐标系名是纯字符串,不会自动加命名空间 | 广播和查询必须用完整名 robot1/base_link |
两车都用 base_link |
树冲突/查询错乱 | 加前缀或独立话题 |
| 重映射了 /tf 但没重映射 /tf_static | 静态树没进来 | 两个话题都要重映射 |
六、工程实践:完整的多坐标系节点
把本篇所有知识合成一个实用节点:订阅激光雷达 → 等 TF → 变换到 map → 发布障碍物坐标。
6.1 C++ 完整示例
// scan_to_map.cpp:LaserScan + MessageFilter -> 变换到 map -> 发布 PointStamped#include <rclcpp/rclcpp.hpp>#include <tf2_ros/transform_listener.h>#include <tf2_ros/buffer.h>#include <tf2_ros/message_filter.h>#include <tf2_geometry_msgs/tf2_geometry_msgs.hpp>#include <message_filters/subscriber.h>#include <sensor_msgs/msg/laser_scan.hpp>#include <geometry_msgs/msg/point_stamped.hpp>
class ScanToMap : public rclcpp::Node{public: ScanToMap() : Node("scan_to_map"), tf_buffer_(this->get_clock()) { tf_listener_ = std::make_shared<tf2_ros::TransformListener>(tf_buffer_); scan_sub_.subscribe(this, "scan"); tf_filter_ = std::make_shared<tf2_ros::MessageFilter<sensor_msgs::msg::LaserScan>>( scan_sub_, tf_buffer_, "map", 10, this->get_node_base_interface(), this->get_node_logging_interface(), this->get_node_clock_interface()); tf_filter_->registerCallback( std::bind(&ScanToMap::scan_cb, this, std::placeholders::_1)); marker_pub_ = this->create_publisher<geometry_msgs::msg::PointStamped>( "obstacle_in_map", 10); }
private: void scan_cb(const sensor_msgs::msg::LaserScan::SharedPtr scan) { // 找最近障碍(最简单:取第一束距离最小的点) size_t i = 0; float min_r = std::numeric_limits<float>::max(); for (size_t j = 0; j < scan->ranges.size(); ++j) { if (scan->ranges[j] > scan->range_min && scan->ranges[j] < scan->range_max && scan->ranges[j] < min_r) { min_r = scan->ranges[j]; i = j; } } double angle = scan->angle_min + i * scan->angle_increment;
// laser 坐标系下的障碍点 geometry_msgs::msg::PointStamped laser_pt; laser_pt.header = scan->header; // 沿用数据的时间戳和 frame laser_pt.point.x = min_r * std::cos(angle); laser_pt.point.y = min_r * std::sin(angle); laser_pt.point.z = 0.0;
// 变换到 map(MessageFilter 保证此刻 TF 可用) try { auto transform = tf_buffer_.lookupTransform( "map", scan->header.frame_id, scan->header.stamp, std::chrono::seconds(1)); auto map_pt = tf2::doTransform(laser_pt, transform); map_pt.header.frame_id = "map"; map_pt.header.stamp = this->now(); marker_pub_->publish(map_pt); RCLCPP_INFO(this->get_logger(), "最近障碍在 map: (%.2f, %.2f)", map_pt.point.x, map_pt.point.y); } catch (const tf2::TransformException& ex) { RCLCPP_WARN(this->get_logger(), "变换失败: %s", ex.what()); } }
tf2_ros::Buffer tf_buffer_; std::shared_ptr<tf2_ros::TransformListener> tf_listener_; message_filters::Subscriber<sensor_msgs::msg::LaserScan> scan_sub_; std::shared_ptr<tf2_ros::MessageFilter<sensor_msgs::msg::LaserScan>> tf_filter_; rclcpp::Publisher<geometry_msgs::msg::PointStamped>::SharedPtr marker_pub_;};
int main(int argc, char** argv){ rclcpp::init(argc, argv); rclcpp::spin(std::make_shared<ScanToMap>()); rclcpp::shutdown(); return 0;}package.xml 需声明:rclcpp、tf2_ros、tf2_geometry_msgs、message_filters、sensor_msgs、geometry_msgs。
6.2 组合运行验证
# 终端 1:完整 TF 树(三件套 + 传感器)ros2 run tf2_demo tf_tree_demo &ros2 run tf2_demo multi_static &
# 终端 2:模拟激光数据(发布一个简单 scan)ros2 topic pub /scan sensor_msgs/msg/LaserScan \ "{angle_min: -1.57, angle_max: 1.57, angle_increment: 0.01, \ range_min: 0.1, range_max: 10.0, \ ranges: [1.0, 0.8, 0.6, 0.4, 0.2, 0.3, 0.5, 0.7, 0.9, 1.0]}" \ --rate 5
# 终端 3:运行处理节点ros2 run tf2_demo scan_to_map你会看到 scan_to_map 每收到一帧 scan 就输出“最近障碍在 map”的坐标。这就是建图、导航、避障系统中“感知数据上到全局坐标系”的雏形。
6.3 观察树
ros2 run tf2_ros view_framesrqt_tf_tree树应类似:
map → odom → base_link → laser └→ camera └→ imu七、常见问题与排障清单
| 问题 | 原因 | 解决 |
|---|---|---|
| 两个坐标系能各自查到,但互相查不到 | 它们不在同一棵子树 | 查看树,找出断点,补上缺失的变换 |
| 数值在某个时刻跳变 | 两个节点同时广播同一对父子 | 只保留一个发布者 |
map→base_link 与 odom→base_link 都有人发 |
树结构错误(base_link 双父) | 正确做法是 map→odom→base_link 链式 |
| MessageFilter 回调永远不触发 | target_frame 方向 TF 一直不可用 | 检查树里是否有 map;确认发布频率 |
| 查询历史时刻报 extrapolation | 时间戳太旧(>10s 缓存) | 检查数据延迟;Buffer 缓存时间可配 |
| 静态变换改了但看不到 | /tf_static 用 transient_local,新订阅者能收到,但旧数据还在 |
重启节点或发新数据 |
| 多机器人帧名冲突 | 都用了裸名 base_link |
加命名空间前缀或独立话题 |
八、小结与下一篇预告
本篇核心:
- TF 树规则:单父、无环、子树独立;“树通查询通、树断查询断”。
- 三件套架构:
map → odom → base_link,定位与里程计分离,控制平滑。 - 传感器/关节挂载:静态变换挂传感器、动态变换挂关节;
sendTransform(vector)批量发送。 - MessageFilter:带时间戳数据与 TF 就绪绑定的标准解法,处理激光/相机数据必备。
- 命名与隔离:坐标系命名规范、多机器人前缀/话题隔离。
- 完整实践:LaserScan → MessageFilter → 变换到 map → 发布障碍点。
下一篇进入 建模仿真篇:URDF 机器人建模(link 与 joint)。URDF 是机器人的“骨骼描述”,它把 TF 树和 3D 模型绑定:每个 link 是刚体、每个 joint 是坐标系之间的变换。学完你就能用代码描述一台完整机器人,为 Gazebo 仿真和 MoveIt2 机械臂打基础。tf2 是神经,URDF 是骨骼——两者结合,机器人“活”了起来。
ROS2系列教程:TF树与多坐标系管理(C++/Python)
作者:Wang Shijie
本文链接:https://wsj-blog.pages.dev/posts/ros2-xi-lie-jiao-cheng-tf-shu-yu-duo-zuo-biao-xi-guan-li-c/
本文采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议进行许可。
留言评论
评论资源加载失败,请重试。