简介:本资源是一套基于OpenCV与C++实现的双目立体视觉深度测量方案,面向具备一定C++与计算机视觉基础、希望深入理解双目测距原理的开发者与学习者。方案围绕双目相机标定、极线校正、SGBM半全局立体匹配与三角测量深度计算四大核心模块展开,并采用多线程架构实现图像采集与处理并行运行,配套实时测距显示与三维点云可视化界面,适用于工业检测、机器人导航、自动驾驶等需要精确距离测量的场景。资源包共70个文件,以jpg双目图像样本、png校正结果图、cpp源码、vcxproj工程文件及xml标定参数为主,另含sln解决方案与md说明文档,整体约2.73MB,目录结构清晰,可直接用Visual Studio打开编译调试。目前已有74人学习下载,读者可借此完整走通从标定到视差图生成再到深度换算的全流程,理解参数配置、精度校准与误差补偿的实现思路,是学习双目视觉测距的实用参考。
1. 双目深度测量:从两张图到毫米级点云,C++ 与 OpenCV 到底怎么落地
产线上一个纸箱摆在传送带中间,单目相机只能告诉你「那里有个矩形」,却给不出「它离镜头 812 毫米」这种能直接驱动机械臂抓取的数值。双目立体视觉解决的正是这件事:用左右两个已标定的相机同时拍一张,靠视差反推每个像素的深度,最终输出一张深度图或一坨带 XYZ 的点云。整套链路在 OpenCV 里都有对应函数,用 C++ 写出来能跑在工控机上,延迟稳定、内存可控,这是 Python 原型换成 C++ 部署的核心动机。它适合做机器人抓取、体积测量、避障、三维重建的工程师,也适合已经会 OpenCV 图像处理、想往三维方向走的人。下面按「原理立住 → 标定 → 校正 → 匹配 → 重建 → 避坑」的顺序,把每一步的参数和翻车点讲透。
2. 先搞懂视差与深度公式:为什么基线一改,精度就变
2.1 从相似三角形推出 Z = f·B / d
双目测深度的全部数学就一个公式:Z = f · B / d。f 是焦距(像素单位),B 是两相机光心之间的基线距离,d 是同一个物点在左右图上的横坐标之差,也就是视差。推导来自相似三角形:物点在左相机成像位置和右相机成像位置的水平偏移,与物点距离成反比。距离越远,视差越小;距离越近,视差越大。
这个公式直接决定了两件事。第一,深度精度和视差精度挂钩,对公式求导可得 ΔZ = Z² · Δd / (f · B),也就是说深度误差随距离平方增长。同样 0.1 像素的视差误差,在 1 米处可能只差几毫米,到 5 米处就放大到几厘米。第二,想提高远距离精度,要么加大基线 B,要么提高焦距 f,要么把视差做到亚像素。加大基线会让近处遮挡变严重、匹配更难,所以基线不是越大越好,得按你的工作距离选。
2.2 基线、焦距、工作距离三者的取舍表
选型阶段先把这三个参数定下来,后面标定和匹配才有意义。下面这张表是我在几个项目里总结的经验区间,不是硬标准,但能帮你快速排除明显不合理的组合。
| 工作距离 | 建议基线 B | 建议焦距 f(像素) | 视差量级 | 典型场景 |
|---|---|---|---|---|
| 0.3–1 m | 60–120 mm | 500–1000 | 50–200 px | 机械臂抓取、零件测量 |
| 1–3 m | 120–300 mm | 800–1500 | 20–100 px | AGV 避障、箱体体积 |
| 3–10 m | 300–600 mm | 1000–2000 | 5–40 px | 室外重建、大场景 |
焦距 f 这里指的是像素焦距,等于物理焦距除以像元尺寸。比如 6 mm 镜头配 3.45 μm 像元,f ≈ 60000/3.45 ≈ 1739 像素。基线和工作距离大致满足 B ≈ Z/10 到 Z/20 时,视差落在可匹配的舒适区。基线太小视差趋近于零,噪声淹没信号;基线太大近处出现大面积遮挡,左右图找不到对应点。
2.3 极线校正为什么是匹配的前提
原始左右图里,同一个物点的对应点不在同一行上,你要在二维平面里搜匹配,计算量爆炸还容易错。极线校正(rectification)把两个相机虚拟旋转到光轴平行、成像平面共面,校正后同一个物点只在同一行出现,匹配从二维搜索降成一维搜索。OpenCV 里用stereoRectify算出校正变换,再用initUndistortRectifyMap生成映射表,最后remap应用到图上。这一步没做对,后面StereoBM或StereoSGBM出来的视差图会整片发黑或错乱,是新手最常见的翻车点之一。
3. 相机标定:把内参、外参和畸变一次算准
3.1 标定板采集:张正友法需要多少张、什么角度
标定用棋盘格,OpenCV 的findChessboardCorners加calibrateCamera就是张正友标定法的实现。采集要点:至少 10 到 15 对有效图像,棋盘格要覆盖画面四个角和中心,倾斜角度在 ±30° 内变化,不要全是正对镜头的图。左右相机同时拍,保证同一时刻棋盘格在两幅图里都能被检测到。
下面是我常用的单目标定代码骨架,左右相机各跑一遍,拿到各自的内参和畸变系数。
#include <opencv2/opencv.hpp> #include <vector> int main() { cv::Size boardSize(9, 6); // 内角点数量,不是方格数 float squareSize = 25.0f; // 方格边长,单位 mm std::vector<std::vector<cv::Point3f>> objPoints; std::vector<std::vector<cv::Point2f>> imgPoints; for (int i = 0; i < imageList.size(); ++i) { cv::Mat img = cv::imread(imageList[i], cv::IMREAD_GRAYSCALE); std::vector<cv::Point2f> corners; // 亚像素级角点检测,提升标定精度 bool found = cv::findChessboardCorners(img, boardSize, corners, cv::CALIB_CB_ADAPTIVE_THRESH | cv::CALIB_CB_NORMALIZE_IMAGE); if (!found) continue; cv::cornerSubPix(img, corners, cv::Size(11, 11), cv::Size(-1, -1), cv::TermCriteria(cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER, 30, 0.001)); std::vector<cv::Point3f> obj; for (int r = 0; r < boardSize.height; ++r) for (int c = 0; c < boardSize.width; ++c) obj.push_back(cv::Point3f(c * squareSize, r * squareSize, 0)); objPoints.push_back(obj); imgPoints.push_back(corners); } cv::Mat K, D; std::vector<cv::Mat> rvecs, tvecs; // 标定,返回重投影误差 double err = cv::calibrateCamera(objPoints, imgPoints, img.size(), K, D, rvecs, tvecs); std::cout << "reprojection error: " << err << std::endl; return 0; }逻辑说明:findChessboardCorners先粗定位角点,cornerSubPix把角点精度提到亚像素,这一步不做的话重投影误差会明显偏大。calibrateCamera输出内参矩阵 K 和畸变系数 D,返回值是平均重投影误差。参数说明:boardSize填的是内角点数,9×6 的棋盘格内角点是 8×5,填错会一直检测失败;squareSize必须和实际打印尺寸一致,单位随意但要和后续三维坐标单位统一;cornerSubPix的窗口 11×11 是常用值,太小精度不够,太大容易受噪声影响。
3.2 双目标定:stereoCalibrate 的输入输出与重投影误差判据
单目标定完,用stereoCalibrate算两个相机之间的旋转 R 和平移 T。T 的模长就是基线 B。这一步的输入是左右相机的内参、畸变、以及每对图像里棋盘格的角点。
cv::Mat R, T, E, F; // flags 固定内参,只优化外参,避免过拟合 double stereoErr = cv::stereoCalibrate(objPoints, imgPointsL, imgPointsR, KL, DL, KR, DR, imgSize, R, T, E, F, cv::CALIB_FIX_INTRINSIC, cv::TermCriteria(cv::TermCriteria::MAX_ITER + cv::TermCriteria::EPS, 100, 1e-5)); std::cout << "stereo reprojection error: " << stereoErr << std::endl; std::cout << "baseline: " << cv::norm(T) << std::endl;逻辑说明:CALIB_FIX_INTRINSIC表示单目标定得到的内参不再优化,只求 R 和 T,这样更稳。E 是本征矩阵,F 是基础矩阵,一般用不到但接口要求输出。判据:双目重投影误差控制在 0.3 像素以内算好,0.5 以内能用,超过 1 像素说明采集有问题,回去检查棋盘格是否平整、是否有运动模糊、左右图是否严格同步。基线cv::norm(T)应该和你设计的机械尺寸吻合,差太多说明标定发散。
3.3 校正映射:stereoRectify 与 initUndistortRectifyMap 的配合
标定完拿到 R、T,就可以算校正参数了。stereoRectify输出左右相机的校正旋转矩阵和新的投影矩阵,再用initUndistortRectifyMap生成 x、y 方向的映射表。
cv::Mat R1, R2, P1, P2, Q; cv::stereoRectify(KL, DL, KR, DR, imgSize, R, T, R1, R2, P1, P2, Q, cv::CALIB_ZERO_DISPARITY, 0, imgSize); cv::Mat mapLx, mapLy, mapRx, mapRy; cv::initUndistortRectifyMap(KL, DL, R1, P1, imgSize, CV_32FC1, mapLx, mapLy); cv::initUndistortRectifyMap(KR, DR, R2, P2, imgSize, CV_32FC1, mapRx, mapRy); cv::Mat leftRect, rightRect; cv::remap(leftImg, leftRect, mapLx, mapLy, cv::INTER_LINEAR); cv::remap(rightImg, rightRect, mapRx, mapRy, cv::INTER_LINEAR);逻辑说明:Q是重投影矩阵,后面reprojectImageTo3D要用它把视差图转成三维点云。CALIB_ZERO_DISPARITY让主点在左右图对齐,减少无效区域。映射表只算一次,之后每帧只做remap,这是实时性的关键。参数说明:initUndistortRectifyMap的输出类型用CV_32FC1,精度够且remap支持;remap的插值用INTER_LINEAR,INTER_NEAREST快但会有锯齿,影响亚像素视差。
提示:校正后一定要肉眼检查左右图同一行是否对齐。可以画几条水平线叠加,如果同一物点在左右图不在同一行,说明标定或校正有问题,别急着往下走。
4. 立体匹配:StereoBM 与 StereoSGBM 怎么选、参数怎么调
4.1 两种匹配器的原理差异与适用场景
StereoBM是块匹配,速度快,适合纹理丰富、光照均匀的场景,比如室内零件。StereoSGBM是半全局匹配,用动态规划在多个方向聚合代价,边缘更完整、弱纹理区域表现更好,但慢几倍。工控机上如果分辨率是 640×480,SGBM 也能跑到实时;1280×720 以上就要掂量算力了。我的习惯是先用 SGBM 出效果,确认链路通了,再评估要不要换 BM 提速。
4.2 numDisparities 与 blockSize 的联动调参
这两个参数是调参的重头。numDisparities必须能被 16 整除,它决定视差搜索范围,直接对应最近可测距离。blockSize是匹配块大小,必须是奇数,越大越平滑但边缘越糊。
int numDisparities = 16 * 6; // 96,覆盖约 0.5m 到 3m int blockSize = 5; // 小物体用 5,大平面可用 7 或 9 auto sgbm = cv::StereoSGBM::create( 0, numDisparities, blockSize, 8 * blockSize * blockSize, // P1 32 * blockSize * blockSize, // P2 1, // disp12MaxDiff 0, // preFilterCap 10, // uniquenessRatio 100, // speckleWindowSize 2, // speckleRange cv::StereoSGBM::MODE_SGBM_3WAY); cv::Mat disp, dispNorm; sgbm->compute(leftRect, rightRect, disp); // 视差图是 16 位定点,除以 16 得到真实像素视差 disp.convertTo(disp, CV_32F, 1.0 / 16.0);逻辑说明:numDisparities设小了,近处物体视差超出范围会变成黑洞;设大了计算量上升且远处噪声增多。经验公式是numDisparities ≈ f · B / Z_min,按你最近工作距离算。blockSize从 5 起步,噪声大就加到 7,但别超过 11,否则小物体边缘会糊成一团。P1、P2 是平滑惩罚项,P2 通常是 P1 的 4 倍,按blockSize²缩放是 OpenCV 官方示例的惯例。uniquenessRatio设 5 到 15,越大越严格,能滤掉误匹配但会让弱纹理区域更空。speckleWindowSize和speckleRange用来去掉小块噪点,视差图花的时候先调这两个。
4.3 视差图后处理:空洞填充与亚像素优化
原始视差图在遮挡区和弱纹理区会有空洞(值为负或 0)。常见做法是先做左右一致性检查,再用中值滤波去噪,最后对空洞做小范围填充。
cv::Mat dispFiltered; // 中值滤波去椒盐噪声,窗口 5 或 7 cv::medianBlur(disp, dispFiltered, 5); // 把无效视差(<=0)置为无效标记,后续可插值 cv::Mat mask = dispFiltered > 0; // 简单空洞填充:对无效点用邻域有效视差均值替代 cv::Mat dispFilled = dispFiltered.clone(); for (int y = 1; y < dispFilled.rows - 1; ++y) { for (int x = 1; x < dispFilled.cols - 1; ++x) { if (dispFilled.at<float>(y, x) <= 0) { float sum = 0; int cnt = 0; for (int dy = -1; dy <= 1; ++dy) for (int dx = -1; dx <= 1; ++dx) { float v = dispFilled.at<float>(y + dy, x + dx); if (v > 0) { sum += v; ++cnt; } } if (cnt > 0) dispFilled.at<float>(y, x) = sum / cnt; } } }逻辑说明:中值滤波去掉孤立噪点,邻域填充只处理小空洞,大面积空洞填了也是错的,不如留空。参数说明:中值窗口 5 是速度和效果的平衡,7 更平滑但会吃掉细节。真正要提升精度,应该在匹配阶段开亚像素模式,SGBM 本身输出是整像素,需要额外做抛物线拟合,或者直接用StereoBM的setPreFilterCap配合后处理。
注意:视差图里黑色区域不一定是错的,可能是遮挡或超出搜索范围。别盲目填充,先确认是哪种,否则会把错误深度喂给下游。
5. 从视差到三维:reprojectImageTo3D 与点云输出
5.1 Q 矩阵的含义与坐标单位
reprojectImageTo3D用校正阶段得到的 Q 矩阵,把视差图逐像素转成三维坐标。Q 里编码了基线、焦距、主点等信息,输出点的单位和你标定时用的squareSize单位一致。如果标定用毫米,输出就是毫米。
cv::Mat points3D; cv::reprojectImageTo3D(dispFilled, points3D, Q, true); // 取图像中心像素的三维坐标 int cx = points3D.cols / 2, cy = points3D.rows / 2; cv::Vec3f p = points3D.at<cv::Vec3f>(cy, cx); std::cout << "center point XYZ: " << p << std::endl;逻辑说明:handleMissingValues设为 true,会把无效视差点标记为很大的值(约 10000),方便过滤。参数说明:Q 必须是stereoRectify输出的那个,不能自己拼。取点时注意points3D类型是CV_32FC3,用Vec3f访问。
5.2 点云过滤与保存为 PLY
原始点云噪声多,要按深度范围和无效值过滤,再保存成 PLY 给 MeshLab 或 CloudCompare 看。
std::vector<cv::Point3f> cloud; for (int y = 0; y < points3D.rows; ++y) { for (int x = 0; x < points3D.cols; ++x) { cv::Vec3f p = points3D.at<cv::Vec3f>(y, x); // 过滤无效值和超出工作距离的点 if (p[2] > 0 && p[2] < 3000.0f && std::abs(p[0]) < 2000 && std::abs(p[1]) < 2000) cloud.push_back(cv::Point3f(p[0], p[1], p[2])); } } std::ofstream ply("cloud.ply"); ply << "ply\nformat ascii 1.0\nelement vertex " << cloud.size() << "\nproperty float x\nproperty float y\nproperty float z\nend_header\n"; for (auto& pt : cloud) ply << pt.x << " " << pt.y << " " << pt.z << "\n"; ply.close();逻辑说明:过滤条件按你的工作距离设,p[2]是 Z,单位毫米。PLY 头必须写对,否则 MeshLab 打不开。参数说明:std::abs(p[0]) < 2000是限制 X 范围,防止边缘畸变产生的飞点。保存成 ASCII 方便调试,正式用可以写二进制省空间。
5.3 精度验证:用已知尺寸物体反推误差
拿一个已知宽度的物体放在工作距离处,测它点云里的宽度,和真值比。比如 100 mm 的方块,测出来 98 到 102 mm 之间算正常。误差来源主要是标定误差、视差亚像素精度、以及物体边缘的匹配模糊。想量化,就多测几个距离,画一条误差随距离变化的曲线,看是否和 ΔZ = Z²·Δd/(f·B) 的趋势吻合。如果远距离误差爆炸得比理论还快,多半是标定发散或基线测量不准。
6. 避坑与排查:双目深度测量最常见的 5 个翻车现场
6.1 视差图整片全黑或全白
现象:compute出来的视差图几乎全是无效值,或者一片均匀。原因:最常见是numDisparities设得太小,近处物体视差超出范围;其次是左右图没做校正,对应点不在同一行,匹配器找不到。解决:先确认remap后的左右图同一行对齐,再把numDisparities按f·B/Z_min算出来设够,从 16 的整数倍往上加。
6.2 深度值整体偏移一个固定量
现象:测出来的距离总是比真值大或小一个固定值,比如总差 20 mm。原因:标定时的squareSize和实际打印尺寸不一致,或者基线 T 的模长和机械实测对不上。解决:拿卡尺量棋盘格实际边长,重新标定;标定后打印cv::norm(T)和机械尺寸对比,差超过 1% 就查采集质量。
6.3 物体边缘深度出现「飞点」
现象:物体轮廓周围有一圈深度突变到很远或很近的点。原因:边缘处左右图遮挡关系不同,匹配器在遮挡区强行匹配出错。解决:开左右一致性检查,或者在后处理里对视差图做边缘腐蚀,把轮廓附近几个像素的深度丢掉。别指望匹配器在遮挡区给出正确值,这是原理限制。
6.4 弱纹理表面深度大面积空洞
现象:白墙、金属平面这类没纹理的区域,视差图大片空洞。原因:块匹配和 SGBM 都依赖纹理,没纹理就没法区分对应点。解决:给被测物打散斑或投影纹理,这是工业双目常用手段;或者换主动双目(结构光),但那就不是纯 OpenCV 方案了。软件层面只能靠增大blockSize和调低uniquenessRatio缓解,效果有限。
6.5 实时性不达标,帧率掉到个位数
现象:1280×720 下 SGBM 一帧要几百毫秒。原因:numDisparities太大、blockSize太大、或者每帧都在重算映射表。解决:映射表只算一次缓存起来;分辨率降到 640×480;numDisparities按实际最近距离设,别盲目设大;实在不够就换StereoBM,或者把匹配放到 GPU 上用cudastereo。工控机上还要注意别在调试模式编译,Release 下 OpenCV 的 SIMD 优化才生效。
7. 把标定结果固化下来:一次标定、长期复用的工程习惯
标定是整条链路里最耗时也最容易反复的一步,我的习惯是把它做成一次性的离线流程,结果存成 YAML,运行时直接读,绝不每次启动重标。OpenCV 的FileStorage就是干这个的。
// 标定完成后保存 cv::FileStorage fs("stereo_calib.yml", cv::FileStorage::WRITE); fs << "KL" << KL << "DL" << DL << "KR" << KR << "DR" << DR; fs << "R" << R << "T" << T << "Q" << Q; fs << "imageSize" << imgSize; fs.release(); // 运行时读取 cv::FileStorage fs2("stereo_calib.yml", cv::FileStorage::READ); cv::Mat KL, DL, KR, DR, R, T, Q; fs2["KL"] >> KL; fs2["DL"] >> DL; fs2["KR"] >> KR; fs2["DR"] >> DR; fs2["R"] >> R; fs2["T"] >> T; fs2["Q"] >> Q;逻辑说明:把 K、D、R、T、Q 全存下来,运行时只做initUndistortRectifyMap和remap,省掉标定开销。参数说明:YAML 里矩阵会自动带类型信息,读回来直接用。注意imageSize也要存,分辨率变了映射表就失效。
一个容易被忽略的点:相机被撞过、镜头拧过、温度变化大,标定就会漂。我的做法是在工装上加一个已知尺寸的基准块,每次开机先拍一张,用深度值反查是否偏移超过阈值,超了就提示重标。这比定期盲目标定靠谱,也是产线设备能长期稳定跑的后悔药。
另外,如果你要在同一台机器上跑多个双目相机,或者相机和机械臂有坐标变换,标定结果要带上相机坐标系到机械臂基座的外参,这部分用solvePnP配合标定板做手眼标定,思路和单目一样,只是输入换成了双目重建出的三维点。别把双目内参和手眼外参混在一个文件里,分开存,出问题好定位。
最后说个我踩过的坑:早期图省事,把numDisparities设成 256 想覆盖所有距离,结果帧率掉到 3 帧,还以为是代码效率问题,查了半天才发现是搜索范围太大。后来按最近工作距离反算,96 就够用,帧率直接回到 30。参数不是越大越好,够用就行,这个习惯帮我省了很多无谓的优化时间。希望帮到你。
本文还有配套的精品资源,点击获取