简介:SOFT算法立体视觉里程计的MATLAB实现包,面向机器人技术、立体视觉与视觉里程计方向的研究者和开发者,基于特征选择与跟踪完成立体相机的运动估计。资源已适配MATLAB R2018a,依赖并行处理与计算机视觉工具箱,包含完整可运行的main.m、visualSOFT.m等源码,以及特征处理、特征匹配、特征选择、运动估计等模块,并附有特征匹配效果图、算法流程图等示意图和README说明文档。压缩包共42个文件,以27个m格式源码文件为主,辅以7个png和4个jpg图片用于直观展示算法中间结果,另有3个md文档辅助说明,整体大小仅3.88MB。目前已有707人学习下载,适合想快速上手视觉里程计算法或改进自身SLAM系统的中高级MATLAB用户,可直接在项目基础上扩展与验证。
1. 视觉里程计与SOFT算法:从特征跟踪到运动估计的完整路径
视觉里程计(Visual Odometry)是移动机器人定位的可靠基石,它不依赖GPS、不需要布置信标,只靠相机图像序列就能递推估计机器人自身的运动轨迹。SOFT(Stereo Odometry based on Feature Tracking)算法正是这个方向里相当经典的一支,它强调高质量特征跟踪和立体几何约束,在KITTI视觉里程计基准上长期保持了很有竞争力的结果。与单目VO不同,SOFT依靠立体视觉获得绝对尺度,递推过程中的漂移明显更小,尤其适合地面机器人、AGV和室外场景应用。这篇文章要完成的是一套可以直接在MATLAB里运行的SOFT方案:从标定、图像校正,到特征检测、立体匹配、三角化和滑窗优化,每层都有可复现代码和参数说明。无论你是研究导航算法,还是想把手上的机器人底盘跑出自定位能力,读完都能在MATLAB里跑通最小闭环。
2. 用MATLAB搭建立体视觉里程计的输入:标定、校正与数据管线
2.1 立体校正为什么能决定SOFT的上限
SOFT算法的完整流程是“特征提取 → 立体匹配 → 帧间跟踪 → 运动估计 → 局部优化”,其中立体匹配和三角化极度依赖极线几何约束。原始图像的极线是一条斜线,右图上可能的匹配点落在一个二维搜索区域里,计算量大且容易误匹配;经过立体校正之后,左右图像行的极线被拉成水平直线,搜索范围从二维退化为一维,匹配速度和正确率都会有实质提升。
立体校正的精度还直接影响深度估计。在基线0.5米、焦距700像素的典型配置下,5米外一个像素的视差误差大约对应15厘米的深度误差,这个量级足以让后续位姿估计出现明显漂移。所以不要在特征算法上花太多精力之前,先确保stereoParameters和校正环节是准确的。
2.2 用stereoParameters组装立体相机模型
在MATLAB中,立体视觉模型的载体是stereoParameters对象。常见做法是左右相机分别做棋盘格标定,再用右相机相对左相机的外参把它们组合起来:
% 左右相机单独标定得到的内参 leftParams = cameraParameters('IntrinsicMatrix', Kl, ... 'RadialDistortion', [k1l, k2l, k3l], 'TangentialDistortion', [p1l, p2l]); rightParams = cameraParameters('IntrinsicMatrix', Kr, ... 'RadialDistortion', [k1r, k2r, k3r], 'TangentialDistortion', [p1r, p2r]); % 组装成立体参数对象 stereoParams = stereoParameters(leftParams, rightParams, ... 'RotationOfCamera2', R_rl, ... 'TranslationOfCamera2', t_rl);RotationOfCamera2是右相机坐标系到左相机坐标系的旋转矩阵,TranslationOfCamera2是右相机原心在左相机坐标系下的平移。方向和符号一旦搞反,校正后的左右图像会出现竖直方向错位,三角化得到的Z值变成负值或发散。我常用的验证方法很简单:把校正后的左右图各取一行,分别画成红色和绿色叠加显示,对应边缘应该落在同一水平线上,错位超过2个像素就要回头检查外参。
如果从零开始标定,直接用stereoCameraCalibratorApp 导入棋盘格视频序列,导出stereoParameters即可,背后做的工作与上面手动组装一致。
2.3 用rectifyStereoImages产出对齐图像
校正本身只是一个函数调用:
% J1和J2是校正且去畸变后的图像 [J1, J2] = rectifyStereoImages(leftImg, rightImg, stereoParams);输出图像的四个边角会出现黑色无数据区域,这是校正重映射的固有结果,后续特征检测不能在这些区域取点。处理方法是生成一个刚性掩膜,在整个序列期间复用:
mask = true(size(J1, 1), size(J1, 2)); mask(im2gray(J1) == 0) = false; mask = imerode(mask, strel('square', 4)); % 向内收缩,避开边缘imerode的核为什么取4x4而不是3x3?因为校正图的黑色边缘通常带一条半影带,3x3核缩进去的距离不够,取到的边缘特征点深度噪声很大,4x4或5x5更稳。
另外一个实用预处理:对校正后的灰度图做一次adapthisteq(自适应直方图均衡)。在室外强烈阳光下,把ClipLimit从默认0.01调到0.015,NumTiles从8x8调成16x16,可以显著挽救过曝路面上的弱角点,特征点数量通常能提升20%以上。
2.4 数据管线组织:帧队列与初始化
把整条处理链封装成函数时,输入是一个按时间排列的帧队列,每个元素包含左图、右图和可选时间戳:
function poses = runSOFTPipeline(stereoParams, frameQueue) numFrames = numel(frameQueue); poses = zeros(3, numFrames); % 每列是[x; y; z] for i = 1:numFrames leftImg = frameQueue{i}{1}; rightImg = frameQueue{i}{2}; % 第一帧定义为世界坐标系原点 if i == 1 poses(:, i) = initializePose(leftImg, rightImg, stereoParams); continue; end poses(:, i) = estimatePoseWithSOFT(leftImg, rightImg, stereoParams); end end把第一帧位姿定义为零,是视觉里程计领域的通用约定,后续所有位姿都以它为参考。有团队会把前两三帧做一次全局对齐再启动,但这会让轨迹与真值对比时多出一个不确定的线性变换,工程上并不划算。
2.5 跑里程计之前值得做的一组静态检查
我调试任何一版VO之前,都会先花10分钟检查输入侧:左右图时间戳是否同步(图像延迟超过5毫秒,高速运动下立体匹配会出现系统性视差偏移);图像是否经过去交错处理;视频编码有没有把暗部噪声压掉。自采数据跟KITTI比精度时,差距通常不在算法,而在这些输入质量上。
| 检查项 | 通过标准 | 失败时的典型症状 |
|---|---|---|
| 左右图时间同步 | 延迟小于5ms | 拐弯时轨迹外偏,RANSAC内点率骤降 |
| 校正对齐 | 同一行边缘错位不超过2px | 深度图出现水平重影 |
| 图像位深 | 8bit或16bit无损 | 暗部特征点集中在固定噪声位置 |
| 镜头清洁度 | 无油污/水渍 | 图像固定区域特征点反复消失 |
另外,如果刚装好MATLAB,跑本文代码需要勾选Computer Vision Toolbox、Image Processing Toolbox和Optimization Toolbox,前两个负责图像与特征,最后一个用在第四章的滑窗优化里。新版R2026a对vision.PointTracker的单精度输入支持已经比较完善,建议统一用single灰度图,减少double数组的分配开销。
3. SOFT核心:特征检测、立体匹配与帧间关联
3.1 用Shi-Tomasi角点做特征提取,而不是FAST
SOFT算法对特征点的要求是“位置足够精确,能在多帧间稳定跟踪”。FAST虽然快,但它只比较像素亮度差,角点定位精度较低,在多帧序列中容易出现抖动。SHi-Tomasi角点(最小特征值角点)的定位更稳定,噪声下的重复检测率更好,是SOFT类方法更常用的选择。MATLAB 里对应detectMinEigenFeatures:
pointsL = detectMinEigenFeatures(grayL, 'MinQuality', 0.001, 'ROI', roiBox);MinQuality的语义是保留那些响应值不低于全图最大响应值千分之一的点。取0.001配合ROI限制,在KITTI类室外场景大概能得到150到300个稳定特征点。roiBox格式是[x, y, width, height],用它排除天空和画面边缘。
内置函数没有直接的均匀化策略,我采用分块非极大值抑制,把图像切成64x64的小块,每块最多保留20个响应最高的点。相比直接在整图上取最强的前N个,分块后的特征点分布更均匀,后续帧间跟踪不容易出现特征聚簇导致位姿估计被局部区域主导。
深度学习中如果做视觉里程计,很多工作喜欢用学习到的特征点,但SOFT的正统做法还是传统角点,原因在于角点的亚像素定位是几何方法的地基,学习特征点除非显式约束可重复性,否则在递推里程计里反而容易累积异常。
3.2 立体匹配:SAD搜索与视差细化
拿到左图特征点后,需要在右图同一行搜索对应匹配。校正后的图像极线水平,搜索退化为沿x方向的一维问题。经典实现是用SAD块匹配:
function disp = stereoMatchSAD(leftPatch, rightScanline, winSize) halfW = winSize; scores = zeros(1, numel(rightScanline) - 2*halfW); for d = 0 : numel(rightScanline) - 2*halfW - 1 rightPatch = rightScanline(d+1 : d+2*halfW+1); scores(d+1) = sum(abs(leftPatch(:) - rightPatch(:))); end [~, bestIdx] = min(scores); disp = bestIdx - 1; % 抛物线拟合亚像素视差 if disp > 1 && disp < numel(scores) s0 = scores(disp-1); s1 = scores(disp); s2 = scores(disp+1); denom = (s0 - 2*s1 + s2); if abs(denom) > eps disp = disp + 0.5 * (s0 - s2) / denom; end end endwinSize取5或7比较常用,小于3时噪声敏感,大于11则深度断裂处匹配模糊化,损失边缘精度。视差搜索范围d_max由最小工作距离和基线决定:基线0.5米、焦距700像素时,d_max=128对应最近约2.7米。这个值设大了虽然能覆盖近处物体,但会引入更多的重复纹理误匹配,建议按实际工作距离计算,不要拍脑袋给256。
3.3 左右一致性检查:最有效的离群点过滤器
立体匹配最常见的失败原因是重复纹理。刷掉错误匹配最有效的手段是左右一致性检查(LRC):从左图特征点找到右图匹配点后,用这个右图点作为查询对象,在左图再搜索一次,如果走回到的不是原出发点,就判定为误匹配丢弃。
这里有个细节:反向匹配的搜索窗口必须与正向完全一致。正向用7x7,反向用5x5,各自的最小值位置会偏移,误删率可以相差5到8个百分点。我统一用5x5窗口,在大多数场景下保留率在70%到85%之间。
做完LRC检查后,视差小于1个像素的点也要剔除,因为此时深度接近无穷大,Z=f*b/d的公式在这里会输出异常值。原图上的深度有效范围,我一般限制在0.5米到30米,超出这个范围的点直接不参与后续三角化。
3.4 帧间跟踪:KLT光流与参数取舍
帧与帧之间的特征关联用KLT光流。MATLAB封装的vision.PointTracker足够稳定,不需要手动实现金字塔Lucas-Kanade:
tracker = vision.PointTracker('MaxBidirectionalError', 1.5, ... 'NumPyramidLevels', 4, 'BlockSize', [15 15]); initialize(tracker, prevPoints, prevGray); [currPoints, validIdx] = step(tracker, currGray);这三个参数需要根据机载运动速度调整:
| 参数 | 推荐起始值 | 调整说明 |
|---|---|---|
| MaxBidirectionalError | 1.5 | 双向光流误差阈值;快速运动时放宽到2.5 |
| NumPyramidLevels | 4 | 高速运动加一层;慢速巡检可减为3层 |
| BlockSize | [15 15] | 增大可抗噪,但会钝化角点响应 |
KLT跟踪完成后,用estimateFundamentalMatrix配合RANSAC剔除野值,这是帧间关联的最后一道防线:
[~, epipolarInliers] = estimateFundamentalMatrix(prevPoints, currPoints, ... 'Method', 'RANSAC', 'DistanceThreshold', 0.5, 'NumTrials', 2000);此处DistanceThreshold的0.5指的是Sampson距离阈值,单位是像素。对于KITTI这种运动平滑的交通场景,0.5到1.0都很合理;但如果画面里动态物体多(行人、车辆),阈值可以放开到1.5,同时增加NumTrials到5000,让RANSAC有更多机会找到干净的内点集。
3.5 三角化:从匹配点对到三维坐标
完成立体匹配之后,用校正后的左右点对直接调用triangulate,一次性得到三维点云:
points3D = triangulate(leftPoints, rightPoints, stereoParams);输入的leftPoints和rightPoints是Nx2的坐标矩阵,且需要已经按校正图像坐标对齐。输出points3D的每一行是[X Y Z],单位与stereoParams中平移矩阵一致(通常为米)。距离越远,三角化噪声按深度的平方增长,这也是为什么上一节要设深度上限:远点不仅没用,还会在后续优化里放大误差。
4. 运动估计与滑窗优化:SOFT的位姿求解与局部地图维护
4.1 3D-2D的PnP求解与RANSAC
有了前一帧的三维点P_prev和当前帧跟踪到的二维点p_curr,运动估计就变成一个标准的Perspective-n-Point(PnP)问题:求当前相机位姿,使三维点在当前帧的投影误差最小。SOFT 与单目VO的关键差异就在这里——它直接用立体匹配拿到三维点,因此单帧运动估计不需要依赖对极几何,尺度信息也不存在漂移。
MATLAB的estimateWorldCameraPose可以直接完成这一求解:
[worldOrientation, worldLocation] = estimateWorldCameraPose(... points3D, currentPoints2D, stereoParams.CameraParameters1, ... 'MaxNumTrials', 5000, 'Confidence', 99.9, 'MaxReprojectionError', 2.0); R_wc = worldOrientation'; % 相机在世界坐标系下的朝向 t_wc = worldLocation(:); % 相机位置,已经在世界坐标系下这里的worldOrientation实际上是把世界坐标点变换到相机坐标系的旋转矩阵,所以从相机角度还原到世界系需要转置;worldLocation本身已经是相机在世界系的位置,直接使用即可。轨迹如果出现镜像翻转,多半是少了这一步转置。MaxReprojectionError以像素为单位:设0.5在动态模糊场景下内点不足,设5.0又会把误匹配放进来,2.0对大多数双目配置是个安全的起始点。
提示:RANSAC 参数是跟着图像分辨率走的。KITTI的1241x376换到1920x1080输入时,
MaxReprojectionError需要放大0.5到1倍,直接照搬旧阈值会让内点率下降10个百分点以上。
4.2 运动先验与滑窗非线性优化
帧间PnP只利用相邻两帧,误差必然逐帧累积。SOFT给出的工程做法是引入运动先验,用前两帧的相对运动外推当前帧位姿,作为非线性优化的初值:
deltaPose = poses(:, end) - poses(:, end-1); predictedPose = poses(:, end) + deltaPose;这个先验在匀速运动场景下既是良好初值,也是一种正则项。低速平滑时它能避免优化掉进局部极小;快速急转弯时它虽然不准,但作为初值让LM迭代收敛得更快,整体内点率反而更高。
当滑动窗口内累积了多帧关键帧后,可以用lsqnonlin(来自MATLAB优化工具箱)构造一个轻量滑窗优化:
function residuals = reprojectionResidual(poseVec, pts3D, pts2D, K) T = vec2tform(poseVec); P_cam = T * [pts3D'; ones(1, size(pts3D,1))]; proj = K * P_cam(1:3, :); residuals = proj(1:2,:) ./ proj(3,:) - pts2D'; end options = optimoptions('lsqnonlin', 'Algorithm', 'levenberg-marquardt', ... 'Display', 'off', 'MaxFunctionEvaluations', 5000); optPose = lsqnonlin(@(p) reprojectionResidual(p, mapPoints, mapObs, K), ... initPose, [], [], options);vec2tform需要自己写:把6维位姿向量[x y z roll pitch yaw]转换为4x4齐次矩阵。MaxFunctionEvaluations默认只有几百次,但滑窗内残差函数维度较高,我一般设到5000。真正决定成败的不是迭代次数而是initPose,它必须来自运动先验——从一个错误初值开始,即使LM也不会收敛到正确解。
4.3 局部地图修剪与关键帧选择
滑窗优化的性能瓶颈在地图规模,而不是优化本身。我采用三条剪枝规则:每个三维点的被观测次数少于3次就直接删除;深度大于设定阈值的点只观测不参与优化;当前帧与最新关键帧的平均视差达到一定像素数才插入新关键帧。
meanDisparity = mean(vecnorm(currPoints - prevPoints, 2, 2)); if meanDisparity > 2.0 addKeyframe(points3D, currPoints); end注意vecnorm需要R2021b以上版本,旧版可以用sqrt(sum((currPoints-prevPoints).^2, 2))替代。视差阈值的取值取决于相机帧率:10FPS、车速5m/s时,帧间位移约2到4像素,阈值2.0能保持每秒2到5个关键帧;如果换成30FPS的ZED相机,建议调高到4.0,否则关键帧太密,滑窗退化成一个只做局部帧间配准的短视系统。
4.4 滑窗优化里最值得调的3个参数
| 参数 | 推荐起始值 | 调整策略 |
|---|---|---|
| 滑窗大小N | 10 | 增大能抑制漂移但增加耗时;超过15通常无明显收益 |
| 重投影误差阈值(像素) | 2.0 | 光照复杂上调到3.0;室内高对比下调到1.0 |
| 深度剔除阈值(米) | 30 | 室外空旷上调;室内下调到15 |
这三个参数建议做成外部配置项而不是硬编码。每次跑完一段序列,打印实际参与优化的关键帧数和三维点数,如果数量异常(比如少于50个三维点),先回去看特征检测和LK跟踪那两层的输出,而不是盲目调整滑窗。
5. 在KITTI与自制数据上验证SOFT:误差指标与失效排查
5.1 最快跑通一套验证:KITTI Odometry 00序列
自采立体视频耗时费事,先用KITTI视觉里程计基准的00序列做验证是最快路径。KITTI的灰度相机0/1已经是校正后的立体对,只需要从calib.txt里取出P0和P1矩阵构造stereoParams:
P0 = [fx 0 cx tx0; 0 fx cy 0; 0 0 1 0]; P1 = [fx 0 cx tx1; 0 fx cy 0; 0 0 1 0]; baseline = (tx1 - tx0) / fx;把fx、cx、cy填进cameraParameters,baseline作为TranslationOfCamera2的x分量。跑完后把轨迹和真值画在一起:
plot(gt(:,1), gt(:,3), 'k-'); hold on; plot(pose(1,:), pose(3,:), 'r-'); legend('Ground Truth', 'SOFT VO'); axis equal;5.2 ATE的计算必须先走Umeyama对齐
评估里程计精度最常用的是ATE(绝对轨迹误差)。很多初学者直接拿估计轨迹和真值对应点计算RMSE,得到的误差可能比真实漂移大一个量级——因为估计轨迹所在的坐标系和真值坐标系之间存在一个固定的旋转和平移偏差。正确做法是先做Umeyama 6-DOF对齐:
function R = umeyamaAlign(src, dst) mu_src = mean(src, 2); mu_dst = mean(dst, 2); H = (src - mu_src) * (dst - mu_dst)'; [U, ~, V] = svd(H); R = U * diag([1 1 det(U*V')]) * V'; enddet(U*V')处理反射情况,轨迹近似直线时少这一步会出现镜像翻转。RPE(相对位姿误差)通常取间隔100帧的相对变换来计算,与ATE合看,才能区分问题是“全局漂移大”还是“局部抖动大”。
5.3 三种高频失效模式与优先排查顺序
从KITTI换到自制室内数据时,最常遇到的失效有三个。第一种是过曝或眩光导致特征点骤减,对策是开启自动曝光,并在预处理里加一条img = min(img, 250)的像素截断。第二种是快速旋转或剧烈颠簸导致KLT全丢,先把vision.PointTracker的NumPyramidLevels提到4或5,同时把MaxBidirectionalError下调到1.0——宁肯丢点也不能留下野值。第三种是单调纹理走廊里角点不足,此时可以把detectMinEigenFeatures换成detectORBFeatures,按分块策略保留每块响应最高的点,角点存活率通常更高。
5.4 一个先查标定再调算法的实用技巧
当整段轨迹的终点误差超过行驶距离的5%时,先别急着调滑窗参数。让机器人沿已知直线行走5米,跑完输出终点距离:
endDist = norm(pose(:, end) - pose(:, 1)); fprintf('实测距离 %.2f m, 真值 5.00 m, 偏差 %.1f%%\n', ... endDist, abs(endDist - 5.0) / 5.0 * 100);如果误差大于5%,优先检查baseline标定值——深度方向的系统性偏差几乎都出自基线长度不准。先做一段直线运动的闭环位移检查,能快速区分标定误差和算法误差,避免在错误的环节上空耗时间。这个技巧比任何参数搜索都更值得先做。
本文还有配套的精品资源,点击获取