第1章:坐标系与刚体变换

做激光雷达SLAM,说白了就是回答三个问题:我在哪?周围有什么?怎么过去的?而这三个问题的答案,都绕不开坐标系和刚体变换。我记得刚入行那会儿,被各种坐标系绕得晕头转向,一个旋转矩阵写反了,点云直接飞到天上去。嗯,今天咱们就把这块硬骨头啃下来。

1.1 世界坐标系:一切测量的基准

世界坐标系,就是那个绝对参考系。你可以把它想象成地球上的经纬度——不管车怎么动,这个坐标系纹丝不动。在自动驾驶里,我们通常用东北天(ENU)或者北东地(NED)来定义它。

关键点:世界坐标系是全局一致的。所有传感器数据最终都要统一到这个世界坐标系下,才能做融合和规划。

我个人习惯用ENU。为什么?因为直观。X轴指东,Y轴指北,Z轴朝天。你开车的时候,东和北是固定的,不会因为车头朝向而改变。这在做高精地图匹配时特别方便。

1.2 车体坐标系:车辆自身的参考系

车体坐标系跟着车走。一般定义是:X轴朝前(车头方向),Y轴朝左,Z轴朝上。原点通常在后轴中心或者车辆质心。

这里有个坑——不同车厂的定义可能不一样。我在项目中遇到过,某供应商的IMU数据用的是X轴朝右,结果融合出来的姿态全是反的。所以拿到数据第一件事,先确认坐标系定义,别想当然。

坐标系 X轴 Y轴 Z轴 原点
世界坐标系 东 北 天 任意固定点
车体坐标系 前 左 上 后轴中心
雷达坐标系 前 左 上 激光雷达中心

1.3 雷达坐标系:传感器的视角

每个激光雷达都有自己的坐标系。比如Velodyne的坐标系,X轴朝前,Y轴朝左,Z轴朝上。但有些国产雷达可能不一样。你想想看,如果雷达装歪了,它的坐标系和车体坐标系之间就有一个旋转偏移。

这个偏移就是我们要标定的东西。说白了,就是求一个旋转矩阵R和一个平移向量t,把雷达坐标系下的点转换到车体坐标系下。

小技巧:标定的时候,我习惯先做粗标定——用卷尺量一下雷达的安装位置和角度,得到一个大概的初值。然后再用优化算法精调。这样收敛快,不容易掉进局部最优。

1.4 欧拉角与四元数:旋转的两种表达

旋转怎么表示?最直观的是欧拉角。绕X轴转叫横滚角(Roll),绕Y轴转叫俯仰角(Pitch),绕Z轴转叫偏航角(Yaw)。听起来很合理对吧?

但欧拉角有个致命问题——万向锁。当俯仰角接近±90°时,横滚和偏航会耦合在一起,丢失一个自由度。我在做无人机SLAM时就吃过这个亏,飞机垂直朝下时,姿态解算直接炸了。

所以,工程上我们更常用四元数。四元数用四个数表示旋转,没有奇点,插值也平滑。它的形式是:

q = w + x*i + y*j + z*k
其中 w^2 + x^2 + y^2 + z^2 = 1

你可能会问,四元数怎么用?其实很简单。比如你要把一个点p绕某个轴旋转,用四元数做乘法就行:

p' = q * p * q^(-1)

这里的乘法是四元数乘法,不是普通乘法。代码实现时,我建议直接用Eigen或者Sophus库,别自己手写——我曾经手写过一次,debug了一整天,最后发现是符号写反了。

1.5 齐次变换矩阵:把旋转和平移打包

旋转用四元数或旋转矩阵,平移用向量。但每次变换都要分开算,太麻烦。于是有了齐次变换矩阵:

T = [ R  t ]
    [ 0  1 ]

这是一个4x4的矩阵。R是3x3的旋转矩阵,t是3x1的平移向量。最后一行是[0,0,0,1]。用这个矩阵,一个乘法就能完成旋转+平移:

p' = T * p

这里的p是齐次坐标,也就是在三维坐标后面加一个1,变成[x,y,z,1]^T。

注意:齐次变换矩阵的乘法顺序很重要!先旋转再平移,和先平移再旋转,结果完全不同。我见过有人把顺序搞反,点云直接飞到几百米外。记住:先旋转,后平移。

1.6 知识体系总览

下面这张图总结了本章的核心逻辑。你可以看到,所有坐标系变换最终都归结为齐次变换矩阵的链式乘法。

坐标系与刚体变换知识体系 世界坐标系 绝对参考系 (ENU/NED) 车体坐标系 车辆自身参考系 雷达坐标系 传感器视角 欧拉角 (Roll/Pitch/Yaw) 四元数 (w,x,y,z) 旋转矩阵 (3x3) 齐次变换矩阵 T = [R t; 0 1] 链式乘法:T_world_to_radar = T_world_to_car * T_car_to_radar 核心思想:所有坐标系变换统一为齐次变换矩阵的乘法

1.7 实战:坐标系变换的代码实现

说了这么多,来点实际的。下面是一个C++代码片段,演示如何把雷达坐标系下的一个点变换到世界坐标系:

#include <Eigen/Dense>

// 定义齐次变换矩阵
Eigen::Matrix4d T_car_to_radar;   // 雷达 -> 车体
Eigen::Matrix4d T_world_to_car;   // 车体 -> 世界

// 假设已经标定好了这两个矩阵
T_car_to_radar << 
  0.999, -0.017,  0.017, 0.5,
  0.017,  0.999,  0.017, 0.0,
 -0.017, -0.017,  0.999, 0.3,
  0,      0,      0,     1;

T_world_to_car <<
  0.998, -0.052,  0.035, 100.0,
  0.052,  0.998, -0.017, 200.0,
 -0.035,  0.017,  0.999, 0.5,
  0,      0,      0,     1;

// 雷达坐标系下的一个点
Eigen::Vector4d p_radar(1.0, 2.0, 0.5, 1.0);

// 变换到车体坐标系
Eigen::Vector4d p_car = T_car_to_radar * p_radar;

// 变换到世界坐标系
Eigen::Vector4d p_world = T_world_to_car * p_car;

std::cout << "世界坐标系下的点: " << p_world.transpose() << std::endl;

经验之谈:写这种代码时,我习惯把每个变换矩阵的源坐标系和目标坐标系用注释写清楚。比如 T_car_to_radar 表示「从雷达坐标系到车体坐标系」。这样半年后回来看代码,不会一脸懵逼。

1.8 避坑指南

最后,分享几个我踩过的坑:

  • 坐标系方向搞反:我曾经把车体坐标系的Y轴方向搞反,结果点云左右颠倒。后来我养成了一个习惯——标定完先画个简单的点云图,看看形状对不对。
  • 四元数归一化:四元数必须归一化,否则旋转会带缩放。我见过有人忘了归一化,点云越转越小,最后缩成一个点。
  • 齐次坐标的w分量:变换时一定要保证w=1。如果w=0,表示方向向量,不会被平移——这在某些场景下有用,但如果你不小心搞混了,结果会非常诡异。

嗯,坐标系和刚体变换就讲到这里。这些东西看似基础,但真到了工程里,每一个细节都可能让你debug到怀疑人生。记住:坐标系定义清楚,变换矩阵写对,顺序别搞反——这三点做到了,后面SLAM的路就好走多了。

可可精品资料库