简介这是使用OpenCV 3.2实现双目校正与双目测距的C工程代码采用块匹配算法完成立体匹配与视差计算。资料面向计算机视觉初学者以及从事机器人避障、自动驾驶和三维感知的开发者有助于理解从相机标定、双目校正到深度恢复的完整链路。压缩包共三十八个文件容量约九点四三兆字节以Visual Studio解决方案、工程配置、C源代码和OpenCV标定文件为核心同时提供调试与发布版本的可执行程序、符号文件和编译日志既可直接运行也便于修改重编目前已有1776人学习下载。代码经过实测原始块匹配结果存在偏差作者利用线性拟合后处理将测距误差控制在数厘米量级方便读者对比参数并观察优化效果。配套的内外参标定文件和清晰的工程目录为替换相机、重新标定以及二次开发提供了可复用的模板是一份兼顾理论与实操的入门级双目视觉项目。1. 为什么建议你先跑通BM再做SGBM双目测距的版本适配与选型逻辑OpenCV 3.2 的双目校正与测距代码说复杂也复杂说简单也简单。我拆过很多套类似代码后发现最容易翻车的不是标定环节而是视差图出来后不知道怎么换算成距离。这套资源核心覆盖StereoBM::create、stereoRectify、reprojectImageTo3D三段主流程能解决从双目标定到视差图生成、再到深度恢复的一整条链路。适合有单目相机标定基础、但还没有把视差图真正变成物理距离的从业者。如果你是拿它做机器人避障、目标测距或者实验验证先把 BM 算法跑通再去看 SGBM这个顺序能省掉大半排查时间。2. 双目校正的原理与OpenCV 3.2接口细节坐标系、畸变与BM算法选型2.1 双目系统为什么必须校正极线约束与视差搜索的本质双目测距的核心不是“测”而是“匹配”。左右相机拍同一个物体理论上能在两张图上找到同一个空间点对应的像素位置这两个像素的横坐标差值就是视差。但现实里左右相机之间存在旋转和平移再加上镜头畸变同一个点在左右图上的位置并不在同一水平线上。如果不做校正匹配就得在二维平面上搜索计算量剧增误匹配率也高。校正的目的是把左右图像重新投影到同一个虚拟平面上让左右图像的极线变成水平线。这样一来匹配只需要沿 x 轴搜索视差就是单纯的横坐标差。OpenCV 3.2 里实现这一套逻辑的函数很明确参数组织也有固定套路stereoCalibrate输出左右相机之间的旋转矩阵R和平移向量T。stereoRectify根据R、T和左右相机内参输出左右校正旋转矩阵R1、R2左右投影矩阵P1、P2以及重投影矩阵Q。initUndistortRectifyMap把上述矩阵转换成左右图像的映射表mapX、mapY后面直接remap用。Q矩阵很多人忽略但它是视差转距离的钥匙。Q是一个 4x4 矩阵格式大致如下位置含义Q[0][0]焦距相关的缩放系数Q[1][1]焦距相关的缩放系数Q[0][3]左图像主点 xQ[1][3]左图像主点 yQ[3][2]1 / 基线长度单位由标定时的平移向量决定Q[2][3]右图像主点 x 与左图像主点 x 的差值我一般会把Q矩阵打印出来看一眼如果Q[3][2]约等于 0说明基线没正确传给标定过程后面深度恢复出来的数据全是乱的。2.2 BM算法与SGBM的取舍轻量场景下的现实选择OpenCV 3.2 同时提供了StereoBM和StereoSGBM两个类。BM 全称 Block Matching块匹配基本思路是取左图一个小块在右图同一行范围内滑动找灰度差异最小的位置作为匹配点。SGBM 全称 Semi-Global Block Matching半全局块匹配增加了平滑约束和多方向扫描对低纹理区域的容错明显更强代价是耗时翻好几倍。这套资源选 BM 算法我猜是为了在普通 CPU 上跑实时测距。BM 的耗时主要取决于搜索范围和块大小在 640x480 分辨率下通常能跑到 30ms 以内而 SGBM 动不动就要 200ms 起步。如果你的场景是室内桌面测距、物体轮廓清晰的抓取定位BM 够用但如果场景里有大面积白墙、天空或者光滑地面BM 的视差图会有很多黑洞这时候你再去换 SGBM 也不迟。选型时注意一个容易被忽略的点BM 对输入图像的纹理密度非常敏感。纹理越丰富匹配越准纹理越少斑点噪声越重。所以如果你用的是工业相机配均匀光源反倒可能效果不好稍微加一点光照梯度反而有助匹配。2.3 OpenCV 3.2与新版API的差异版本兼容的常见做法用 OpenCV 3.2 最大的坑是它夹在 2.x 和 3.4/4.x 之间接口风格混合。你在网上搜到的代码很多是 2.x 的cv::StereoBM对象直接operator()到了 3.2 已经被弃用改用Ptrcv::StereoBM bm cv::StereoBM::create()。我在拆这套代码时遇到的第一处不一致就是这里。资源作者大概是在 3.2 环境下写的所以create风格的接口贯穿始终。如果你之前用的是 4.x会注意到 4.x 里StereoBM::create还在但内部参数名有变化比如setPreFilterCap在 4.x 中支持范围不同。更稳妥的做法是编译时判断版本宏#if CV_VERSION_MAJOR 4 // 4.x 分支 cv::Ptrcv::StereoBM bm cv::StereoBM::create(0, 0); #else // 3.x 分支 cv::Ptrcv::StereoBM bm cv::StereoBM::create(); #endif这个宏判断在代码包里不一定有我通常建议自己加进去省得以后换环境重编时到处找差异。另一个跨版本差异是stereoRectify的最后一个参数alpha3.2 里默认是-1表示自动裁剪黑色边角4.x 也保留了这行为但有些第三方编译版可能改过默认值建议显式传参。3. 双目标定到深度估计的完整代码流程从棋盘格到真实距离3.1 单目与立体标定棋盘格角点检测和参数组合双目标定不是一次性完成的它分成两步先分别标定左右相机的内参和畸变系数再联合标定左右相机之间的外参。第一步的单目标定结果直接决定第二步的精度所以不要跳过这一步直接去做stereoCalibrate。下面这段代码是双目标定中棋盘格角点检测和物体点生成的部分用的是 OpenCV 3.2 的常用写法// 棋盘格内角点数量例如 9x6 表示每行9个、每列6个内角点 cv::Size boardSize(9, 6); // 如果棋盘格是从市场上买的 24mm 边长这里 squareSize 填 24.0 float squareSize 24.0f; std::vectorcv::Point3f objectCorners; for (int j 0; j boardSize.height; j) { for (int k 0; k boardSize.width; k) { objectCorners.push_back(cv::Point3f(k * squareSize, j * squareSize, 0.0f)); } }逻辑说明boardSize说的是内角点数不是棋盘格黑白格总数。常见误用是把整块棋盘的格子数量填进去导致findChessboardCorners永远检测不到正确角点。squareSize为什么用浮点因为后面的stereoCalibrate需要真实世界单位你用毫米就全用毫米用厘米就全用厘米混了之后基线T的单位会错最终距离换算全部偏移。我这里以毫米为准后面Q[3][2]算出来的深度也是毫米。实际标定采集时我一般会左右各拍 20 对图像棋盘格在画面里要覆盖不同位置、不同倾角最好每张里棋盘占画面面积的 30% 以上。少于 15 对时stereoCalibrate输出的T向量会不稳定重投影误差看着挺低但校正出来的图像边界有明显扭曲。下面是stereoCalibrate的核心调用std::vectorstd::vectorcv::Point3f objectPoints; std::vectorstd::vectorcv::Point2f imagePointsL, imagePointsR; // 左右内参、畸变系数由单目 calibrateCamera 得到 cv::Mat K1, D1, K2, D2; cv::Mat R, T, E, F; int flag cv::CALIB_FIX_INTRINSIC; cv::stereoCalibrate(objectPoints, imagePointsL, imagePointsR, K1, D1, K2, D2, imageSize, R, T, E, F, flag);flag参数这里用了CALIB_FIX_INTRINSIC表示左右内参不再参与优化只解外参。这样做的理由是单目标定已经有足够精度时双目标定再去动内参容易把误差分散到旋转矩阵里导致后续校正出现奇怪的弯曲。如果你的单目内参是从网上随便抄的就不要用这个标志改用CALIB_USE_INTRINSIC_GUESS让双目标定微调内参。E和F在 3.2 中可以不传但我建议保留一份用它们做 sanity check本质矩阵E的奇异值理论上应该有两个相等、一个为 0如果离谱说明标定图像之间存在误匹配或原始像素坐标没有去畸变。3.2 立体校正映射表stereoRectify 与 initUndistortRectifyMap 的组合使用拿到R、T之后下一步是把左右图像校正到同一坐标系。stereoRectify做的事情是计算左右相机的校正旋转矩阵和投影矩阵但它不直接生成映射表必须配合initUndistortRectifyMap使用。cv::Mat R1, R2, P1, P2, Q; cv::Size imageSize(1280, 720); cv::stereoRectify(K1, D1, K2, D2, imageSize, R, T, R1, R2, P1, P2, Q, cv::CALIB_ZERO_DISPARITY, 0, imageSize); // 为左右相机各生成一张映射表 cv::Mat mapLx, mapLy, mapRx, mapRy; cv::initUndistortRectifyMap(K1, D1, R1, P1, imageSize, CV_32FC1, mapLx, mapLy); cv::initUndistortRectifyMap(K2, D2, R2, P2, imageSize, CV_32FC1, mapRx, mapRy); cv::Mat rectifiedL, rectifiedR; cv::remap(left, right, setter());alpha0表示让校正后的图像裁剪到最大内接矩形去掉黑色边界实际上丢失了部分画面边缘。alpha-1会自动选择最小裁剪区域尽量保留原始视野但四个角会多出黑色。做测距时我强烈建议不要用alpha0因为裁剪掉边缘后图像尺寸不变但有效视角变小距离换算时主点在Q矩阵里的位置对不上测出的偏差会比用alpha-1大。alpha1保留所有像素包括黑边适合调试阶段用等参数稳定后再换回-1。CV_32FC1是映射表的标准类型不要改成CV_16SC2。虽然CV_16SC2更快但 OpenCV 3.2 中initUndistortRectifyMap对CV_16SC2的支持在部分平台上实现不一致换成CV_32FC1的耗时差别几乎感知不到。3.3 BM算法核心计算参数和深度恢复映射表做好后remap把左右图像校正成极线水平对齐图像接下来就是 BM 的计算。这里是整套资源里最需要手工调节的模块int numDisparities 64; int blockSize 11; cv::Ptrcv::StereoBM bm cv::StereoBM::create(numDisparities, blockSize); bm-setPreFilterType(cv::StereoBM::PREFILTER_XSOBEL); bm-setPreFilterSize(9); bm-setPreFilterCap(31); bm-setMinDisparity(0); bm-setNumDisparities(numDisparities); bm-setTextureThreshold(10); bm-setUniquenessRatio(10); bm-setSpeckleWindowSize(100); bm-setSpeckleRange(32); cv::Mat disp, dispShow, depth3D; bm-compute(rectifiedL, rectifiedR, disp); // 视差图归一化显示方便肉眼验证 disp.convertTo(dispShow, CV_8U, 255.0 / numDisparities); // 深度的恢复 cv::reprojectImageTo3D(disp, depth3D, Q, true, CV_32F);这里的参数含义必须说清楚因为很多人就是在这里反复尝试numDisparities是视差搜索范围必须能被 16 整除且大于 0。这个值决定最近可测的距离取值越大能测的距离越近但计算量线性上升。640x480 图像上 64 是底线如果物体离镜头很近需要把搜索范围提到 96 或 128。blockSize是匹配块尺寸必须为奇数。数值越大视差图越平滑但边缘细节越容易丢失数值越小边缘越清晰但噪声越大。做测距时物体边缘的视差不准确会直接导致距离跳动所以我会把物体内部区域作为测量依据而不是边缘。blockSize0在 3.2 中是非法参数会直接在compute里报错这跟 4.x 的部分版本行为不同。preFilterSize是预处理窗口大小必须为奇数且要大于blockSize否则预处理没有实际意义。preFilterCap是预处理截断值默认 31范围 163数值越大纹理响应越强。注意setPreFilterType只用PREFILTER_XSOBEL或PREFILTER_NORMALIZED_RESPONSE两种不要传入 0 开头的枚举。uniquenessRatio是视差唯一性比例取值 515 比较常见。如果太小误匹配会大量出现如果太大有效视差点会被削掉视差图看起来会很稀疏。当视差图出现明显的“条纹”时优先调大这个值而不是调textureThreshold。speckleWindowSize和speckleRange是斑点滤波参数。前者决定多大的视差连通域可以被滤除后者决定连通域内视差值的允许波动范围。测距场景下我习惯把speckleWindowSize设为blockSize * 10左右speckleRange设成 16 或 32。如果speckleRange太小真实物体上的小视差波动也会被当成噪声滤掉导致物体表面缺一块。compute返回的disp类型是CV_16S有符号 16 位整数。有效视差值是除以 16.0 后的浮点数负值表示没有匹配到。所以拿disp直接convertTo成CV_8U做显示时要先除以 16 再线性拉伸不然画面会明显偏暗。上面代码用的缩放系数是255.0 / numDisparities这只适用于minDisparity0且视差范围恰好等于numDisparities的场景。如果改了minDisparity显示公式也要改。reprojectImageTo3D里handleMissingValuestrue会把无效视差点的三维坐标置成 10000大数这样后续统计时可以排除。depth3D是三通道CV_32F图像第一通道是 x 坐标、第二通道是 y 坐标、第三通道是深度 z。注意这里的 z 方向是相机光轴方向不是物体到相机的直线距离。如果你要输出毫米级的距离直接用depth3D.atcv::Vec3f(y, x)[2]即可如果要测斜向距离得把 x、y、z 一起算模长。4. 避坑指南双目校正与BM算法在真实场景下的五个高频雷区4.1 视差图全黑但算法没报错CV_16S换算方式错位现象bm-compute正常执行但显示的视差图全黑或全部是同一灰度。原因disp是CV_16S类型里面的数值是真实视差的 16 倍而且负值代表无效匹配区域。直接把CV_16S转CV_8U负数被截断成 0正数因为没有除以 16又大多超出 255 范围变成白色最终显示效果肯定不对。解决先除以 16 再做线性映射或者用下面这个通用逻辑cv::Mat dispScaled; disp.convertTo(dispScaled, CV_32F, 1.0 / 16.0); cv::Mat dispShow; double minVal, maxVal; cv::minMaxLoc(dispScaled, minVal, maxVal); dispScaled.convertTo(dispShow, CV_8U, 255.0 / (maxVal - minVal));这样做的好处是不管numDisparities怎么变显示结果都能动态适配范围。从那以后我每次调试视差图都用这段做预处理再没遇到过全黑问题。4.2 校正后图像仍有明显垂直视差alpha参数和图像尺寸在作怪现象左右图remap后把两张图上下并列放在一起明显看到对应特征点不在同一水平线上视差图也出现大量横向条纹噪声。原因最常见的原因是标定图像尺寸和实际remap的图像尺寸不一致。findChessboardCorners用的imageSize如果是 640x480但remap时输入的图像是 1280x720映射表坐标全部错位。第二种原因是stereoRectify的alpha传了0导致图像被裁剪后又拉伸垂直方向的误差被放大。解决检查initUndistortRectifyMap的第三个参数和stereoRectify的第四个参数是否都是同一个imageSize。alpha先设为-1重新生成映射表后再做remap。验证对齐除了用肉眼看还可以在两张校正图上画一条水平线取同一特征点看 y 坐标差值规范做法是左右 y 差值在 1 个像素以内。4.3 距离测量偏差整体偏大Q矩阵基线与标定平移单位不一致现象视差图看起来正常物体轮廓清晰但测出的距离比实际距离偏大或偏小固定倍数。原因stereoCalibrate输出的T[0]绝对值就是基线长度它的单位取决于构造objectPoints时squareSize的单位。如果你用毫米标定但reprojectImageTo3D之后期望输出米就会差 1000 倍。还有一种情况是有人在stereoRectify里手动调整过T的值或者乘以系数导致Q[3][2]不对。解决把T[0]打印出来确认它和实际基线长度在同一单位下匹配。然后用一个已知距离的物体做验证比如在 500mm 处放一个标定板读取depth3D.atcv::Vec3f(y, x)[2]看是不是 500 附近。如果偏差是一个固定比例直接更新Q矩阵的Q[3][2]为1.0 / 实际基线长度但更稳妥的做法是重新跑一遍双目标定不要手动改Q因为手动改只修了一个维度x 和 y 方向的映射可能会乱。4.4 低纹理区域出现条纹或空洞纹理阈值与块大小互锁现象白色墙壁、桌面这些区域在视差图里出现横条纹或者空洞连成一片而纹理丰富的区域一切正常。原因BM 算法依赖图像局部灰度差异。低纹理区域的灰度梯度太小匹配代价函数在多个位置都能拿到相似的低值算法无法区分真实匹配点。textureThreshold设得太低时这些区域会被强行赋予视差值产生条纹。解决先提高textureThreshold比如从 10 提到 15 或 20无效区域会变多但误匹配会变少。如果空洞太多就把blockSize调大一点来获得更多纹理上下文但要同时增加numDisparities以保持边缘精度。调这两项要反复对比没有真正意义上的万能组合我的习惯是固定numDisparities64依次试blockSize5, 9, 15, 21每次只改一个变量记录一组视差图的空洞率和平均耗时。4.5 程序运行时间越来越长且内存占用上升map类型和Mat浅拷贝隐患现象程序刚跑起来时速度正常几分钟后越来越卡内存占用缓慢爬升最终几乎卡死。原因initUndistortRectifyMap生成的映射表如果用CV_16SC2短整型结构在某些 3.2 版本中remap会反复申请临时缓冲区。另一个常见来源是在循环里直接rectifiedL left.clone()之后继续remap到同一个rectifiedL导致 OpenCV 内部创建临时对象并持续累积。本质上不是内存泄漏而是运行时不释放的临时缓冲区。解决把映射表统一用CV_32FC1生成一次并保存在成员变量中循环里只做remap。remap的目标Mat提前分配好空间不要用复制cv::Mat rectifiedL(imageSize, CV_8UC3); while (capture.read(frame)) { cv::remap(frame.left, rectifiedL, mapLx, mapLy, cv::INTER_LINEAR); }如果确认是 3.2 的已知性能问题可以在remap后手动清空rectifiedL的一部分来强制释放内存但大多数场景用固定大小Mat就能解决。遇到这种维持一段时间才爆发的问题时逐帧调用cv::getTickCount()统计耗时能看到第多少帧开始异常方便定位。5. 验证与进阶从视差图到精确距离的实操技巧最后说一个我常用的验证套路它能把盲调的挫败感降到最低。拿到一套标定和测距代码后我不会急着测实际距离而是先做一次相对验证把棋盘格放在三个已知距离处比如 300mm、500mm、1000mm采集三组左右图像跑完校正和 BM 后分别取棋盘格中心点的视差值计算出三个距离值再做一次线性拟合。如果三个距离的偏差比值基本一致说明标定和Q矩阵是自洽的剩下要调的就是 BM 参数带来的随机波动。进阶技巧里视差图的空洞可以用cv::erode和cv::dilate做闭运算补一下但要注意这会把小物体的边缘也抹掉。更好的做法是在speckleFilter之后加一个简单的连通域分析把小于阈值的视差斑点强制置为 0我之前自己实现过一个版本效果比直接调speckleWindowSize更可控。对于精度提升我建议在numDisparities64、blockSize9的基础上先用 5 张不同物体的真实图像做定量评估。测量距离时不要用单点而是在depth3D上取一个 5x5 的窗口求中值这样能抵消 BM 单像素匹配的抖动。如果最终精度要求到毫米级BM 的极限可能不够这时候可以切换到StereoSGBM并把P18*channels*blockSize*blockSize、P232*channels*blockSize*blockSize作为初始值多出来的耗时用缩小搜索范围来抵消。另外我每次都会固定相机的自动增益、自动白平衡和曝光时间让rectifiedL和rectifiedR保持一致的亮度。很多人视差图反复不对最后发现是左右相机的自动曝光不一致导致同一物体在左右图像上的灰度差异过大BM 的匹配代价被严重干扰。整套代码跑通之后我给自己定了个习惯每次换相机或者调过标定参数先打印T[0]和Q[3][2]再用三组已知距离做线性拟合验证全部通过后才继续调 BM 参数。这套流程救过我很多次希望帮到你。本文还有配套的精品资源点击获取