二维坐标变换:旋转矩阵、平移向量、齐次坐标

各位同学,今天我们来聊聊二维坐标变换。说实话,这是感知融合里最基础、也最容易被忽视的一块。我见过不少同事,算法调得飞起,结果坐标一变换,车的位置直接偏到马路牙子上去了。嗯,咱们今天就把这块地基打牢。

1. 旋转矩阵:让坐标系转起来

先问大家一个问题:一辆车上的激光雷达,和车本身的坐标系,方向一样吗?

答案是不一定。雷达安装时可能有俯仰、偏航。在二维世界里,我们只关心绕Z轴的旋转,也就是偏航角θ。

旋转矩阵长这样:

R = | cosθ  -sinθ |
    | sinθ   cosθ |

它的作用很简单:把一个点(x, y)绕原点旋转θ角度。新坐标(x', y') = R * (x, y)。

我个人习惯把旋转矩阵记成「余弦在主角,正弦在对角」。为什么?因为cosθ永远在矩阵的主对角线上,sinθ在对角线上,但符号不同。

核心要点:旋转矩阵是正交矩阵,它的逆等于它的转置。这意味着R⁻¹ = Rᵀ。这个性质在实际工程中非常有用——你不需要真的去求逆矩阵,转置一下就行,计算量小得多。

我在项目中遇到过一个问题:雷达点云和相机图像对不上。排查了半天,发现是旋转矩阵的符号搞反了。顺时针旋转和逆时针旋转,sinθ的符号正好相反。你想想看,一个符号错了,整个融合结果就全歪了。

2. 平移向量:把坐标系挪个位置

旋转解决的是方向问题,平移解决的是位置问题。

假设激光雷达装在车顶前方0.5米处。那么雷达坐标系的原点,相对于车辆坐标系的原点,就有一个偏移量。这个偏移量就是平移向量t = [tx, ty]ᵀ。

变换公式很简单:

x' = x + tx
y' = y + ty

说白了,就是把点挪过去。

避坑指南:我曾经在标定的时候,把平移向量的单位搞错了。雷达输出的是毫米,车辆坐标系用的是米。结果一变换,点云全飞到天上了。后来我养成了一个习惯:所有传感器数据进来,第一件事就是统一单位。毫米转米,或者米转毫米,必须明确。

3. 齐次坐标:把旋转和平移合二为一

刚才我们做了两步:先旋转,再平移。写成公式就是:

x' = R * x + t

这个形式虽然简洁,但有个问题:它不是线性变换。因为有个加法t在里面。如果我们有一连串的变换(比如从雷达到车辆,再从车辆到世界),就得反复做乘法和加法,很麻烦。

齐次坐标就是来解决这个问题的。

它的思路很简单:给二维点(x, y)加一个维度,变成(x, y, 1)。然后变换矩阵变成3x3的:

T = | R  t |
    | 0  1 |

展开就是:

| x' |   | cosθ  -sinθ  tx | | x |
| y' | = | sinθ   cosθ  ty | | y |
| 1  |   | 0      0      1  | | 1 |

这样一来,旋转和平移就合并成了一个矩阵乘法。连续变换只需要把矩阵乘起来就行。

为什么用齐次坐标?因为矩阵乘法可以串联。你想想看,从雷达到车辆是一个矩阵T_rv,从车辆到世界是T_vw。那么从雷达直接到世界就是T_rw = T_vw * T_rv。一次乘法搞定,不用写循环,不用判断边界。代码干净,效率高。

4. 逆变换:从目标坐标系回到源坐标系

有正向变换,就一定有逆向变换。比如我们知道了车辆坐标系下的点,想反推它在雷达坐标系下的位置。

逆变换矩阵就是T的逆矩阵:

T⁻¹ = | Rᵀ  -Rᵀ * t |
       | 0      1     |

注意,这里利用了旋转矩阵的正交性:R⁻¹ = Rᵀ。所以逆变换的计算量很小。

重要提醒:千万不要直接对T求逆!虽然数学上可行,但数值稳定性差。尤其是当平移量很大时,直接求逆会引入数值误差。我建议用公式手动计算逆矩阵,又快又准。

5. 知识体系图

下面这张图展示了二维坐标变换的核心逻辑:

二维坐标变换知识体系 旋转矩阵 R 平移向量 t 齐次坐标 T = [R | t; 0 | 1] 正向变换 逆变换 串联变换 注意:单位统一、符号检查、数值稳定性 核心思想:用齐次坐标将旋转和平移统一为线性变换 工程实践:矩阵乘法串联、逆变换用公式计算

6. 代码示例:C++实现

下面是一个简单的二维变换实现。我在实际项目中就是这么写的:

#include <Eigen/Dense>
#include <cmath>

// 构建旋转矩阵
Eigen::Matrix2d buildRotationMatrix(double theta) {
    Eigen::Matrix2d R;
    R << cos(theta), -sin(theta),
          sin(theta),  cos(theta);
    return R;
}

// 构建齐次变换矩阵
Eigen::Matrix3d buildTransform(double theta, double tx, double ty) {
    Eigen::Matrix3d T = Eigen::Matrix3d::Identity();
    T.block<2,2>(0,0) = buildRotationMatrix(theta);
    T(0,2) = tx;
    T(1,2) = ty;
    return T;
}

// 正向变换
Eigen::Vector2d transformPoint(const Eigen::Vector2d& p,
                                const Eigen::Matrix3d& T) {
    Eigen::Vector3d p_homo(p.x(), p.y(), 1.0);
    Eigen::Vector3d p_trans = T * p_homo;
    return Eigen::Vector2d(p_trans.x(), p_trans.y());
}

// 逆变换(用公式计算,避免直接求逆)
Eigen::Matrix3d inverseTransform(const Eigen::Matrix3d& T) {
    Eigen::Matrix2d R = T.block<2,2>(0,0);
    Eigen::Vector2d t(T(0,2), T(1,2));
    
    Eigen::Matrix3d T_inv = Eigen::Matrix3d::Identity();
    T_inv.block<2,2>(0,0) = R.transpose();  // R⁻¹ = Rᵀ
    T_inv.block<2,1>(0,2) = -R.transpose() * t;
    return T_inv;
}

个人经验:我习惯用Eigen库做矩阵运算。它的block操作非常方便,而且编译优化后性能很好。如果你用ROS,tf2库也封装了这些变换,但底层原理是一样的。理解原理,用起来才放心。

7. 总结

二维坐标变换,说白了就是三件事:旋转、平移、合起来。旋转用矩阵,平移用向量,合起来用齐次坐标。

嗯,这里要注意:齐次坐标的最后一维是1,不是0。如果是0,表示方向向量(只旋转不平移)。这个区别在感知融合中经常用到——比如表示一个朝向时,我们用方向向量,最后一维就是0。

好了,二维变换就讲到这里。下一章我们进入三维世界,那里有更多有意思的东西等着我们。

可可精品资料库