4、坐标变换与TF树:坐标系定义(base_link, odom, map),TF广播与监听,Eigen库基础,四元数与欧拉角转换
做SLAM这些年,我最大的体会就是——坐标系搞不清楚,后面全白搭。你想想看,机器人明明在往前走,地图上却显示它在后退,这种问题十有八九是坐标变换出了岔子。今天咱们就把这块硬骨头啃下来。
4.1 三个核心坐标系:base_link、odom、map
ROS里定义了三个最重要的坐标系,我习惯把它们叫做「铁三角」。
| 坐标系 | 含义 | 特点 |
|---|---|---|
| base_link | 机器人本体坐标系 | 随机器人移动,原点一般在底盘中心 |
| odom | 里程计坐标系 | 以起始点为原点,存在漂移 |
| map | 全局地图坐标系 | 固定不变,用于建图和定位 |
base_link 说白了就是「机器人的身体」。激光雷达装在哪儿?IMU装在哪儿?都是以base_link为基准定义的。我在项目中遇到过有人把激光雷达的安装偏移搞反了,结果建出来的地图全是歪的——嗯,排查了两天才找到原因。
odom 是里程计坐标系。它从机器人启动时的位置开始算起。但有个问题——里程计会漂移。你让机器人走10米,odom告诉你走了10.05米,这就是误差。所以odom坐标系是局部一致、全局不一致的。
map 是全局坐标系。它一旦建立就固定不动了。SLAM的核心任务之一,就是找到base_link在map坐标系下的位姿。说白了,就是回答「我在哪儿」这个问题。
重要原则:TF树中,map → odom → base_link 的变换关系必须严格遵循。map到odom的变换由定位模块发布,odom到base_link的变换由里程计模块发布。千万别搞反了!
4.2 TF广播与监听
TF(Transform)是ROS里管理坐标变换的利器。我刚开始学的时候觉得它很玄乎,其实说白了就是发布坐标系之间的相对关系。
4.2.1 广播TF
假设你的机器人有个激光雷达,安装在前方0.3米处。你需要告诉系统:base_link和laser_frame之间的关系。
#include <ros/ros.h>
#include <tf/transform_broadcaster.h>
int main(int argc, char** argv) {
ros::init(argc, argv, "robot_tf_publisher");
ros::NodeHandle n;
tf::TransformBroadcaster broadcaster;
ros::Rate rate(10.0);
while (n.ok()) {
// 激光雷达安装在base_link前方0.3米
tf::Transform transform;
transform.setOrigin(tf::Vector3(0.3, 0.0, 0.1));
tf::Quaternion q;
q.setRPY(0, 0, 0);
transform.setRotation(q);
// 发布变换:base_link → laser_frame
broadcaster.sendTransform(
tf::StampedTransform(transform, ros::Time::now(),
"base_link", "laser_frame"));
rate.sleep();
}
return 0;
}
这里有个坑——时间戳一定要用最新的。我曾经犯过一个错误,用了ros::Time(0),结果TF树一直报「extrapolation into the future」的警告。折腾了半天才发现是时间戳的问题。
4.2.2 监听TF
监听TF更常用。比如你要把激光雷达的数据转换到base_link坐标系下:
#include <ros/ros.h>
#include <tf/transform_listener.h>
tf::TransformListener listener;
tf::StampedTransform transform;
try {
// 等待变换可用
listener.waitForTransform("base_link", "laser_frame",
ros::Time(0), ros::Duration(3.0));
// 获取变换
listener.lookupTransform("base_link", "laser_frame",
ros::Time(0), transform);
} catch (tf::TransformException &ex) {
ROS_ERROR("%s", ex.what());
}
个人经验:监听TF时一定要加try-catch。TF树偶尔会断一下,不加异常处理程序直接崩掉。我见过太多新手在这上面栽跟头了。
4.3 Eigen库基础
Eigen是C++里做矩阵运算的标配库。做SLAM绕不开它。你想想看,坐标变换本质上就是矩阵乘法。
4.3.1 基本用法
#include <Eigen/Dense>
// 定义向量
Eigen::Vector3d v(1.0, 2.0, 3.0);
// 定义矩阵
Eigen::Matrix3d R;
R = Eigen::AngleAxisd(0.25*M_PI, Eigen::Vector3d::UnitZ()) *
Eigen::AngleAxisd(0.5*M_PI, Eigen::Vector3d::UnitY()) *
Eigen::AngleAxisd(0.33*M_PI, Eigen::Vector3d::UnitX());
// 矩阵乘法
Eigen::Vector3d v_rotated = R * v;
// 齐次变换矩阵
Eigen::Matrix4d T = Eigen::Matrix4d::Identity();
T.block<3,3>(0,0) = R;
T.block<3,1>(0,3) = Eigen::Vector3d(1.0, 2.0, 3.0);
我个人习惯用 Eigen::Isometry3d 来表示变换矩阵,它比手动拼 Matrix4d 更安全:
Eigen::Isometry3d T = Eigen::Isometry3d::Identity();
T.rotate(R);
T.pretranslate(Eigen::Vector3d(1.0, 2.0, 3.0));
4.4 四元数与欧拉角转换
说到这个我就来气。欧拉角有万向锁问题,但很多人就是喜欢用它。我建议你内部计算一律用四元数,只在显示和调试时转成欧拉角。
4.4.1 为什么用四元数?
- 无万向锁:欧拉角在俯仰角±90°时会丢失一个自由度
- 插值平滑:四元数的球面插值(Slerp)比欧拉角线性插值自然得多
- 计算稳定:四元数乘法比矩阵乘法更高效
4.4.2 转换示例
#include <Eigen/Geometry>
// 欧拉角 → 四元数
double roll = 0.1, pitch = 0.2, yaw = 0.3;
Eigen::Quaterniond q;
q = Eigen::AngleAxisd(roll, Eigen::Vector3d::UnitX()) *
Eigen::AngleAxisd(pitch, Eigen::Vector3d::UnitY()) *
Eigen::AngleAxisd(yaw, Eigen::Vector3d::UnitZ());
// 四元数 → 欧拉角
Eigen::Vector3d euler = q.toRotationMatrix().eulerAngles(2, 1, 0);
// 注意:返回顺序是 yaw, pitch, roll
避坑指南:我曾经在四元数转欧拉角时搞错了旋转顺序。ROS里默认的欧拉角顺序是ZYX(yaw-pitch-roll),但有些库用的是XYZ。一定要确认清楚!
4.4.3 实战中的小技巧
在SLAM里,我经常需要把四元数转成旋转矩阵:
// 四元数 → 旋转矩阵
Eigen::Matrix3d R = q.toRotationMatrix();
// 旋转矩阵 → 四元数
Eigen::Quaterniond q2(R);
// 检查四元数是否合法
if (q2.norm() < 1e-6) {
ROS_WARN("四元数接近零,可能有问题!");
}
嗯,这里要注意——四元数一定要归一化。数值误差会导致四元数的模不再是1,这时候用它做旋转会出问题。我习惯每次用之前都调一下 q.normalize()。
4.5 总结
坐标变换是SLAM的基石。说白了,整个SLAM系统就是在不断回答三个问题:
- 我在哪儿?(base_link在map中的位姿)
- 我从哪儿来?(odom到base_link的变换)
- 周围长啥样?(激光数据转换到全局坐标系)
搞清楚了这三个坐标系的关系,你的SLAM系统就成功了一半。剩下的,就是调参和debug了——那又是另一个故事了。