2026/10/7 9:45:49

MATLAB实现VO/INS外参标定:基于轨迹对齐与最小二乘优化

MATLAB实现VO/INS外参标定:基于轨迹对齐与最小二乘优化 做视觉里程计VO和惯性导航系统INS的多传感器融合外参标定是绕不过去的第一道坎。相机坐标系和INS坐标系之间差了一个旋转、一个平移如果VO是单目的还要多估一个尺度。这三个量不校准确后面做EKF、因子图还是任何形式的融合残差里总会带着一个解释不清的系统误差调参调到怀疑人生。最近我用MATLAB把这条标定链路完整跑通了基于轨迹对齐加最小二乘优化一次求解相机到INS的旋转、平移和尺度。这篇就把整个方案背后的原理、代码组织、实操流程和踩过的坑全部摊开讲清楚给正在做VO/INS融合或者被标定结果反复折磨的朋友一个可以直接对着写的基线。这套方法对两类人最有用一是刚开始接触VIO、组合导航的研究生需要一个原理清楚、能亲手改的标定基线二是已经在做工程、但标定结果始终不稳定、换一条数据就漂的工程师。MATLAB做这种离线标定原型非常合适可视化、调试、参数分析都顺手代码量也不大完全没必要一上来就上C。1. 标定问题的本质坐标系统一与外参的物理含义1.1 多传感器数据融合的第一步为什么是外参标定多传感器数据融合的本质是把不同传感器对同一物理过程的观测统一到同一个坐标系、同一个时间基准下再做估计。INS输出的是载体在导航系下的位置、速度和姿态VO输出的是相机相对启动瞬间世界系的轨迹和位姿。这两个坐标系之间的相对关系就是外参。它包含两个部分传感器之间固定安装产生的旋转和平移偏移。如果外参不准融合模型里的坐标变换就是错的。我在EKF调试里见过最典型的一种症状设备静止的时候一切正常一旦运动起来新息序列就出现随速度相关的周期波动。一开始怀疑是IMU零偏标了零偏没用怀疑VO噪声模型调了方差也没用最后把外参旋转粗校准了一下问题立刻消失。原因很简单外参误差在运动时会放大速度越快位置残差里的投影误差越大这跟噪声完全是两回事。所以外参标定不是校准一下精度更高的加分项而是多传感器数据融合能不能成立的前提。代码可以写得花哨模型可以建得很复杂但外参错一个角秒融合结果都会在动态场景里被打回原形。1.2 旋转、平移、尺度这三个量分别校的是什么外参求解的核心是三个量旋转、平移、尺度。三者物理含义完全不同优化时的敏感性也完全不同。参数量含义典型难点旋转 R3x3相机坐标系到IMU坐标系的轴向旋转关系安装角不一定是小角度可能差几十度欧拉角表示容易出万向锁平移 t3x1相机光心相对IMU测量中心的位移矢量不是简单距离是带方向的矢量平面运动时部分平移方向辨识度差尺度 s标量单目VO轨迹的整体放缩系数单目视觉天然尺度模糊多段数据尺度漂移时标定结果不稳定旋转和平移好理解相机和IMU装在同一个刚体上位置和朝向不一样需要一个刚性变换把二者对齐。尺度则是单目VO特有的问题。单目相机只靠图像无法恢复场景的绝对深度重建出来的轨迹形状是对的但整体大了一倍还是小了一倍视觉本身给不出答案。这个倍数就是尺度因子只能靠另一个能提供绝对尺度的传感器来确定INS正好能干这个活。1.3 坐标系方向约定最容易被忽略的前提条件写代码之前必须先定清楚坐标变换的方向。整篇代码统一采用这个约定p_imu s * R * p_cam t也就是把相机坐标系下的点变换到IMU坐标系下。R和t也是按这个方向标的。如果你需要用反向变换直接取逆R_inv R.; t_inv -R. * t;。别看这个约定只差一个正负号实际工程里出问题最多的就是这里。VO导出轨迹用的可能是相机坐标系也可能是经过重力对齐的世界坐标系INS落地的导航系可能是ENU也可能是NED。坐标系定义不一致标定出来的结果要么是接近90度整倍数的旋转要么是平移数值离谱。后文第5节我会专门给排查方法先把这个坑立在这。2. 数学建模与求解策略从闭式解到非线性优化2.1 轨迹对齐的目标函数怎么构造标定问题的输入是两条轨迹VO轨迹点和INS轨迹点。理想情况下同一时刻的两个点满足相似变换关系p_imu,i s * R * p_cam,i t n_i其中 n_i 是观测噪声和残差项。目标函数就是最小化所有时刻的位置误差平方和J Σ || s * R * p_cam,i t - p_imu,i ||^2这个目标函数形式看起来很干净但实际工程中只有位置残差会带来一个问题旋转的偏航角方向约束很弱。想象一条基本在平面里的轨迹上下方向的平移和绕竖直轴旋转在位置残差上会互相耦合优化器容易选择一种轨迹大部分对上但偏航有偏差的解。所以如果VO能同时输出姿态强烈建议把姿态残差也加进目标函数J J_pos λ * J_att姿态残差用旋转矩阵的对数映射把旋转误差映射成轴角向量然后计算范数平方。λ根据位置量和姿态量的尺度比例调整通常取0.1到1之间的值。加了这个约束偏航角的可辨识度会好很多。2.2 Umeyama闭式解用SVD一次性拿到稳定初值7个参数的优化问题直接用非线性求解器也能跑但初值给不好就很容易掉进局部极小。工程上最稳的做法是先算一个闭式解当初值。这方面最经典的算法是Umeyama在1987年提出的最小二乘相似变换估计思路非常优雅第一步计算两个点集的质心把点集中心化。这样做的目的是把平移项从优化问题里解耦出来旋转和尺度可以先在零均值点集上求解。第二步计算协方差矩阵H Pc * Qc.其中Pc和Qc分别是中心化后的VO轨迹点和INS轨迹点。第三步对H做SVD分解[U, ~, V] svd(H)。旋转的最优估计由R V * U.给出。这里有个关键细节如果det(V * U.) 0说明这个解包含了一个反射变换需要把V的最后一列取负号确保R是合法的旋转矩阵。第四步尺度 s 用投影长度的比值来计算s sum(sum(Qc .* (R * Pc))) / sum(sum(Pc .* Pc))。第五步平移由质心关系直接反推t mu_q - s * R * mu_p。这个算法的好处是完全不需要迭代数值上非常稳定。MATLAB的svd函数底层用的就是隐式QR迭代数值精度很可靠我们只需要把业务逻辑写对就行。2.3 为什么闭式解之后还要用非线性优化细化既然Umeyama能一步求出R、t、s为什么还要再走一遍非线性优化原因是真实数据不满足理想假设。Umeyama假设所有点都是独立同分布的白噪声但真实VO轨迹有累积漂移INS轨迹可能有时间同步偏差不同区段的轨迹质量也不一样。闭式解虽然能给出一个不错的全局解但没有办法灵活地处理权重、时间偏移和其他约束。非线性优化在这时候就派上用场。我用MATLAB的lsqnonlinLevenberg-Marquardt算法对闭式解做细化优化变量是7个旋转用轴角表示3个分量平移3个分量尺度1个分量。初值直接用Umeyama的结果转换过来几乎不会失败。这里要给刚入坑的朋友一个强烈的建议不要跳过闭式解直接给一组随机初值就开始优化。我复现过那种做法十个初值里能收敛到正确解的不到一半。就像标定相机内参时鱼眼外参初始化如果拿不合理的初值直接跑initextrinsics经常会直接报错退出本质上是同一类问题——非线性优化对初值极其敏感闭式解就是用来干这个的。3. MATLAB代码的核心实现细节3.1 代码怎么组织函数与轻量OOP的结合这套代码我没有全部堆在一个脚本里而是按照数据和职责拆成几块。核心解算部分用无状态函数实现纯数学逻辑不依赖外部数据格式方便单测流程控制部分用轻量的类来封装把轨迹数据加载、时间戳对齐、结果可视化这些操作挂到对象上。这里体现的其实就是热词里经常提的MATLAB OOP架构思路。对一个小型标定器来说不需要把继承、接口做得太重但类的封装能让主流程非常干净。主脚本大概长这样% 加载轨迹 vo Trajectory(vo_tum.txt, tum); ins Trajectory(ins_csv.csv, csv); % 时间戳对齐 [P1, P2, idx] alignByTime(vo, ins); % 闭式解初值 [s0, R0, t0] umeyama(P1, P2); % 非线性细化 x0 [rotationMatrixToVector(R0); t0; s0]; x_opt refineWithLsqnonlin(x0, P1, P2); % 可视化结果 TrajectoryVisualizer.compare(vo, ins, x_opt);这样每次跑新数据只需要换文件名和轨迹格式解析器。解算部分完全不用动对排查问题非常有帮助。3.2 Umeyama求解器的MATLAB实现我直接把核心求解函数放上来这一段建议先整体读一遍再对着上面的算法原理核一遍function [s, R, t] umeyama(P, Q) % 求解 s * R * P t ≈ Q % P、Q均为 3xN 矩阵按列存放轨迹点 mu_p mean(P, 2); mu_q mean(Q, 2); Pc P - mu_p; Qc Q - mu_q; H Pc * Qc.; [U, ~, V] svd(H); d sign(det(V * U.)); if d 0 V(:, end) -V(:, end); end R V * U.; s sum(sum(Qc .* (R * Pc))) / sum(sum(Pc .* Pc)); t mu_q - s * R * mu_p; end注意d sign(det(V * U.))这一行不能少。理论上SVD分解后det(U)和det(V)的乘积已经保证了行列式为1但在数值运算下偶尔会出现反射解不修正的话算出来的旋转会带一个镜像轨迹看起来是反的。这个细节在教科书里经常一句话带过实际作用非常大。3.3 非线性优化与残差函数的实现细节非线性细化部分旋转矩阵用轴角向量表示。自己实现一个Rodrigues变换函数不依赖任何工具箱function R rodrigues(r) % 轴角向量到旋转矩阵 theta norm(r); if theta 1e-12 R eye(3); return; end k r / theta; K [0 -k(3) k(2); k(3) 0 -k(1); -k(2) k(1) 0]; R eye(3) sin(theta) * K (1 - cos(theta)) * (K * K); end残差函数按前面目标函数的形式写function res sim3Residual(x, p_cam, p_imu) % x [rx; ry; rz; tx; ty; tz; s] R rodrigues(x(1:3)); t x(4:6); s x(7); pred s * (R * p_cam) t; res pred - p_imu; res res(:); % lsqnonlin要求残差是列向量 end调用lsqnonlin时options设置很关键options optimoptions(lsqnonlin, ... Display, iter, ... Algorithm, levenberg-marquardt, ... MaxFunctionEvaluations, 5000, ... FunctionTolerance, 1e-12, ... StepTolerance, 1e-12); x0 [rotationMatrixToVector(R0); t0; s0]; x_opt lsqnonlin((x) sim3Residual(x, P_cam, P_imu), x0, [], [], options);关于rotationMatrixToVector如果你没有Robotics Toolbox可以在网上找一个轴角反变换函数原理是用旋转矩阵的反对称部分提取旋转轴、用迹提取旋转角代码不超过十五行。数值Jacobian在这个规模下完全够用几千个点算起来很快不需要手写解析Jacobian省去很多出bug的机会。3.4 时间戳对齐两个传感器频率不一样怎么办VO和INS的采样频率通常不一样外参标定前必须先做时间戳对齐。最简单可靠的方法是线性插值把高频轨迹插值到低频轨迹的时间戳上。一般VO在10到30赫兹INS在50到200赫兹所以把INS轨迹插值到VO时间戳上是常规做法插值方向保证了信息只减少不增加。t_vo vo.t; % VO时间戳长度 N t_ins ins.t; % INS时间戳长度 M p_ins_vo zeros(3, length(t_vo)); for k 1:3 p_ins_vo(k, :) interp1(t_ins, ins.p(k, :), t_vo, linear, extrap); end这里有两个细节。第一如果INS采样率比VO还低插值方向就要反过来把VO轨迹插值到INS时间戳上。第二真实系统往往存在固定的时间延迟可能是几十毫秒这个延迟在外参解算时会表现为旋转和平移的系统偏差。更高级的做法是把时间偏移 Δt 也加进优化变量里一起求解残差变成p_imu(t_vo Δt)初值可以通过互相关搜索粗估。这个功能我建议大家在闭式解基础上逐步加先保证基础流程跑通再加时间偏移估计否则问题叠在一起很难排查。4. 从仿真验证到真实数据采集的完整流程4.1 仿真数据验证先证明代码数学上正确不管代码写得多自信直接拿真机数据跑都是作死。标准做法是先做仿真验证用完全已知的真值生成轨迹验证标定代码能否恢复出正确外参。我用MATLAB生成了一段带高度变化的8字形轨迹然后施加一个已知的相似变换再给轨迹点加高斯噪声检验求解误差。t linspace(0, 20, 2000); p_imu_true [8 * sin(t); 6 * sin(t) .* cos(t); 0.5 * t]; r_true [0.3; -0.4; 0.2]; R_true rodrigues(r_true); t_true [0.5; -0.2; 0.1]; s_true 0.65; p_cam_obs (R_true. * (p_imu_true - t_true)) / s_true; p_cam_obs p_cam_obs 0.02 * randn(size(p_cam_obs)); [s_hat, R_hat, t_hat] calibrate(p_cam_obs, p_imu_true);第一次跑不加噪声误差应该做到1e-12量级加噪声后估计值和真值之间应该在噪声方差附近波动。这一步极其重要因为真机数据的问题往往是复合的如果代码本身的数学正确性都没有验证过遇到标定结果不对时根本无法定位是算法问题还是数据问题。我见过不少同学上来直接拿KITTI数据标标出来不对就怀疑程序实际是自己运动段选得太差。仿真通过之后还要做一组敏感性分析分别在旋转、平移、尺度三个维度上加扰动观察优化是否收敛到真值残差是否平滑下降。这样可以确认目标函数在这组数据上没有平台区或者退化方向。4.2 真实数据采集运动激励、时钟同步与场景选择仿真通过后上真机数据采集的质量直接决定标定结果这里几条经验非常关键第一运动激励要充足。不要只在平面里走小圈一定要让传感器有绕三个轴的旋转和各个方向的平移。绕水平轴旋转尤其重要因为它直接贡献偏航角的可辨识度。建议的激励动作是慢速、大幅度地绕三个轴各转几圈穿插加速和减速的平移段。如果运动激励不足优化结果会非常依赖初值甚至不同初值算出不同外参。第二时钟必须同源。最好用硬件同步信号给两个传感器打时间戳或者至少用同一台电脑的系统时钟记录。两套时钟不对齐标定残差里会出现随时间增长或按速度波动的分量。很多标定失败案例最后都死在时间戳上而不是算法本身。第三设备上电后先静止五到十秒再做动作。静止段用来让IMU做零偏估计INS输出的轨迹起点才可信。同理VO要在纹理充足的地方初始化不要一开始就对着白墙或者玻璃幕墙。第四场景里尽量保留丰富的特征和中等的光照避免过度曝光和运动模糊。VO轨迹质量直接决定标定的输入质量VO如果中途丢帧重定位轨迹里会出现明显断点这类数据要果断剔除不要往优化里喂。4.3 标定结果评估RMSE、残差分布与轨迹可视化标定完成后不能只盯着一个数字看要做三个层面的评估。第一个层面是轨迹对齐RMSE。把估计出的外参应用到VO轨迹上计算与INS轨迹的残差均方根RMSE sqrt(mean(sum((s * R * P_cam t - P_imu).^2, 1)))RMSE可以当作一个整体指标但单独看它不够。好的结果RMSE应该和传感器噪声水平同一量级通常在厘米到分米级别如果RMSE是米级那肯定有问题。第二个层面是残差时间序列。画出每个时间戳上的残差向量观察它是否随机分布在零附近。如果残差有明显的正弦波动大概率是时间同步偏差如果残差在某一大段集中在同一方向大概率是坐标系方向约定有问题。第三个层面是可视化。把两条轨迹画在同一张三维图里变换前应该是形状一致但不在同一位置、大小也不一样的两条曲线变换后应该基本重合。这一步非常直观比任何指标都能说明问题。做完这三个检查标定结果才敢往融合系统里用。5. 常见问题与排查技巧实录5.1 优化陷入局部极小值外参初值不是小事现象轨迹大体能对上但某个方向始终有偏移残差降不下去或者不同数据段标出来的外参差异很大。原因基本就两类。一类是初值给得不好非线性优化收敛到了错误的局部极小值另一类是运动激励不足目标函数本身存在退化方向。排查方法很直接先看Umeyama闭式解这一步的输出是否合理。如果闭式解的旋转和平移量级明显不对说明原始轨迹就有问题如果闭式解合理但优化后变差说明运动激励或权重设置有问题。另外建议把运动数据切成几段分别做闭式解看结果是否一致。不一致就说明数据里存在漂移段或质量差的段。这里要说一个我复现过的教训鱼眼相机标定时initextrinsics外参初始化失败这个问题跟VO/INS外参标定里的初值问题本质是同一件事——非线性优化对初值的依赖极高。所以我一贯的做法是任何标定流程里都要先用闭式解或粗匹配算法给出初值再进优化器。5.2 时间偏差驱动的伪残差怎么识别现象残差序列呈明显的波浪形波峰出现在传感器运动速度最快的时候静止段残差几乎为零。这是时间戳偏差的典型特征。处理方法分两步。先做粗对齐采集时如果两个传感器共用同一个时间系统但各自记录时间戳直接用最小均方误差搜索一个常数时间偏移量比如在±100ms范围内搜索。dts -0.1:0.001:0.1; err zeros(size(dts)); for k 1:length(dts) p_ins_shift interp1(t_ins, p_ins, t_vo dts(k), linear, extrap); err(k) sum(sum((p_ins_shift - p_vo).^2)); end [~, best] min(err); dt_best dts(best);粗对齐之后如果残差还是波形再把时间偏移量加进优化变量里一起求精。注意时间偏移和旋转之间存在一定耦合所以一定要在运动激励充足的数据上估计否则时间偏移会去吸收旋转的误差。5.3 坐标系方向和符号约定错了最隐蔽的坑现象标定出的旋转分量里有接近90度整倍数的量平移数值也完全不对但轨迹可视化之后发现确实有一半是对上的。这种情况十有八九是坐标系方向约定不一致。比如你按“相机Z向前Y向下”导出的VO轨迹和按“ENU导航系Z向上”处理的INS轨迹做标定旋转里自然会出现一个补角的偏差。排查技巧很简单做一个单轴平移实验把设备沿某一个轴正向平移一段已知距离看VO轨迹和INS轨迹在各自坐标系下的坐标变化方向是否一致。逐个检查X、Y、Z三个轴确认一致后再跑标定能省掉大量无效调试时间。另一个技巧是直接看两条轨迹在原始坐标系下的包围盒如果包围盒的朝向和尺寸差异过大先怀疑坐标系定义而不是算法。5.4 标定结果验证训练数据与验证数据要分开现象标定RMSE非常小但换一段新数据验证效果明显变差。这种过拟合问题我在工程里见过太多次原因是标定数据里的运动模式太单一优化器把大部分误差都“分配”到了这次运动特有的一些方向上而泛化性很差。解决办法是采集两组数据一组用于标定另一组用于独立验证。标定流程结束后用验证集重新计算RMSE和残差分布。两组数据的指标如果相差很大说明标定集的运动激励不足需要重新设计采集动作。另外也有一种可能是VO轨迹本身漂移过大。单目VO长跑一段之后会累积严重的尺度漂移这一段轨迹参与优化会污染外参。一个简单处理是给不同时间段设定不同的权重轨迹质量好的段权重高轨迹漂移明显的段权重低。如果漂移太大直接截断只保留VO质量好的部分参加标定。6. 一些个人经验和小技巧最后聊几个我这套流程跑下来最想强调的点。第一标定不是一次性的工作。IMU上温度变化明显、机械结构有应力形变、安装螺丝松动都可能让外参产生微小漂移。建议建立“标定—验证—再标定”的闭环。做室内测试前标一次换到户外环境再跑一次验证长期没动过设备也要隔一段时间复测。我见过一台设备用了几个月没重新标融合定位精度慢慢变差最后查出是摄像头支架被磕歪了几毫米。第二保留原始轨迹数据和标定配置脚本。标定结果出问题时能够随时用同一份数据重跑对比新旧参数的变化趋势这是排查问题最有力的工具。把每一次标定的参数、数据采集时间、场景描述记录成一个表格时间长了就是一份很有价值的工程日志。第三如果追求更高的精度可以考虑把外参和时间偏移、IMU零偏放在同一个优化框架里联合求解。轨迹对齐只用了位置信息性能上限相对有限但作为初步标定已经完全够用。先用这套代码把流程跑通再按需要叠加更复杂的模型比一上来就搞全套联合标定要稳得多。这也是当初我用MATLAB做原型验证的原因——快速试错、快速可视化、快速定位问题等方案成熟了再往C迁移整个周期会舒服很多。