
从2014年写完这套程序到现在我一直觉得它是自己机器视觉学习路上性价比最高的一次投入。当时在CSDN连载OpenCV学习笔记写到摄像机定标和立体匹配的时候发现市面上教程虽然不少但绝大多数都是控制台程序跑完就黑屏连中间结果都看不清。于是我把整个流程封装成了一个MFC对话框程序从双摄像头采集、棋盘格标定到立体校正、视差图输出全部放进带按钮和图片控件的窗口里点一下按钮看一步结果方便调参数也方便排查问题。这篇博文就把这套测试程序从设计思路到具体实现完整拆开讲一遍适合正在学OpenCV双目视觉、想用MFC做上位机界面的C开发者参考。1. 项目背景与整体设计思路1.1 为什么用MFC而不是其他界面方案很多初学者拿到OpenCV第一件事就是把它装好、跑一个imshow看看然后立刻发现一个问题本地调试没问题一旦想做一个稍微像样点的工具控制台窗口根本不够用。当时我面临的选择有三个纯控制台、Qt、MFC。纯控制台最省事OpenCV自带HighGUI窗口能显示图像能响应简单的鼠标事件但交互能力很弱想做一个“采图-标定-匹配”三步操作的工具用户不可能对着黑窗口输命令。Qt的界面效果好、跨平台但在2014年前后Qt和VS的整合远没有现在顺滑而且当时我的项目全部基于Windows环境没有跨平台需求引入Qt反而多一层学习成本。MFC在当时是Windows桌面开发的主流选择VS对MFC的调试支持非常成熟对话框模板拖控件也方便。我的实际需求是界面要能同时显示左右相机画面和标定结果要有按钮触发定标和匹配流程要能拖动滑条实时调整SGBM参数。这些用MFC的Picture Control、Button、Slider来搭非常顺手。所以最终选了VS2013 MFC对话框工程 OpenCV 2.4.9这套组合。1.2 测试程序的总体架构与模块划分这个程序的定位是“测试程序”不是生产级软件所以架构没必要设计得太重。我按照功能把程序拆成了四个模块图像采集模块负责打开左右两个USB摄像头实时采集画面负责标定板图像的“同时抓拍”。单目标定模块对左右相机分别计算内参矩阵和畸变系数。双目标定模块基于单目标定结果计算两个相机之间的旋转矩阵和平移向量。立体匹配模块先做立体校正再做BM或SGBM匹配最终输出视差图。四个模块之间通过MFC按钮事件串联数据用全局结构体保存。比如CalibResult结构体里存左右相机的内参矩阵、畸变系数、旋转矩阵和平移向量单目标定结束后填充这个结构体双目标定再从结构体里取数据作为初始值。这样模块之间的耦合度低单独调试某一环节也方便。之所以把模块拆得这么清楚有一个很现实的原因双目标定涉及多个环节如果所有代码全塞在一个按钮响应函数里一旦标定结果不对根本不知道是角点提取的问题、单目标定的问题还是立体标定的问题。分模块之后每步结果都可以在界面上的Edit控件或输出窗口里看到定位问题快很多。2. 开发环境搭建与工程配置2.1 版本选型OpenCV 2.4.9与VS2013的组合先说版本选择这件事。2014年的时候OpenCV 3.0还在RC阶段主流的稳定版本是2.4.x系列。我选2.4.9是因为它修复了之前版本的一些已知bug而且2.4.x保留了完整的C接口像cvFindChessboardCorners、cvStereoCalibrate这些函数在2.4.x里用起来很顺手。我承认现在回看2.4.x的API确实老旧但当时这套接口在CSDN上资料最全遇到问题一搜一大把解决方案。如果你现在做类似项目用OpenCV 4.x也完全可以核心流程没有本质变化只是函数名和参数结构变了。这篇博文里的思路可以照搬代码需要做一层API迁移。2.2 MFC工程接入OpenCV的完整配置OpenCV在VS环境下的配置是新手最容易卡住的地方。当年我给很多同学调过环境90%的问题都出在三个地方环境变量、附加依赖项、Debug/Release库混用。配置流程分四步安装OpenCV。下载2.4.9的Windows安装包解压到D:\opencv在系统环境变量Path中添加D:\opencv\build\x86\vc12\bin。注意是vc12对应VS2013。VS2010对应vc10VS2012对应vc11选错版本会导致运行时崩溃。新建MFC对话框项目。VS2013新建项目时选“MFC应用程序”应用程序类型选“基于对话框”在“MFC的使用”一栏选“在静态库中使用MFC”。选静态库的好处是程序发布时不用带着一堆MFC DLL坏处是编译出的exe体积会大一些。个人测试程序选静态库更省心至少不会出现换台电脑就报“缺少mfc100u.dll”的问题。配置工程属性。打开项目属性页在“VC目录”的“包含目录”里添加D:\opencv\build\include在“库目录”里添加D:\opencv\build\x86\vc12\lib。填写附加依赖项。这里有个很容易踩的坑Debug和Release模式要填不同的库文件名。OpenCV 2.4.x的库文件名带d后缀表示Debug版本不带d的是Release版本。例如// Debug模式附加依赖项 opencv_calib3d249d.lib opencv_core249d.lib opencv_highgui249d.lib opencv_imgproc249d.lib opencv_features2d249d.lib opencv_flann249d.lib opencv_legacy249d.lib opencv_ml249d.lib opencv_objdetect249d.lib opencv_photo249d.lib opencv_stitching249d.lib opencv_ts249d.lib opencv_video249d.lib opencv_videostab249d.lib // Release模式附加依赖项 opencv_calib3d249.lib opencv_core249.lib // ...以此类推去掉d如果Debug模式填了不带d的库编译能通过但运行时会因为符号不匹配产生一堆莫名其妙的内存错误。这种错误最难查表面上看是图像数据损坏实际是Debug堆和Release堆被混用了。我的建议是把所有附加依赖项分Debug和Release两套配好一次到位。至于网上很多帖子提到的“让控制台程序支持MFC”的方法也就是在控制台工程里加#include afxwin.h并修改入口函数我后来试过几次维护成本比直接建MFC工程高得多不建议走这条路。2.3 图像显示CvvImage与Picture Control的配合MFC的Picture Control本身只负责显示位图而OpenCV处理的图像格式是IplImage或Mat两者之间需要一个桥接。OpenCV 2.4.x官方没有提供MFC专用的显示类但网上流传很广的CvvImage类基本是标配。CvvImage的核心就两个方法CopyOf把IplImage复制到内部缓存DrawToHDC把图像绘制到指定DC。在MFC里用Picture Control显示图像的典型代码是这样的void CCalibTestDlg::ShowImage(CDC* pDC, CvvImage img, int nCtrlID) { CWnd* pWnd GetDlgItem(nCtrlID); CRect rect; pWnd-GetClientRect(rect); CDC* pCtrlDC pWnd-GetDC(); img.DrawToHDC(pCtrlDC-GetSafeHdc(), rect); pWnd-ReleaseDC(pCtrlDC); }调用的时候把Mat或IplImage转成CvvImage然后刷新Picture Control的客户区即可。这里有一个必须注意的问题CvvImage不是标准OpenCV库文件需要自己把CvvImage.h和CvvImage.cpp加到工程里。网上流传的版本很多有的在Unicode字符集下会报错需要把CString相关的char*转换处理好。我当时用了一个改好的版本关键就是把里面所有char改成TCHAR再用CString::GetBuffer做转换。另外一个容易被忽略的点Picture Control默认有SS_BITMAP样式和边框显示图像时如果不想图片被边框裁掉要在OnPaint里先擦除背景再调用DrawToHDC。否则你会看到图像周围有一圈难看的白边怎么调都对不齐。3. 摄像机定标模块的实现细节3.1 标定板的选择与图像采集摄像机定标说白了就是通过拍摄已知尺寸的标定板计算相机内部参数焦距、主点坐标和畸变系数。标定板的选择直接决定定标质量。我用的是一张打印的7×10棋盘格内角点数为9×6每个格子边长25mm贴在平整的硬纸板上。内角点数的选择是有讲究的。OpenCV的cvFindChessboardCorners要求棋盘格必须是奇数行或奇数列这样内角点的邻域才有明确的黑白分界。9×6是资料里最常用的配置角点数量足够多有利于参数收敛。图像采集是标定流程里最容易被轻视的一步。很多初学者随便拍三五张就开始标定结果重投影误差大得离谱。我当时给自己定的标准是左右相机各采集15~20张不同姿态的标定板图像并且要求每张图像里棋盘格完整出现在画面中不能有遮挡尽量避免高光反射。采集的时候有两个细节值得注意。第一标定板要倾斜、旋转、远近都覆盖到光平移不旋转的话标定结果的外参退化和内参不准问题很难暴露。第二两个相机的采集必须“同时”进行。虽然USB摄像头做不到硬件同步但可以用一个按钮触发左右相机各抓一帧的方式做软同步。我这个程序里的“抓拍标定板”按钮就是干这件事的。3.2 角点提取与单目标定单目标定的代码流程比较固定// 查找棋盘格角点 CvSize boardSize cvSize(9, 6); int found cvFindChessboardCorners(grayImg, boardSize, corners, CV_CALIB_CB_ADAPTIVE_THRESH | CV_CALIB_CB_NORMALIZE_IMAGE); // 亚像素精化 cvFindCornerSubPix(grayImg, corners, count, cvSize(11,11), cvSize(-1,-1), cvTermCriteria(CV_TERMCRIT_ITER | CV_TERMCRIT_EPS, 30, 0.01)); // 标定单目相机 cvCalibrateCamera2(objectPoints, imagePoints, pointCounts, imgSize, cameraMatrix, distCoeffs, NULL, NULL, 0);cvFindChessboardCorners的核心原理是先用全局阈值把图像二值化再用轮廓提取和四边形拟合找到棋盘格区域最后通过亚像素细化确定角点位置。这个函数对图像质量比较敏感所以采集时要保证光线均匀。单目标定的输出是相机内参矩阵A和畸变系数DA [fx 0 cx] [ 0 fy cy] [ 0 0 1]其中fx和fy是焦距单位是像素cx和cy是主点坐标。畸变系数包括径向畸变参数k1, k2和切向畸变参数p1, p2。为什么要标定可以用一个生活化的例子解释镜头是透镜透镜的边缘会让直线变弯这就是畸变。如果你不纠正这个“弯”照出来的棋盘格边缘就不是直的立体匹配的时候左右图像上同一个点的坐标会差出好几个像素深度计算自然就不准了。3.3 双目立体标定单目标定解决的是“单个相机看到的世界长什么样”双目标定则要回答“左右两个相机之间的相对位置是什么”。这个相对关系用旋转矩阵R和平移向量T描述。OpenCV的双目标定函数是cvStereoCalibratecvStereoCalibrate(objectPoints, imagePoints1, imagePoints2, pointCounts, cameraMatrix1, distCoeffs1, cameraMatrix2, distCoeffs2, imageSize, R, T, E, F, CV_CALIB_FIX_INTRINSIC, // 这里固定内参只优化外参 cvTermCriteria(CV_TERMCRIT_ITER | CV_TERMCRIT_EPS, 100, 1e-5));我在程序里用了CV_CALIB_FIX_INTRINSIC标志意思是左右相机的内参和畸变系数已经通过单目标定确定了这一步只优化R和T。这么做的好处是收敛快结果稳定不会出现内参外参互相“抢误差”导致的结果漂移。3.4 标定结果质量评估标定不是跑完函数就万事大吉。我建议每次标定完都看一眼重投影误差这个数字直接反映了标定结果的精度。重投影误差的计算思路是把棋盘格的3D角点按标定得到的内参和外参重新投影回图像平面计算投影点和实际检测到的角点之间的像素距离均值。OpenCV里用cvProjectPoints2可以算但2.4.x的cvStereoCalibrate本身就会在返回值里给出RMS误差。我的经验值RMS小于0.3像素说明标定结果很好0.3到0.5像素之间还能接受超过0.5像素建议重新采图标定。误差大一般是因为采集图像数量不足、某张图像里棋盘格有轻微弯曲或者标定板不平整。还有一个容易被忽略的问题两个相机的视野和分辨率最好一致。我当时有一对同型号的摄像头但其中一个镜头上有轻微的划痕标定结果一直不理想。后来换了摄像头之后误差立刻降下来了。硬件本身的问题软件再调也没用。4. 立体匹配与视差计算模块4.1 立体校正从两幅图到行对齐立体匹配的基本假设是左右图像中对应的匹配点位于同一行上。这个假设在现实中基本不成立因为两个相机总是存在一定的旋转和平移误差导致左右图像不是严格的行对齐。立体校正就是通过重投影把左右图像变换到行对齐的状态。OpenCV的立体校正流程是// 1. 计算校正映射 cvStereoRectify(cameraMatrix1, distCoeffs1, cameraMatrix2, distCoeffs2, imageSize, R, T, R1, R2, P1, P2, Q, CV_CALIB_ZERO_DISPARITY, 0); // 2. 计算重映射表 cvInitUndistortRectifyMap(cameraMatrix1, distCoeffs1, R1, P1, imageSize, CV_32FC1, mapx1, mapy1); cvInitUndistortRectifyMap(cameraMatrix2, distCoeffs2, R2, P2, imageSize, CV_32FC1, mapx2, mapy2); // 3. 执行重映射 cvRemap(frame1, rectified1, mapx1, mapy1); cvRemap(frame2, rectified2, mapx2, mapy2);其中Q矩阵是视差到三维坐标的转换矩阵后面生成深度图时会用到。CV_CALIB_ZERO_DISPARITY标志会让两个相机的主点校正到同一行上这样视差值的参考点一致输出的视差图物理意义更清晰。校正完之后可以把左右图像上下拼在一起或者用cvLine画几条水平辅助线检查棋盘格角点是否在同一行上。这一步的视觉效果非常直观也是判断双目标定是否成功的一个重要指标。4.2 BM与SGBM匹配算法对比与参数选择OpenCV 2.4.x里有两套常用的立体匹配算法StereoBM和SGBM。StereoBM基于块匹配速度快但得到的视差图比较粗糙边缘毛刺多。SGBMSemi-Global Block Matching通过路径代价聚合在保持速度的同时大幅提升了匹配精度边缘更清晰弱纹理区域的误匹配也少。我的程序里默认用SGBM因为测试程序对实时性要求没那么高优先保证结果质量。SGBM的参数比较多我列出几个影响最大的参数作用我的推荐值minDisparity最小视差值0numDisparities视差搜索范围必须是16的倍数64或128blockSize匹配块大小奇数5或7P1视差变化1像素的惩罚8blockSizeblockSizeP2视差变化超过1像素的惩罚32blockSizeblockSizeuniquenessRatio最小代价与次小代价的比值阈值10speckleWindowSize去除斑点的窗口大小100speckleRange斑点内允许的视差变化32numDisparities是最关键的参数。它决定了算法搜索匹配点的范围范围太小会漏掉近距离物体范围太大不仅慢还会在无纹理区域产生大量误匹配。我的习惯是先设一个较大的值比如128看视差图如果物体边缘都正常再逐步减小。blockSize的选择比较纠结。块越大匹配越鲁棒但视差图的细节丢失越多块越小细节保留好但噪声也大。对于640×480的图像5或7是比较折中的选择。P2的值直接控制视差图的光滑程度。P2太小视差图噪点多P2太大物体边缘会被过度平滑出现“咬边”现象。我通常会从32*s*s开始调如果边缘模糊就减小如果噪点多就增大。4.3 视差优化与深度图生成SGBM的输出是CV_16S类型的视差图像素值表示的是视差的16倍所以要除以16.0得到真实视差值。视差图直接显示出来是灰色的为了视觉效果好我会做一个伪彩色映射。视差图得到之后可以进一步生成深度图。深度和视差的关系是Z f * T / d其中Z是物体到相机的距离f是焦距像素单位T是左右相机的基线距离d是视差。这个公式很好理解同一个物体离相机越近它在左右图像上的位置差视差就越大所以深度和视差成反比。OpenCV里可以用cvReprojectImageTo3D把视差图转换为三维坐标cvReprojectImageTo3D(disparityFloat, xyz, Q, true);Q矩阵来自cvStereoRectify输出的xyz是一个三通道图像每个像素存的是对应的(X, Y, Z)坐标。true参数表示输入视差图需要除以16转换成真实视差值。还有一点值得提SGBM在弱纹理区域比如白墙、天空会输出很多无效视差。可以用speckleWindowSize过滤小斑点但效果有限。如果想要更好的结果可以用ximgproc模块里的WLS滤波器对视差图做后处理能显著改善边缘代价是额外增加几十毫秒的计算时间。4.4 立体匹配效果调优的实战顺序调SGBM参数不能乱调我的顺序是先固定相机位置用一个有明显前景和背景的物体做测试然后按下面的顺序调先调numDisparities确保视差图没有大面积的黑色空洞。再调blockSize让边缘清晰度达到要求。最后调P1和P2平衡噪点和边缘平滑度。如果视差图上有大量小斑点调speckleWindowSize和speckleRange。这套顺序的核心逻辑是先解决“能不能匹配上”的问题再解决“匹配得准不准”的问题最后解决“看起来干不干净”的问题。顺序乱了经常会调了几天参数也找不到问题在哪。还有一次我印象很深SGBM参数怎么调效果都不好后来发现是相机没固定好轻微晃动导致左右图像没有严格对齐。所以如果参数调到差不多合适仍然出不来效果先检查硬件固定是否牢靠再回头怀疑算法。5. 测试程序完整操作流程5.1 从采集到双目标定的操作顺序这套程序设计成测试工具所以操作流程也尽量贴近实际使用习惯。完整流程是这样的打开程序点击“打开相机”按钮左右两个Picture Control分别显示两个相机的实时画面。把标定板放在两个相机都能完整看到的位置点击“抓拍标定板”程序自动保存当前帧到内存并在界面上提示“已保存第X对图像”。变换标定板姿态重复步骤2直到采集满15~20对图像。点击“单目标定”程序对左右相机分别执行角点提取和cvCalibrateCamera2把内参、畸变系数和RMS误差显示在输出窗口。点击“双目标定”程序执行cvStereoCalibrate输出R和T矩阵保存到文件。点击“立体校正”程序对当前左右帧执行cvRemap并在界面上并排显示校正结果。点击“立体匹配”程序执行SGBM匹配并显示视差图。勾选“视差图伪彩色显示”把视差图切换为伪彩色的可视化模式。每个按钮之间是强依赖关系如果跳过单目标定直接点双目标定程序会弹出提示框防止误操作。这个设计思路来自我自己的教训一开始没做流程控制结果有人直接点了“立体匹配”程序崩溃了排查了半天才发现是标定数据没初始化。5.2 立体匹配与深度结果验证立体匹配做完之后除了用肉眼看视差图我还会做一个简单的定量验证在左图上点击一个点程序通过视差图找到右图的对应点并画出来同时显示该点的深度值。这个验证功能对理解立体匹配的原理非常有帮助。比如你点左图上某个物体的边缘右图的对应点如果也在边缘上说明匹配正确如果对应点跳到背景上去了说明这个区域的视差估计有误需要回去调参数。深度值的输出可以顺便验证Z f * T / d的准确性。拿一个已知距离的物体比如距离相机50厘米的书本看程序算出的深度是不是大致在50厘米附近。误差在5%以内基本正常如果差太多优先怀疑标定时的基线距离T是否准确。T不是标定直接输出的而是从R和T矩阵中提取出来的平移向量的模长单位是标定板格子实际物理尺寸毫米所以标定板的格子尺寸一定要量准一个毫米的误差都会直接变成深度误差。6. 调试记录与常见问题排查6.1 环境配置类问题环境配置阶段遇到最多的问题就是程序一运行就提示找不到OpenCV DLL。这个问题的根源是系统找不到opencv_core249d.dll等动态库。解决方法是把D:\opencv\build\x86\vc12\bin添加到系统环境变量的Path中添加后必须重启VS否则环境变量不生效。另一个常见报错是“0xc000007b应用程序无法正常启动”。这个错误码几乎全是DLL位数不匹配导致的最常见的就是64位系统里运行了需要32位库的程序但配置的是x64的OpenCV库。解决方法是统一工程平台为x86或x64匹配对应的OpenCV库目录。我在程序里默认用了x86因为当时选的USB摄像头SDK只有32位驱动。还有“此项目需要MFC库”的报错。这个是因为VS安装时没有勾选“适用于桌面的VC工具”中的MFC组件。打开VS安装程序勾选MFC组件修改安装等安装完成再打开工程就正常了。6.2 标定精度类问题标定阶段最常见的挫败感来自cvFindChessboardCorners总是找不到角点。我调试中发现几个原因一是棋盘格贴在玻璃等反光材料上高光区域干扰了角点检测二是光照不均匀棋盘一侧亮一侧暗三是图像分辨率过低角点模糊。解决办法是换哑光纸打印棋盘格增加环境光或者用CV_CALIB_CB_ADAPTIVE_THRESH让算法自行适应光照变化。还有一个标定精度的问题有时候RMS很小但立体校正后图像没有行对齐。这种情况多半是标定板图像里包含了大角度倾斜的图片导致单目标定和双目标定之间的外参不一致。解决方案是采集图像时倾斜角控制在45度以内太夸张的角度对提高精度没帮助反而会让标定结果不稳定。6.3 立体匹配效果类问题匹配阶段的问题主要集中在视差图上。第一种情况是视差图出现大面积黑色区域。这通常是numDisparities太小近处物体的视差超出了搜索范围。把numDisparities调大即可。第二种情况是视差图噪点特别多像雪花一样。原因一般是blockSize太小或者uniquenessRatio太低。先调blockSize到7试试不行再把uniquenessRatio升到15。第三种情况是边缘处“咬边”前景物体的轮廓像被啃过一样。这是P2过大的典型症状适当调小P2就能缓解。第四种情况是整幅视差图都是同一个值完全看不到层次。这往往是立体校正没做成功左右图像没有行对齐SGBM在错误的搜索范围内找不到匹配点。到此环节建议先检查cvStereoRectify的输入R和T是否合理再用棋盘格辅助线检查校正结果。还有一个老生常谈但必须提醒的点两个USB摄像头没有硬件同步握持或者轻微移动都会导致左右帧不是同一时刻的画面立体匹配时就会出现错位。所以使用这个程序时相机必须固定在支架上并且尽量用同一个型号的摄像头减少帧率不一致带来的影响。我自己在这个项目里踩过最大的坑是摄像头热插拔。程序运行中拔掉USB摄像头再插回去再点“打开相机”就直接崩溃。后来在打开相机的代码里增加了设备枚举和重试逻辑并且在程序退出时主动释放摄像头资源才算把这个问题压下去。如果你在调试中发现相机打不开先重新插拔一次USB再重新打开程序这是最快恢复现场的方法。最后再分享一个小技巧标定参数和匹配参数全部支持通过配置文件读写默认在程序目录下生成calib.xml和stereo.xml。调好一组参数后保存下来下次启动直接加载不用重新标定。这个功能虽然不起眼但在反复测试的时候能省下大量重复劳动。