做视觉惯导融合,十个项目有八个卡在第一次跑通标定环节。别问我怎么知道的,这个 VO 与 INS 外参标定的 MATLAB 工程,就是用来解决这一关的:通过优化求解相机到 INS 坐标系的旋转、平移和尺度,把两个传感器的轨迹对齐到同一个空间基准上,为后续多传感器数据融合铺路。这篇内容适合正在做视觉里程计/惯性导航联合定位、想自己实现一套外参标定代码的开发者,也适合那种“算法流程看过不少、一写 MATLAB 就崩”的实操型选手。
先说清楚一个容易被误解的点:外参标定不是“标定一次就永远不用管”的事。它解决的问题是,相机坐标系和 INS 坐标系之间的刚体变换关系。这个关系如果给错,后面无论是松耦合的滤波融合,还是紧耦合的滑窗优化,都会在残差里带进系统性偏差。你会在实验中发现,明明加速度计和陀螺仪噪声模型没问题,视觉特征跟踪也正常,融合结果就是飘。问题往往不在前端,而在外参。
这套工程把标定拆成“数据准备 → 初值求解 → 非线性优化 → 结果验证”四步,用 MATLAB 写起来逻辑清楚,跑完还能直接看到每次迭代的残差变化,适合用来摸清标定原理。下面我就按实际开发顺序,把这个工程的核心设计、数学建模、代码实现和踩坑记录完整拆一遍。
1. 项目整体设计思路:为什么用“轨迹对齐”来做外参标定
1.1 标定问题的本质是坐标系变换,不是单纯的参数拟合
先看基本面:VO 输出的是相机位姿序列,INS 输出的是世界系下的位置、速度和姿态。两者在同一个运动过程中各自估计出一条轨迹,但由于坐标系定义不同,同一时刻的两个位置点之间,存在一个固定的旋转和平移关系。如果 VO 是单目,还会多出一个尺度因子。
所以这个项目本质上做的事情是:把同一段运动下的 VO 轨迹拆成 N 个位置点,把 INS 轨迹在对应时间戳下拆成 N 个位置点,然后寻找一组变换参数,使得两个点云尽可能重合。这就是三维点集配准的经典问题,早期工作一般用 Horn 的绝对定向法,也就是四元数线性求解;更高阶的做法则是构建非线性最小二乘,用 LM 或 Dogleg 算法迭代优化。
用轨迹对齐而不是用“同时采集标定板图像 + IMU 静止测量重力”这类方案,最大的好处是:不需要外部运动捕捉系统,也不需要专门的标定间。你只需要让设备在手里做一段包含充分旋转和位移的运动,两个传感器各自记录数据,就能解出外参。对嵌入式平台或相机 IMU 组合模块来说,这个条件是很容易满足的。
1.2 工程结构划分:五个模块各自负责什么
实际的 MATLAB 工程不会把所有代码堆在一个脚本里,这个项目按我的习惯拆成了五个模块,逻辑边界很清楚:
- 数据读取模块:负责加载 VO 轨迹文件、INS 轨迹文件,统一时间基准。
- 数据预处理模块:完成时间戳插值、坐标系转换、单位换算。
- 初值估计模块:用线性方法求一个大致不离谱的 R、t、s,作为优化的起点。
- 非线性优化模块:构建残差函数,调用
lsqnonlin做精细求解。 - 验证与输出模块:计算重投影误差、绘制对齐轨迹,输出标定结果。
模块拆分这件事不是形式主义。我见过很多初学者把所有计算塞进一个脚本里,结果换一组数据就要改半天,调试的时候满屏幕变量分不清谁是谁。合理拆开后,每个模块都能独立测试:数据格式变化只改读取模块,优化器调参只动优化模块。后面你在实际项目中换相机、换 IMU 型号,这套结构还能复用大半。
2. 外参标定的数学建模:旋转、平移、尺度的细节不容易绕过去
2.1 坐标系约定是第一个容易翻车的地方
数学建模的第一步不是写公式,而是把坐标系定义钉死。相机坐标系一般定义为中心在相机光心,X 向右、Y 向下、Z 沿光轴向前;INS 坐标系常见定义为中心在 IMU 测量中心,X 向前、Y 向左、Z 向上。两者之间差了一个接近 90 度倍数的旋转,具体数值取决于安装方式。
这个项目里,优化求解的目标是找到从 INS 坐标系到相机坐标系的变换关系,记作:
[ p_{cam} = s \cdot R_{c \leftarrow i} \cdot p_{ins} + t ]
这里的 (p_{cam}) 是某个三维点在相机坐标系下的坐标,(p_{ins}) 是同一个三维点在 INS 坐标系下的坐标。注意别搞反方向:有的资料写成 (p_{ins} = R \cdot p_{cam} + t),得到的是另一个外参,实际使用时容易把自己绕晕。我自己的经验是,代码里写清楚“从谁到谁”的注释,并且在验证阶段用一段仿真数据测试正反向一致性,比口头约定靠谱得多。
还有一个容易被忽略的问题:INS 解算出来的轨迹位置是什么坐标系下的?有些 INS 系统输出的是经纬度和高度,需要转成局部笛卡尔坐标;有些直接输出东北天坐标系下的位置。不同约定下,外参的平移分量完全不同。所以数据预处理里必须包含坐标系转换这一步,不能默认 VO 和 INS 已经在同一世界系。
2.2 旋转参数化:旋转向量、四元数还是旋转矩阵
在 MATLAB 的lsqnonlin中做优化时,旋转的自由度是 3,但旋转矩阵本身是 9 个元素。直接把 9 个元素当作优化变量会带来冗余约束,求解器需要额外处理正交性,数值稳定性也差。所以一般用旋转向量或单位四元数来参数化。
旋转向量是三个元素,方向表示旋转轴,模长表示旋转角度。MATLAB 里rotationVectorToMatrix和rotationMatrixToVector可以直接互转,非常方便。单位四元数是四个元素,带有一个单位约束,但用四元数做优化时换算成旋转矩阵也简单。
就这个项目而言,我建议优化变量数量取 7 个:旋转向量 3 个、平移 3 个、尺度 1 个。这样残差函数在迭代时不需要处理额外的单位约束。如果非要选四元数,则需要用quaternion类或者手写归一化残差,把硬约束转成软约束,代码复杂度会上升不少。实际对比下来,旋转向量在这类“全局轨迹对齐”问题上的收敛速度和稳定性都够了。
2.3 残差构建与目标函数设计
假设测量到的相机轨迹点集合为 (C_i),INS 轨迹点在相同时间戳下的位置为 (I_i),那么残差可以写作:
[ r_i = C_i - (s \cdot R \cdot I_i + t) ]
最小二乘目标函数就是:
[ \min_{R, t, s} \sum_{i=1}^{N} | r_i |^2 ]
这里面有一个容易忽略的细节:轨迹对齐通常涉及几十上百个点,但每个点在优化中的权重不应该是相同的。如果某段运动是纯平移,那么旋转的可观测性就很弱;如果设备大部分时间静止,那平移分量又容易被噪声支配。更稳健的做法是按点与点之间的距离或匹配置信度设置权重,或者直接使用 IMU 的速度信息来约束尺度。
MATLAB 的lsqnonlin默认假设残差是均方差形式,你只需要返回一个向量,它会在内部自动求和平方。这里要注意,残差向量的长度是 3 乘以点云点数。如果轨迹有 200 个采样点,残差就是 600 维,优化变量是 7 个,超定方程组规模足够让求解器收敛到合理结果。
3. MATLAB 代码实现:关键模块的写法和容易被误解的点
3.1 数据读取与时间戳对齐:不做插值后面全是坑
VO 轨迹和 INS 轨迹的采样频率很难完全相同。常见的情况是 VO 在 20 到 30 Hz,INS 在 100 到 200 Hz。直接拿两个时间戳不完全对齐的点集做优化,残差会带上额外的时间对齐误差,标定结果自然不准。
处理思路很简单:以 VO 的时间戳为基准,对 INS 的位置轨迹做线性插值。INS 数据频率高、输出平滑,线性插值的精度完全够用。如果反过来以 INS 为基准插值 VO,那就需要小心 VO 位姿在帧间变化不是线性的,尤其相机快速转动时,线性插值四元数或平移会引入不可忽略的误差。
代码层面试过的一个稳定写法是:
% vo_ts: VO 时间戳向量 % ins_ts: INS 时间戳向量 % ins_pos: INS 位置矩阵,3 x N vo_pos_interp = interp1(ins_ts, ins_pos', vo_ts, 'linear', 'extrap')';extrap参数要慎用,它会把超出边界的值按趋势外推,严重时产生离谱数据。我一般会在插值之后做一次数值检查,把超出时间范围的样本直接滤掉。
3.2 初值估计:用 Umeyama 算法先算一个“不蠢”的解
直接用随机初值跑lsqnonlin很容易掉进局部最小值,尤其是尺度和平移同时求解的时候。正确做法是先做一个线性初值估计,把结果当作优化起点。
Umeyama 算法是三维点集配准的标准方法:先计算两组点的质心,再做去质心处理,然后用 SVD 分解求解旋转,最后通过残差比例求尺度和平移。这个算法在 MATLAB 里用 SVD 一行就能写,但要注意两个点集的维度排列——按惯例是 3×N,别传成 N×3。
求解旋转的核心代码思路如下:
% X: 3xN, Y: 3xN, 求解 Y = s * R * X + t mu_x = mean(X, 2); mu_y = mean(Y, 2); Xc = X - mu_x; Yc = Y - mu_y; [U, ~, V] = svd(Yc * Xc'); R = V * U'; if det(R) < 0 V(:, end) = -V(:, end); R = V * U'; end s = trace(R' * Yc * Xc') / sum(sum(Xc .^ 2)); t = mu_y - s * R * mu_x;这段代码算出的 R、t、s 直接作为优化初值。用 SVD 得到的旋转一般不会差太多,后续优化只需要在局部范围内精细调整。注意这个代码假设 X 和 Y 都是三维点集,如果 VO 轨迹是单目带尺度,那么这里的尺度初值能反映出 VO 轨迹相对 INS 轨迹的缩放比例。
3.3 优化求解器选型与参数设置
MATLAB 里lsqnonlin是最适合这个问题的内置求解器,它支持levenberg-marquardt和trust-region-reflective两种算法。对这个规模的小优化问题,LM 收敛更快,但对初值敏感度略高;初值已经由 Umeyama 给好的情况下,优先选 LM。
优化变量我按旋转向量、平移、尺度的顺序拼接:
x0 = [rotationMatrixToVector(R0); t0; s0]; options = optimoptions('lsqnonlin', ... 'Algorithm', 'levenberg-marquardt', ... 'Display', 'iter', ... 'MaxFunctionEvaluations', 1e4, ... 'FunctionTolerance', 1e-10, ... 'StepTolerance', 1e-10); x_opt = lsqnonlin(@(x) computeTrajectoryResidual(x, vo_pos, ins_pos_interp), x0, [], [], options);残差函数内部要做旋转向量到矩阵的转换:
function residual = computeTrajectoryResidual(x, vo_pos, ins_pos) R = rotationVectorToMatrix(x(1:3)); t = x(4:6); s = x(7); transformed = s * (R * ins_pos) + t; residual = vo_pos - transformed; end这里的vo_pos是相机轨迹位置,ins_pos是插值对齐后的 INS 轨迹位置。如果 VO 是单目,s初始值不会正好是 1,优化过程会自动调整;双目的尺度固定为 1,那就在代码里固定x(7)=1,或者使用lsqnonlin的参数上下界把尺度锁死。
4. 实操过程记录:从数据采集到结果验证,每个环节都有“为什么”
4.1 数据采集要求:不是随便晃两下就行
采集数据这一步看着简单,实际上决定了标定结果的可用性。要让外参中的旋转近乎可观测,运动必须包含充分的旋转激励:沿三个轴分别转几圈、做几次“8”字摆动、包含不同速度的平移。如果只做直线平移,旋转和平移在残差里会强耦合,解出来的旋转可能完全错误,但残差看起来却很低。
一个具体的采集建议:手持设备在室内由近到远走一个“回”字形路线,中途多次改变机头朝向,路线总时长建议在 30 秒以上,包含至少 10 次明显的转向。VO 在弱纹理环境容易丢帧,所以环境光照要好,场景中最好有静止物体提供特征点。
如果项目里已经有一套实时融合系统,我建议直接用系统记录原始传感器数据,而不是用后处理的数据。因为后处理可能经过了平滑、时间校准等操作,数据质量反而被掩盖,标定代码跑出的结果无法反映真实硬件状态。
4.2 标定结果验证:不能只看残差大小
很多人跑完lsqnonlin看到残差下降就认为标定完成了,这是很危险的。外参标定的真实检验方式是看“对齐后的轨迹一致性”和“融合闭环精度”。我通常做三步验证:
第一步,绘制对齐后的轨迹图。把 INS 轨迹变换到相机坐标系下,与 VO 轨迹画在同一张三维图里,肉眼观察两条曲线是否基本重合。如果存在局部分叉或剪切变形,说明某些时间段数据有问题。
第二步,做往返一致性检查。同一段路线正走一次、反走一次,分别标定,比较两次得到的旋转矩阵和平移向量的差异。稳定性好的标定结果差异应该在很小的阈值内,如果两次结果差出一大截,优先怀疑数据采集质量或时间同步。
第三步,把标定后的外参接入融合代码,做一次真实定位测试。比如在室内走一个闭环,查看最终位置误差。外参正确时融合轨迹不会有明显的“转体”或“侧漂”现象。
4.3 可视化与输出:把结果固化下来
这个项目的末尾,我会输出一个结构化结果文件,包含旋转矩阵、平移向量、尺度因子、最终 RMSE 和迭代次数。保存成.mat文件不算最方便,我更喜欢直接生成可读的文本或 YAML 格式,方便后续 C++ 或 Python 工程直接读取。
results.R = R_opt; results.t = t_opt; results.scale = s_opt; results.rmse = sqrt(mean(sum((vo_pos - (s_opt * (R_opt * ins_pos) + t_opt)).^2, 1)));RMSE 的值要结合轨迹尺度看才有意义。如果 INS 轨迹范围是 50 米,RMSE 只有几厘米,那是很好的结果;如果轨迹范围只有 2 米,RMSE 也是几厘米,那这个标定结果的可靠性就值得怀疑——因为噪声占的比例太大了。
5. 使用这个标定工程时,我实际踩过的几个大坑
5.1 外参初始化失败:初值离真解太远,优化直接发散
这个现象特别常见,尤其是用鱼眼相机做 VO 时,畸变模型和针孔模型的投影差异很大,轨迹解算本身就带偏差。如果 Umeyama 解出的初值不够好,LM 算法会在前几次迭代就跑飞。我处理这个问题的方法有两个:一是把尺度初值先限制在合理范围内,比如以轨迹包围盒对角线长度的比例估算;二是先固定尺度,只优化旋转和平移,等残差进入收敛域后再放开尺度一起优化。
% 两阶段优化 x_stage1 = [rotationMatrixToVector(R_umeyama); t_umeyama; s_umeyama]; % 固定尺度 fixed_scale = s_umeyama; x_stage1(end) = fixed_scale; options_stage1 = optimoptions('lsqnonlin', 'Algorithm', 'levenberg-marquardt'); x_opt_stage1 = lsqnonlin(@(x) resid(x, vo_pos, ins_pos), x_stage1, [], [], options_stage1); % 放开尺度 x_stage2 = x_opt_stage1; x_opt_final = lsqnonlin(@(x) resid(x, vo_pos, ins_pos), x_stage2, [], [], options_stage1);这个操作在标记点对齐和轨迹对齐中都有效,本质上是先用低自由度的强约束把解拉到正确的山谷里,再用高自由度拟合细节。
5.2 时间戳没有对齐,残差看起来很小但轨迹在飘
我用过一段 VO 与 INS 时间基准相差 50 ms 的数据集,优化后 RMSE 只有几厘米,但把对齐后的轨迹叠加可视化,明显看出转弯处有一条“分叉”。原因是时间误差在直线运动时对残差影响小,转弯时两帧位置变化大,时间偏移导致的位置偏差就暴露了。
解决方式是在残差模型中加入时间延迟变量,把时间偏移也作为优化量之一。比较简单的做法是在数据预处理阶段画互相关图,求两个轨迹的最优时间偏移,再去插值。实用代码片段是先粗扫一个延迟窗口,选使两条轨迹对齐误差最小的延迟值。
5.3 尺度因子收敛到不合理数值
单目 VO 的轨迹尺度本来就是任意的,与 INS 轨迹对齐后,尺度因子理论上是固定的。但我在一些低纹理环境下遇到尺度收敛到 0.8 或者 1.3 这种明显偏离真实值的情况。排查后发现问题不在优化方程,而在 VO 轨迹本身发生了尺度漂移——长时间运行的单目 VO 会因累积误差导致局部尺度不一致。
这种情况下,分段落优化是有效的手段:把轨迹按时间切成几段,每段单独估计尺度和外参,观察尺度的时段变化。如果发现某段的尺度与其他段明显不同,那这一段 VO 数据大概率丢了尺度约束,应该剔除或降权。
5.4 INS 数据中的温漂和零偏
INS 数据看似比 VO 可靠,但消费级惯性器件的零偏会随温度变化。长时间采集后,INS 解算的位置本身就有漂移,用它作为标准参考会污染标定结果。我的做法是在标定数据采集前让设备充分预热,并且在采集中保持设备温度相对稳定,避免刚从低温环境拿出设备就直接开始录数据。
6. 一点后续扩展想法:这个工程还能怎么接着玩
这套标定工程目前是离线处理方式,跑完一次给出一组固定外参。实际项目中如果传感器安装结构件容易形变,或者使用了可调焦相机,外参可能随时间变化。后续可以考虑把算法扩展成滑窗式在线标定:利用融合滤波器中的残差序列,每隔一段时间重估一次外参,并把重估结果反馈给前端。MATLAB 环境下可以先做一个基于滑动窗口的仿真验证,确认外参更新频率与噪声约束的关系,再移植到 C++ 嵌入式环境。
另外,标题里提到“滤波跟踪”这个方向,本身就是视觉惯导融合的另一个分支。如果后续想走紧耦合融合路线,外参标定就不只是几何对齐问题,它还会和 IMU 零偏、视觉特征深度耦合在一起。到时候这套纯几何标定的结果可以作为紧耦合系统的初始化值,再用基于滤波或图优化的方法做进一步精化。
我个人的体会是,外参标定这类工作属于“看起来简单、做起来绕”的典型。数学公式十分钟能看懂,但真正跑通一套代码、验证完数据质量、解决完一两轮实际工程问题,那种收获感远不止是得到一个参数文件。按照上面这套流程走一遍,踩掉几个坑之后,你会发现后面做多传感器融合时,“外参”这个包袱终于可以放下,安心去调滤波器和优化策略了。