第二章:坐标系统与空间变换
说实话,做多传感器融合这些年,我见过太多项目栽在坐标变换这个坑里。明明算法模型都调好了,一上路就飘,最后发现是某个传感器的坐标系定义反了。嗯,咱们今天就把这块彻底讲透。
2.1 三大坐标系统:世界、车身、传感器
先理清一个概念。我们做融合,本质上是在回答一个问题:不同传感器看到的同一个物体,到底在哪儿?
这就要靠坐标系统来统一语言了。
2.1.1 世界坐标系
世界坐标系是绝对的参考系。你可以把它想象成地球上的经纬度加海拔。我习惯用东北天坐标系(ENU),也就是X轴指向东,Y轴指向北,Z轴指向天顶。为什么?因为GPS输出的经纬高数据,转成ENU最直观。
我的习惯:在项目初期就把世界坐标系原点定在车辆启动位置。这样后续所有轨迹都是相对位移,调试时一眼就能看出问题。
2.1.2 车身坐标系
车身坐标系跟着车走。标准定义是:X轴朝前(车头方向),Y轴朝左(驾驶员左侧),Z轴朝上。原点一般在车辆后轴中心。
为什么是后轴中心?因为车辆运动学模型里,后轴中心是转向的基准点。我之前有个项目把原点放在前保险杠,结果做路径规划时,转弯半径怎么算都不对。后来改成后轴中心,问题迎刃而解。
2.1.3 传感器坐标系
每个传感器都有自己的小世界。比如激光雷达,通常X轴朝前,Y轴朝左,Z轴朝上。但摄像头就不一样了——很多摄像头是Z轴朝前,X轴朝右,Y轴朝下(图像坐标系)。
注意:不同厂商的传感器坐标系定义可能完全不同。我遇到过某款激光雷达,它的Y轴是朝后的。安装前一定要看手册,别想当然。
2.2 旋转的三种表达方式
旋转是坐标变换里最绕的部分。说白了,就是怎么描述一个物体转了多少。有三种主流方法:欧拉角、旋转矩阵、四元数。
2.2.1 欧拉角
欧拉角最直观。就是绕三个轴依次旋转:偏航角(Yaw)绕Z轴,俯仰角(Pitch)绕Y轴,横滚角(Roll)绕X轴。
但欧拉角有个致命问题——万向锁。当俯仰角接近±90°时,偏航和横滚会耦合,丢失一个自由度。我在做无人机姿态解算时就吃过这个亏,飞机一抬头,姿态就乱跳。
避坑指南:如果你要做全姿态的融合(比如无人机、机器人),别用欧拉角做内部计算。它只适合给人看,不适合给机器算。
2.2.2 旋转矩阵
旋转矩阵是个3×3的矩阵,用9个数描述旋转。好处是数学性质好,可以连续相乘。坏处是——冗余,而且有约束条件(必须是正交矩阵,行列式为1)。
我一般在做传感器标定时用旋转矩阵。因为标定过程需要解算多个姿态,矩阵形式方便做最小二乘优化。
// 绕Z轴旋转θ角的旋转矩阵
R_z(θ) = [cosθ -sinθ 0]
[sinθ cosθ 0]
[0 0 1]
// 绕Y轴旋转φ角的旋转矩阵
R_y(φ) = [cosφ 0 sinφ]
[0 1 0 ]
[-sinφ 0 cosφ]
// 绕X轴旋转ψ角的旋转矩阵
R_x(ψ) = [1 0 0 ]
[0 cosψ -sinψ]
[0 sinψ cosψ]
2.2.3 四元数
四元数是我个人最推荐的方式。它用4个数(w, x, y, z)表示旋转,没有万向锁,插值平滑,计算效率高。
你可能会问:为什么是4个数?其实四元数可以理解为「绕一个单位向量旋转θ角」。w = cos(θ/2),(x,y,z) = sin(θ/2) * 单位向量。
我的经验:在IMU数据融合里,四元数是标配。卡尔曼滤波的状态量用四元数,更新用四元数乘法,比欧拉角稳定得多。
// 四元数乘法(两个旋转叠加)
q_result = q1 * q2
// 四元数旋转向量
v_rotated = q * v * q_conjugate
// 四元数转旋转矩阵(常用公式)
R = [1-2(y²+z²) 2(xy-wz) 2(xz+wy) ]
[2(xy+wz) 1-2(x²+z²) 2(yz-wx) ]
[2(xz-wy) 2(yz+wx) 1-2(x²+y²)]
2.3 刚体变换与齐次坐标
旋转讲完了,但实际变换还有平移。一个刚体从A坐标系到B坐标系,需要旋转加平移。这就是刚体变换。
齐次坐标就是干这个的。把3D点(x,y,z)变成4D的(x,y,z,1),然后用一个4×4的变换矩阵一次搞定旋转和平移。
// 齐次变换矩阵
T = [R t]
[0 1]
// 其中R是3×3旋转矩阵,t是3×1平移向量
// 应用变换
p_B = T * p_A
// p_B和p_A都是齐次坐标 [x, y, z, 1]^T
为什么用齐次坐标?说白了,就是为了把旋转和平移写成一个矩阵乘法。这样多个变换可以连乘,形成一个变换链。
核心公式:传感器数据 → 车身坐标 → 世界坐标,就是一连串的齐次变换矩阵相乘。
2.4 坐标转换的工程实现
理论讲完了,咱们看看实际怎么写代码。我以C++为例,展示一个完整的坐标转换流程。
2.4.1 数据结构定义
// 3D点
struct Point3D {
double x, y, z;
};
// 四元数
struct Quaternion {
double w, x, y, z;
};
// 齐次变换矩阵
struct Transform {
Eigen::Matrix3d rotation; // 旋转矩阵
Eigen::Vector3d translation; // 平移向量
// 应用变换
Point3D apply(const Point3D& p) const {
Eigen::Vector3d v(p.x, p.y, p.z);
v = rotation * v + translation;
return {v.x(), v.y(), v.z()};
}
};
2.4.2 传感器到车身坐标转换
// 假设激光雷达安装在车顶,位置偏移和姿态已知
Transform lidar_to_vehicle;
// 初始化:从标定文件读取
lidar_to_vehicle.rotation = ...; // 旋转矩阵
lidar_to_vehicle.translation = ...; // 平移向量 [dx, dy, dz]
// 转换激光雷达点云到车身坐标系
Point3D point_in_lidar = {1.0, 0.5, -0.3};
Point3D point_in_vehicle = lidar_to_vehicle.apply(point_in_lidar);
2.4.3 车身到世界坐标转换
// 车身到世界坐标,需要车辆的当前位姿
Transform vehicle_to_world;
// 从GPS/IMU获取车辆位置和姿态
vehicle_to_world.translation = get_gps_position(); // 经纬高转ENU
vehicle_to_world.rotation = get_vehicle_orientation(); // 从IMU四元数转矩阵
// 完整转换链:传感器点 → 世界坐标
Point3D point_in_world = vehicle_to_world.apply(
lidar_to_vehicle.apply(point_in_lidar)
);
我曾经踩过的坑:坐标转换的顺序不能错。先旋转后平移,还是先平移后旋转?用齐次矩阵乘法时,顺序是先旋转后平移。如果你搞反了,结果会差十万八千里。
2.4.4 时间同步问题
坐标转换还有一个隐藏的坑——时间同步。不同传感器的数据到达时间不同,车辆本身也在运动。如果你用10ms前的车辆位姿去转换当前时刻的激光雷达数据,结果会有明显的运动畸变。
我的做法是:给每个数据打时间戳,用插值法获取对应时刻的车辆位姿。
// 线性插值车辆位姿
Transform interpolate_pose(
const Transform& pose_t1, double t1,
const Transform& pose_t2, double t2,
double t_target
) {
double ratio = (t_target - t1) / (t2 - t1);
// 平移线性插值
// 旋转用四元数球面线性插值(SLERP)
// ...
}
2.5 总结与建议
坐标变换这东西,说难不难,说简单也不简单。我给你的建议是:
- 统一坐标系定义:项目一开始就定好所有坐标系的定义,写在文档里
- 用四元数做内部计算:别碰欧拉角,除非你只是显示给人看
- 写单元测试:每个变换矩阵都写测试用例,验证正反变换是否互逆
- 可视化验证:把转换后的点云和图像叠加显示,一眼就能看出问题
下一章我们会讲传感器的时间同步问题。你会发现,时间不同步带来的误差,有时候比坐标转换还大。咱们到时候细聊。