1. 从"旋转矩阵左乘右乘"到SLAM位姿矩阵:这个坑为什么值得专门写一篇
先说一个我自己的真实经历。前几年用ORB-SLAM2跑数据集的时候,我想在全局坐标系下给相机轨迹加一个固定偏移,让整个地图挪到指定位置。当时想都没想,直接在位姿矩阵左边乘了一个变换矩阵,结果地图全部飞到天上去了。排查了半天,最后发现问题不是出在数值计算上,而是我根本没搞明白左乘和右乘在SLAM位姿语义上的区别。
这个事说起来很基础,但几乎每一个做SLAM的人都会在某一个瞬间被它绊一下。包括我后来带过的实习生,包括在一些技术社群里看到的提问,大家反复在"为什么左乘是全局变换,右乘是局部变换"这个点上纠结。很多人看完《视觉SLAM十四讲》里旋转矩阵左乘右乘那一节,觉得看懂了,但一写代码、一调试位姿就懵。
所以这篇东西不是从零开始教大家矩阵乘法怎么算,而是把位姿矩阵左乘和右乘这件事,放到SLAM的实际应用场景里拆开讲清楚。适合正在看视觉SLAM十四讲、刚开始跑ORB-SLAM2或者自己写位姿解算代码的人,也适合那些已经写了很久代码、但遇到"相机坐标和世界坐标到底该左乘还是右乘"时依然要停下来想一想的同学。
这篇文章的核心只围绕一件事:当你面对一个位姿矩阵T,在它左边乘一个矩阵和右边乘一个矩阵,到底分别意味着什么,在什么场景下应该用哪一个,以及最容易踩的坑在哪里。
2. 先建立坐标系直觉:全局坐标系与局部坐标系的分水岭
在讲左乘和右乘之前,我强烈建议先把坐标系这件事在脑子里钉死。SLAM里面所有的位姿矩阵,本质上都是在描述一个刚体在三维空间中的位置和朝向。而"位置和朝向"必须有参考系才有意义。
2.1 位姿矩阵T到底在表达什么
一个4x4的齐次变换矩阵T,通常长这样:
T = [ R t ] [ 0 1 ]其中R是3x3旋转矩阵,t是3x1平移向量。它表达的含义是:把一个点从某个坐标系下的坐标,变换到另一个坐标系下的坐标。
这里就引出了第一个关键点:位姿矩阵永远描述的是两个坐标系之间的相对关系,而不是绝对概念。比如雷达坐标系到相机坐标系的外参,是一个T;相机坐标系到世界坐标系的变换,也是一个T。在SLAM里最常见的写法是T_wc,意思是"从相机坐标系到世界坐标系的变换",也可以理解为"相机在世界坐标系下的位姿"。
这个下标顺序非常值得多说几句。T_wc的读法是:左边的下标是目标坐标系w,右边的下标是源坐标系c。也就是说,一个在相机坐标系下的点p_c,想要得到它在世界坐标系下的坐标p_w,计算方式是p_w = T_wc * p_c。
这个约定在《视觉SLAM十四讲》里讲得很清楚,但很多初学者容易搞反,把T_wc当成"从世界坐标到相机坐标"来用,后面全乱了。
2.2 全局变换与局部变换的分界线到底在哪
现在来到核心问题:当我们对位姿矩阵T_wc做变换时,在左边乘一个矩阵和在右边乘一个矩阵,分别意味着什么?
假设我们有一个位姿矩阵T_wc,现在想对它施加一个额外的变换ΔT。
如果写成 ΔT * T_wc,这就是左乘。左乘的结果是:在**世界坐标系(全局坐标系)**下,对这个位姿施加变换。
如果写成 T_wc * ΔT,这就是右乘。右乘的结果是:在**相机自身坐标系(局部坐标系)**下,对这个位姿施加变换。
这个结论本身就是整个话题的核心。但是光记住这句话没有用,得理解为什么。
我习惯用一个生活化类比来说明。假设你站在一个房间里,面朝北。现在你面前有一堵墙,墙在你身体的局部坐标系的"前方"。
- 如果你想在全局坐标系下移动这堵墙,让它从房间北边挪到东边,这个变换是在房间坐标系中定义的,对应左乘。
- 如果你想让你自己(刚体)先向右转90度,再往前走,这个动作是相对于你自己的朝向定义的,对应右乘。
对于相机位姿T_wc来说,左乘相当于把整个世界坐标系固定住,然后我问你:如果把这个相机在世界上平移一段距离、旋转一个角度,它新的位姿是什么?右乘则相当于把相机坐标系固定住,然后我问你:如果相机的中心不动,只是相对于它自己的坐标轴转动了一下,或者沿着自己的坐标轴平移了一段,它新的位姿是什么?
这两种想法都是合法的,但它们描述的物理情景完全不同。SLAM中很多bug就是从这里来的——你以为自己在做全局变换,实际写出来的代码却是在做局部变换,或者反过来。
2.3 为什么矩阵乘法不满足交换律在这里是个"好事"
矩阵乘法本来就不满足交换律,AB和BA通常不相等。在SLAM位姿变换这个场景里,这种"不相等"恰恰很好地反映了物理世界的真实差异。
比如一个位姿T_wc,相机在世界坐标系的(1, 0, 0)位置,朝向是世界坐标系X轴正方向。如果我们在左边乘上一个沿世界坐标系Z轴平移1个单位的矩阵ΔT_trans_z,新位姿会跑到(1, 0, 1)处;如果我们在右边乘上同样的ΔT_trans_z,因为ΔT的旋转部分是单位阵,平移部分在左乘和右乘时表现出的效果是一样的(都是(1, 0, 1)),这容易让人产生"左乘右乘没区别"的错觉。
一旦涉及到旋转,区别立刻凸显。假设ΔT是一个绕X轴旋转90度的旋转矩阵,左乘和右乘的结果差异会非常明显。左乘会让位姿在世界坐标系下绕X轴转90度,右乘则会让位姿在自己的相机坐标系下绕X轴转90度。前者会把相机朝向从指向世界X轴变成指向世界Z轴,后者则不会改变相机在世界坐标系中的朝向,因为当相机朝向与世界X轴一致时,相机自己的X轴和世界X轴重合,两者效果恰好一样。但一旦初始朝向不是正好对齐,差异就会显现。
这也是为什么在调试SLAM轨迹的时候,很多人会遇到"旋转方向反了""转的角度对但是轴不对"这类诡异问题,多半就是左乘和右乘用错了地方。
3. 数学推导与几何直觉:为什么左乘是全局、右乘是局部
搞清楚结论之后,接下来要解决的是"为什么"。这一节我会把数学推导过程完整走一遍,帮助那些需要在代码里推导公式、写优化代码的人打好底子。
3.1 用点的变换来倒推位姿的变换
位姿矩阵的物理含义是"把一个点从一个坐标系映射到另一个坐标系"。我们从这个角度出发,来推导左乘右乘的含义。
假设当前相机位姿是T_wc,空间中有个点P,在世界坐标系下坐标是p_w,在相机坐标系下坐标是p_c,那么满足:
p_w = T_wc * p_c这是在SLAM里出现频率最高的公式之一。
现在我要给这个位姿左乘一个新变换ΔT,得到:
T_new = ΔT * T_wc此时:
p_w_new = T_new * p_c = ΔT * T_wc * p_c = ΔT * p_w看出来了吗?左乘ΔT之后,新的位姿对同一个相机坐标系下的点P,会先按原来的T_wc映射到世界坐标p_w,然后再在世界坐标系下按ΔT进行变换。因此左乘的结果是:先保持点的相机坐标不变,但最终它在世界坐标系里的位置被ΔT重新调整了。从位姿的运动角度看,这就是在全局坐标系下移动了相机。
再看右乘,设:
T_new = T_wc * ΔT那么:
p_w_new = T_new * p_c = T_wc * ΔT * p_c我们可以先计算ΔT * p_c。因为ΔT是在T_wc的右侧,它首先作用于相机坐标系下的点p_c,在相机坐标系内对点进行了一次变换,然后再由T_wc映射到世界坐标系。从位姿的运动角度看,这个ΔT是在相机坐标系里做变换,影响的是相机自身的局部运动。
一个简单的推导,把左乘右乘的差别暴露无遗:左乘是把变换直接作用在世界坐标系的点上,右乘是先把点在相机坐标系内做变换再映射到世界坐标系。
3.2 旋转矩阵左乘右乘与位姿矩阵的对应关系
很多人之前在学旋转矩阵的时候,已经接触过"左乘是固定坐标系下的旋转,右乘是自身坐标系下的旋转"这个结论。位姿矩阵的左乘和右乘其实是一回事的推广。
但有一个容易被忽略的细节:位姿矩阵包含了平移分量,所以左乘和右乘的影响不仅仅是旋转方向问题,平移部分也遵循同样的规律。
具体来说,把ΔT拆成旋转ΔR和平移Δt,分情况推导一下。
左乘的情况:
ΔT * T_wc = [ ΔR * R_wc, ΔR * t_wc + Δt ] [ 0 , 1 ]右乘的情况,我们把ΔT写开:
T_wc * ΔT = [ R_wc * ΔR, R_wc * Δt + t_wc ] [ 0 , 1 ]对比这两个结果,信息量很大:
- 旋转部分:左乘后的新旋转是ΔR * R_wc,相当于在世界坐标系下先转ΔR再转到原朝向;右乘后的新旋转是R_wc * ΔR,相当于先按原朝向转到相机朝向,再在相机坐标系下转ΔR。
- 平移部分:左乘时,Δt直接加在世界坐标系的坐标上,并且原有的平移向量t_wc还会被ΔR旋转一遍;右乘时,Δt先被R_wc旋转到世界坐标系方向,再加上原来的平移。
这些公式解释了一个很常见的现象:当你给一个位姿左乘一个纯平移矩阵,相机会在全局坐标系下沿某个轴移动;当你右乘一个纯平移矩阵,相机会沿着自己当前朝向的方向移动。如果相机已经旋转了90度,右乘平移的效果看起来就像是在"侧移",左乘平移则永远是"沿世界坐标轴走"。
3.3 用李群视角看左乘右乘
如果你的研究或者工作涉及位姿优化,比如图优化、Bundle Adjustment,那么大概率会接触到李群和李代数。这时候左乘右乘的概念会被进一步推广。
在李群语境下,左乘扰动模型和右乘扰动模型是两种常见的求导方式。它们在数学本质上的区别,和本文讨论的坐标系差异完全一致:
- 左扰动:在位姿T左侧乘一个微小变换exp(ξ^)δ,扰动被解释为在世界坐标系/全局坐标系下的扰动,对应全局优化的雅可比。
- 右扰动:在位姿T右侧乘一个微小变换,扰动被解释为在局部坐标系下的扰动,对应局部参数化的雅可比。
这个区别直接影响到你写代码时得到的雅可比矩阵形式。比如在g2o或者GTSAM里,不同边定义的误差对位姿的雅可比是左扰动形式还是右扰动形式,在编程时不能搞混。我见过不少人因为把左扰动雅可比直接套到右扰动更新上,导致优化发散。
在代码层面,Eigen和Sophus库对左乘右乘的支持都非常直接。Sophus中,SE3d的*运算符支持两个位姿矩阵相乘,也支持位姿矩阵乘以点。如果你构造一个SE3d delta然后写T_new = delta * T,那就是左乘;写T_new = T * delta,那就是右乘。代码写起来很容易,難的是在写之前想清楚自己要的是哪种。
4. SLAM实际场景里的左乘右乘:建图、定位与轨迹处理的典型案例
理论讲了一大堆,最终还是要落到实际场景中。这一节我用几个SLAM开发中最高频的场景来举例,每一个都是我自己实际写代码时遇到过的。
4.1 场景一:给整条轨迹加上一个全局偏移
这是最典型的左乘应用场景。假设你用ORB-SLAM2跑完了一段数据集,得到了每一帧的相机位姿T_wc,现在因为地图对齐、坐标系变换、或者要与地面真值比较,需要把整条轨迹按某个固定的变换ΔT做一次整体移动。
做法很简单,每一帧做左乘:
T_wc_aligned = ΔT * T_wc_original为什么是左乘而不是右乘?因为这里ΔT是在世界坐标系下定义的:世界坐标系的原点要移到别的地方,整个世界坐标系的轴要重新定向。每一帧相机在世界坐标系里的位姿,自然也要跟着做同样的变换。这个场景如果用了右乘,轨迹会发生奇怪的扭曲——每一帧都在自己的相机坐标系下做了变换,导致轨迹的形状完全改变,而不仅仅是被平移旋转。
我遇到过一种容易混淆的情况:用evo工具评估SLAM轨迹和真值的差异时,需要先做轨迹对齐。如果你自己手动写对齐代码,不小心用了右乘,评估出来的ATE/APE误差会大得离谱,让人误以为算法跑崩了。实际上就是坐标系变换方向搞反了。
4.2 场景二:机器人在自身坐标系下的运动控制
这个场景对应机器人底盘运动学与SLAM位姿更新的结合。假设你有一个差速驱动机器人,底盘发布的速度指令是相对于机器人自身坐标系的:前进1米、左转30度、右转15度,这些都是在机器人当前朝向下的动作。
那么每次更新位姿的时候,应该右乘:
T_wc_new = T_wc_old * ΔT_odom其中ΔT_odom是机器人坐标系下观测到的运动增量(来自轮式里程计)。因为里程计的两个轮子速度天然是在机器人坐标系下积分出来的,运动增量描述的是"相对于机器人当前朝向走了多少",所以必须右乘。
这个场景最容易犯的错是把里程计数据直接加到世界坐标系的平移量上。比如相机朝向北的时候走了1米,然后转了90度朝东,又走了1米。如果搞成左乘,第二次的1米可能还是会加到世界坐标系的北方向上,轨迹就歪了。
我之前调试一个视觉惯导融合的demo时,就是在这个问题上卡了一个下午。IMU预积分出来的增量是在IMU坐标系下的,直接左乘到世界坐标系位姿上,跑出来的轨迹像喝醉了酒一样。
4.3 场景三:相机外参标定与坐标变换链
在做多传感器融合时,经常涉及到相机坐标系、IMU坐标系、雷达坐标系之间的变换。假设我们知道了雷达坐标系到相机坐标系的变换T_lc,又知道相机坐标系到世界坐标系的变换T_wc,那么雷达在世界坐标系下的位姿是:
T_wl = T_wc * T_lc这其实是一个右乘的典型应用。这里没有"对位姿进行额外变换"的意思,而是纯粹的坐标系链路叠加:先把点从雷达坐标系转到相机坐标系,再从相机坐标系转到世界坐标系。这种链式变换遵循的规则就是坐标变换的组合顺序,和前面讨论的左乘右乘是统一的:每次变换作用的都是当前坐标系下的点,所以从左到右乘。
这个例子提醒我们,不要机械地记"右乘=局部运动"这个结论,有时候右乘仅仅是坐标系链路的自然写法。理解透之后,看到T_wc * T_lc这样的式子,你应该立刻能说出来:这是先把雷达系下的点转到相机系,再转到世界系,T_lc的平移和旋转是在雷达自身坐标系里定义的。
4.4 场景四:相机位姿优化中的扰动更新
在图优化和BA里,位姿更新通常有两种方式。如果你用的是"左乘更新",那么每次迭代时:
T_new = exp(ξ^) * T_old如果你的优化库采用的是"右乘更新"约定,则是:
T_new = T_old * exp(ξ^)这两种更新方式对应的雅可比矩阵形式不一样。ORB-SLAM2里的g2o优化,按照实现细节通常是对位姿的扰动求导,其雅可比形式往往对应一种特定的扰动放置位置。如果你要自己写一个自定义边,必须先弄清楚这个优化器约定的是左扰动还是右扰动,否则会出现"误差在下降但位姿更新完全错误"的诡异现象。
判断方法其实不复杂:你把一个微小扰动分别放在左边和右边,分别算出误差对扰动的导数,哪个形式和优化器内部更新公式一致,就选哪个。但很多人会偷懒不推导,直接抄网上代码,抄对了能跑,抄错了就陷入玄学调参。
5. 最容易踩的3个坑:我自己和身边人反复犯过的错误
这一节算是经验之谈,专门挑出那些"不难但特别容易错"的细节。这些坑不会让你写出编译不过的代码,但会让你的仿真结果或者实机表现出一堆莫名其妙的偏差。
5.1 坑一:混淆T_wc和T_cw的物理含义
这个坑实在太常见了。很多人在代码里定义了T_wc,但在做坐标变换时,又错误地用它去乘一个本来应该乘T_cw的点。
比如在ORB-SLAM2里,Tcw表示从世界坐标系到相机坐标系的变换矩阵,也就是把世界系下的地图点转到相机系下。官方代码里追踪线程经常用Tcw乘以地图点坐标来得到投影后的像素坐标。如果你自定义代码时把变量名写反,或者在赋值时混淆了来源,整个投影过程就会乱套。
关于T_wc和T_cw的互转,有一个很实用的公式:如果你只有T_cw,想得到T_wc(相机在世界坐标系下的位姿),直接取逆即可。因为T_cw的逆就是T_wc:
T_wc = T_cw.inverse()在Eigen中就是T_cw.inverse()这么一行。但要注意,虽然矩阵求逆在数学上是精确的,但在数值层面,对于4x4齐次矩阵,直接用.inverse()会比用分块公式慢一点。对于SLAM性能敏感的场景,更高效的做法是利用齐次变换矩阵的求逆性质:
T_wc = [ R_cw^T, -R_cw^T * t_cw ] [ 0 , 1 ]手动构造这个矩阵可以省掉一次通用的4x4求逆计算。这个优化在地图点数量庞大、频繁需要坐标变换的时候,性能提升还是比较明显的。
5.2 坑二:忘记旋转矩阵的正交性对平移部分的影响
回顾右乘公式里的平移部分:
t_new = R_wc * Δt + t_wc很多人在推导右乘平移效果时,会下意识地写成Δt + t_wc,忘了前面还要乘一个R_wc。这个错误在做局部运动累加时尤其致命。比如机器人在世界系的(1, 2, 3)处,朝向旋转了某个角度,然后沿自身坐标系x轴前进了1米。正确的平移更新是先把自身的(1,0,0)方向旋转到世界坐标系下,再叠加到原来的位置上。如果漏了旋转,里程计积分就会在旋转后完全失真。
我之前写一个简单的视觉里程计测试程序时,正好卡在这个地方:旋转角度超过45度后轨迹就开始偏,角度越大偏得越离谱。后来打印中间变量才发现,平移更新少乘了一个旋转矩阵,平移量的方向在相机旋转后没有跟着转。
5.3 坑三:在轨迹评测时忽略坐标变换约定
用evo评测SLAM轨迹时,很多人会从算法里直接导出位姿文件,然后与数据集提供的groundtruth做对比。但不同数据集、不同SLAM系统的位姿约定可能不同。
举个具体例子:TUM数据集提供的groundtruth是相机在世界坐标系下的位姿,对应T_wc形式。而ORB-SLAM2输出的轨迹在默认情况下也是T_wc(或者严格说是两帧之间的相对运动转换后的世界系位姿),两者约定一致,可以直接评测。但有些数据集或者算法输出的是T_cw,如果你不转换直接喂给evo,评测结果会非常糟糕。
正确的做法是:在导出轨迹文件前,确保每一行位姿矩阵表达的是同一个坐标变换语义。如果不一致,就用上面提到的取逆操作统一成T_wc。再提醒一句,evo本身有--align参数做轨迹对齐,但它解决的是"轨迹之间的整体刚体变换"问题,不能解决"你导出的语义本身就不对"的问题。上下文里那些搜索热词反复出现evo,说明很多人在用这个工具,我真心建议大家先用小数据集确认坐标语义,再开大量评测,不然很容易得出错误结论。
6. 一个加深理解的实战小例子:手写坐标变换来验证左乘右乘
光说不练还是容易忘。我建议你亲手做一次这个小实验,它花费的时间不超过10分钟,但能让你对左乘右乘的理解牢固很多。
6.1 构造场景与预期结果
假设相机在世界坐标系的初始位姿为:
- 位置:(0, 0, 0)
- 朝向:世界坐标系X轴正方向(即相机朝向世界X轴)
现在做两步操作:
- 绕世界坐标系的Z轴旋转90度。
- 沿当前相机自身的X轴前进了1米。
请你想一下,这两种操作如果分别用左乘和右乘实现,最后相机应该在哪里?
如果第一步旋转用左乘实现(绕世界Z轴转90度),第二步沿自身X轴前进用右乘实现(因为"自身X轴"就是局部坐标系的定义),那么位姿更新公式为:
T_1 = ΔT_rot_z * T_0 // 左乘,绕世界Z轴转90度 T_2 = T_1 * ΔT_trans_x // 右乘,沿自身X轴走1米预期结果:第一步后相机朝向世界Y轴正方向(因为绕Z轴转90度后,X轴指向Y方向),第二步沿自身X轴走1米,相当于沿世界Y轴走1米。最终位置应该是(0, 1, 0)。
如果你把第二步写成左乘:
T_2_wrong = ΔT_trans_x * T_1由于ΔT_trans_x是沿世界X轴平移1米,最终位置会变成(1, 0, 0)。方向完全错误。
6.2 代码验证
用Eigen实现这个验证非常直观。我用Sophus库,因为它在表达SE3时更贴合SLAM习惯。
#include <iostream> #include <Eigen/Dense> #include <sophus/se3.hpp> int main() { using Sophus::SE3d; using Eigen::Vector3d; using Eigen::AngleAxisd; using Eigen::Matrix3d; // 初始位姿:原点,朝向X轴正方向 SE3d T0(Matrix3d::Identity(), Vector3d(0, 0, 0)); // 绕世界Z轴旋转90度 Matrix3d Rz = AngleAxisd(M_PI / 2.0, Vector3d::UnitZ()).toRotationMatrix(); SE3d delta_rot_z(Rz, Vector3d::Zero()); // 沿X轴平移1米 SE3d delta_trans_x(Matrix3d::Identity(), Vector3d(1, 0, 0)); // 正确:旋转左乘 + 平移右乘 SE3d T1 = delta_rot_z * T0; SE3d T2_correct = T1 * delta_trans_x; // 错误:平移也写成左乘 SE3d T2_wrong = delta_trans_x * T1; std::cout << "correct translation: " << T2_correct.translation().transpose() << std::endl; std::cout << "wrong translation: " << T2_wrong.translation().transpose() << std::endl; return 0; }输出结果应该是:
correct translation: 0 1 0 wrong translation: 1 0 0这一步跑通之后,你就能从"背结论"升级为"能验证结论"。下次遇到任何左乘右乘的疑问,直接写个几行的小demo验证一下,比自己反复琢磨要高效得多。
6.3 进阶:从旋转矩阵推广到任意SE3
这个验证办法可以推广到任意的SE3变换。比如你可以试试:
- 先绕世界X轴转45度,再绕自身Y轴转30度,分别用左乘和右乘实现,看看结果差别在哪里。
- 先沿世界Z轴平移2米,再沿自身Z轴平移2米,在初始朝向不同的情况下看看效果差异。
多做几组组合,你的空间直觉会明显变好。我自己带人入门SLAM时,经常让他们先做个这样的小实验,再去看ORB-SLAM2里PnP求解时对位姿的更新代码,理解速度会快很多。
7. 从原理到代码:如何快速判断一个场景该用左乘还是右乘
最后这部分我想分享一套我自己用下来的判断方法论。不保证百分之百适用所有情形,但对绝大多数SLAM开发场景都有效。
当你面对"这个位姿变换该左乘还是右乘"的问题时,按顺序问自己三个问题:
第一,这个变换ΔT是在哪个坐标系下定义的?如果ΔT的旋转轴、平移方向是在世界坐标系里描述的(比如"沿全局坐标系的Z轴旋转"、"沿世界地图的东北方向移动"),那用左乘;如果ΔT是传感器测量得到的、在自身坐标系下的增量(比如轮式里程计的左右轮积分、IMU预积分、视觉帧间运动估计),那用右乘。
第二,变换作用的语义是什么?如果变换是在"已有位姿"上额外叠加一个运动,而这个运动的参考系就是该刚体当前所在的参考系,那么几乎可以确定用右乘。如果变换是把整个刚体连同它所在的参考系一起在世界坐标系里做刚体运动,用左乘。
第三,点坐标变换的链路是什么?如果你在处理的是p_w = T_wc * p_c这样的链路,想知道一个点在多个坐标系之间怎么转,那这种纯粹的多坐标系链式变换通常就是老老实实按矩阵乘法从左到右叠乘,不用刻意区分左乘右乘,因为它们的组合顺序已经由坐标变换链路本身决定了。
还有一个非常实用的检查手段:把ΔT设成纯旋转,看旋转之后相机的朝向变化是否符合你的预期。比如你期望"绕世界Z轴转90度",左乘后相机在世界坐标系的朝向应该明确改变;如果相机朝向没变,或者方向反了,基本就是乘反了。
在实际工程中,我更推荐的做法是:在关键变换代码处写清注释,把坐标系、变换语义、左乘还是右乘都标出来。很多时候代码本身并没有错,是过了一个月回头看的人(包括自己)忘了当时的语义,然后在上面乱改,才引入了bug。这些注释看起来不起眼,关键时刻比一大堆文档都好用。
8. 从我自己的调试经历里总结的几条经验
文章写到这,主要的内容已经讲完了。再分享几个我从实际项目中沉淀下来、常规文档里不会写的点。
第一个是调试顺序问题。排查位姿相关bug时,不要一上来就怀疑随机噪声、回调丢数据、线程不同步,先把坐标系和左乘右乘链捋一遍。我自己的经验是:这种基础性错误通常是"症状五花八门、根源只有一个",比如轨迹旋转方向反了、地图整体偏移、局部建图漂移,都有可能只是某个T乘反了。看起来是玄学的问题,最后往往落在一个很小的矩阵乘法顺序上。
第二个是输出中间量定位问题。很多位姿bug是"看起来怪怪的但说不清哪里怪"。这时候我一般会在关键位姿更新处打印中间量,分别输出左乘和右乘两个结果,和预期值对比。前面那个10分钟的小实验代码,就是一个很有效的可复用脚手架。在不同项目里,我都写过一个类似的"变换验证函数",输入一个T和一个ΔT,输出左乘和右乘的结果,一目了然。
第三个是代码规范问题。建议在类或者函数命名里就把坐标变换语义写清楚,比如updatePoseWithOdomDelta()、transformGlobal()、applyLocalMotion(),不要只写updatePose。我曾经接手过一个代码库,里面有好几个updatePose,有的内部左乘,有的内部右乘,完全靠调用顺序去猜,维护成本极高。后来全部改成带语义的命名后,整体开发效率高了一大截。
第四个是关于工具链的使用。《视觉SLAM十四讲》的读者大多会接触到Eigen、Sophus、g2o、evo这些工具,这些工具本身的文档可能不会告诉你"这个接口默认是左扰动还是右扰动",但它们的源码和示例里一定会暴露出来。比如g2o的顶点更新函数oplusImpl里,如果传入的增量是被左乘到位姿上的,那这个顶点的求导约定就是左扰动。看源码虽然累,但对理解框架的约定帮助巨大。遇到模棱两可时,直接在源码里搜*this = Sophus::SE3d::exp(delta) * (*this)还是*this = (*this) * Sophus::SE3d::exp(delta),一目了然。
最后说一句:左乘还是右乘,本质上是坐标系参考系的问题。SLAM里面的坐标系多到让人眼花缭乱,但只要抓住"变换是在哪个坐标系下定义的"这一条线,绝大多数问题都能迎刃而解。希望这篇内容对你有用,祝调试顺利。