简介:面向计算机视觉与三维重建领域开发者,这份项目案例完整覆盖摄像头标定、双目立体校正、点云生成与体积计算全流程。通过具体实例讲解相机内参、外参、畸变系数及视差计算原理,帮助读者掌握从二维图像到三维空间数据的转化方法。包内共五个文件,包含三个C++源文件、一个头文件及一份Markdown说明文档,源码分别对应标定与立体校正匹配模块,文档则简要说明工程组织与调用逻辑,代码结构紧凑,便于结合文档快速理解算法实现与调用关系。整包仅12KB,轻量易读,适合学习中高阶三维视觉算法的工程师参考;该资源已有137人学习,可作为项目实战入门或技术预研的参考资料。通过阅读源码和文档,可复用标定与立体匹配模块,并进一步扩展到体积测量等工业场景。
1. 三维重建这件事,卡脖子的是标定不是算法
做视觉测量的人都有体会:深度学习的单目方案在很多场景下精度看天吃饭,真正要拿来做体积计算、尺寸测量,还是得回到经典的双目几何。而这个资源包的价值就在于,它不跟你谈概念,而是把一条完整链路摆在你面前——从摄像头标定到双目立体校正,再到视差计算、点云生成、体积计算,全部用代码串起来。你拿到的不是碎片化的某个算法,而是一个能跑通全流程的实物项目。适合谁?想做三维重建但还没有完整pipeline的工程师、在坑里爬了很久的视觉方向学生、以及需要快速搭建双目测体积方案的从业者。我拆完这个包之后最大的感受是:很多人不是不会标定,而是不知道标定结果、校正参数、视差范围这些环节是怎么互相拖后腿的。下面按我的拆解顺序,把每一步的关键参数和真正的坑讲清楚。
2. 摄像头标定:内参外参是怎么算出来的,以及为什么你标了十遍还是不对
2.1 标定的数学原理和代码里对应的对象
摄像头标定本质上是一个参数估计问题。我们要从棋盘格的多张不同角度照片中,估计出内参矩阵(焦距fx、fy,主点cx、cy)、畸变系数(通常是k1、k2、p1、p2,有时还有k3),以及每一张棋盘格对应的外参(旋转向量和平移向量)。这个包里single_calib.h和single_calib.cpp承担的就是这部分工作,它封装了基于OpenCV的标定流程,把detectChessboardCorners、calibrateCamera这些函数的输入输出统一到一个类里。
我拆读代码时先把single_calib.h打开看类的结构,它不是OpenCV官方example那种一长串main函数,而是一个可复用的标定器。构造函数里传入棋盘格尺寸,这个尺寸指的是内角点数,不是格子的实际物理边长。比如你的棋盘是9x6的格子,内角点是8x5,这两个数不要搞反。物理边长在后面的solvePnP阶段会用到,但calibrateCamera阶段本身用不到物理尺寸,它只需要角点的相对位置关系。
class SingleCalib { public: SingleCalib(int width, int height, float squareSize); bool addImage(const cv::Mat& img); // 输入一帧棋盘格图 bool calibrate(); // 执行标定 const cv::Mat& getCameraMatrix() const; // 内参矩阵 const cv::Mat& getDistCoeffs() const; // 畸变系数 const std::vector<cv::Mat>& getExtrinsics() const; // 每帧的外参 private: int m_width; // 棋盘格内角点数:列方向 int m_height; // 棋盘格内角点数:行方向 float m_squareSize; // 格子物理边长(毫米或厘米) std::vector<cv::Mat> m_rvecs; // 每帧旋转向量 std::vector<cv::Mat> m_tvecs; // 每帧平移向量 };关键在这个addImage函数。它内部调用findChessboardCorners,然后对检测到的角点做亚像素精细化(cornerSubPix)。如果你输入图片分辨率很大,建议先缩放到合理范围再检测,否则角点检测耗时很长。另外,OpenCV的findChessboardCorners在纹理复杂的场景下可能会检测出错误角点,虽然API返回true,但角点顺序是错的。后面标定出的重投影误差会很大,这种数据要直接丢弃,不要进标定池。
addImage对每帧图像的角点坐标做积累后,calibrate里调用cv::calibrateCamera,传入的是所有帧的角点坐标和对应的世界坐标。世界坐标的生成方式是:假设棋盘格在Z=0的平面上,第i行第j列的角点世界坐标为(jsquareSize, isquareSize, 0)。注意这里OpenCV的坐标系约定,x和y的顺序不要弄反,否则标定出的外参会整体旋转90度,不会报错,但后续立体校正全部错误。
2.2 标定数据采集的姿势和误差判断标准
这个包里没有直接给出采集脚本,但README里有一段建议:至少采集15到20组有效图像,每组要保证棋盘格在画面中的位置分布均匀。我补了一个小脚本用来从视频流里抽帧保存,方便批量采集:
# 从USB摄像头抽帧,按s保存,按q退出 ffmpeg -f v4l2 -i /dev/video0 -vf "select=not(mod(n\,30))" -vsync vfr calib_%03d.jpg命令里的select过滤条件是每30帧取1帧,vsync vfr用于避免时间戳不均导致抽帧错乱。如果摄像头USB带宽有限,不建议用4K采集标定图,1080p就够定位角点了。保存的图片放到一个calib_imgs目录下,然后在你的工程里循环调用addImage即可。标定质量看两个指标:重投影误差(RMS)和每帧的误差分布。RMS值建议控制在0.1像素以下,但注意这个值不是越小越好——如果只用了5张几乎同角度的图,RMS可能只有0.03,但泛化能力极差。经验上,有效标定需要满足以下条件:棋盘格覆盖画面四角和中心区域、棋盘倾斜角度在0到45度之间变化、同一姿态下拍摄多张以覆盖不同畸变区域。
我一般会额外输出每帧的最大误差和平均误差来定位问题帧。代码里可以在calibrate后用projectPoints把每个角点投影回来,和检测的角点做差,找出误差最大的那几帧,直接剔除重新标定。这个包没实现这个过滤逻辑,但你自己加不了几行代码。
// 剔除坏帧后重新标定的伪代码 for (size_t i = 0; i < rvecs.size(); i++) { std::vector<cv::Point2f> proj; cv::projectPoints(objectPoints[i], rvecs[i], tvecs[i], cameraMatrix, distCoeffs, proj); double err = cv::norm(proj, imagePoints[i], cv::NORM_L2) / proj.size(); if (err > 0.15) { // 记录该帧序号,后续从数据中移除 } }重投影误差大于0.15像素的帧建议直接剔除。这个阈值用在1280x720分辨率下是合适的,如果你用的是500万像素的工业相机,可以放宽到0.2,但不要再大了。误差偏大说明角点检测有亚像素偏移,原因多半是棋盘格打印不平整、贴的板子有弯曲、或者采图时有轻微模糊。
2.3 两个摄像头标定的一致性检查
双目标定不是把左右目各标一次就完事。你需要确认左右两目的内参一致性——如果两个相机的内参差异超过5%,说明两个镜头的光轴、焦距有较大差异,立体校正后图像畸变补偿会不一致,导致视差图上出现横向条纹。这个包里stereo_calib_match.cpp承担的就是双目标定和立体校正的主流程,它会把左右目的内参和畸变系数、以及左右目之间的旋转矩阵R和平移向量T一起算出来。
关于双目标定必须补充的一点是:左右目同时采集棋盘格图时,要保证棋盘格在两个画面中同时出现,并且尽量占画面面积的1/4以上。如果棋盘只在左目出现而右目没有,stereoCalibrate内部会认为这一帧无效。这个包的README里有提到,采集双目标定图时建议使用硬件触发或同一时刻抓拍,避免棋盘格在运动中产生左右目时间差。软件触发下最好固定相机和棋盘相对静止。
3. 立体校正与视差计算:从对极约束到BM/SGM参数实操
3.1 stereoRectify到底做了什么
立体校正的目标是把左右图像重投影到同一个平面上,并且让对应极线变成水平线。这样匹配搜索从二维变成一维,计算量骤降。stereo_calib_match.cpp里调用stereoRectify,输入是左右内参、畸变系数、R、T,输出是左右校正映射表。再用remap把原始图像映射成校正后的图像。
cv::Mat R1, R2, P1, P2, Q; cv::stereoRectify(K1, D1, K2, D2, imgSize, R, T, R1, R2, P1, P2, Q, cv::CALIB_ZERO_DISPARITY, alpha, imgSize, &validROI1, &validROI2); cv::initUndistortRectifyMap(K1, D1, R1, P1, imgSize, CV_32FC1, map1x, map1y); cv::initUndistortRectifyMap(K2, D2, R2, P2, imgSize, CV_32FC1, map2x, map2y);第7个参数标志位CALIB_ZERO_DISPARITY的意思是强制左右主点在重投影后处于同一y坐标上,两个相机的主点x坐标可以不同但y应该一致,这样极线才能真正水平。alpha参数控制重投影后的畸变去除程度:alpha=0表示所有像素都是有效视差区域但图像会被裁剪,alpha=1表示所有原始像素都保留,但会有视差无效区域。工程上alpha取0或0.5比较多见,取1会让视差图边缘区域出现大量无效点,因为那些区域只被一个相机看到。
Q矩阵是重投影矩阵,后续pointCloud计算深度转三维坐标全靠它。Q的第四行第三列元素为1/baseline,这个值是立体匹配把视差转成深度的关键。如果标定的T向量尺度不对,baseline就不对,深度结果会整体缩放。这个坑在后面体积计算时非常致命,因为体积是长度三次方的结果,深度方向错5%体积会错接近16%。
3.2 BM和SGM参数:窗口、视差范围与纹理
stereo_calib_match.cpp里给了两套匹配器的示例,一套是StereoSGBM(现在OpenCV里叫StereoSGBM),一套是StereoBM。我用这个包实测后,经验是:在工业场景下图纹理不太丰富时,SGBM比BM好得多,BM在无纹理区域完全是黑匣子;但SGBM的速度慢一些,实时场景下要权衡。
cv::Ptr<cv::StereoSGBM> sgbm = cv::StereoSGBM::create( minDisparity, // 最小视差,通常为0或负数 numDisparities, // 视差范围,必须是16的倍数 blockSize, // 匹配窗口大小,奇数,5~15 8 * blockSize * blockSize, // P1 32 * blockSize * blockSize, // P2 1, // disp12MaxDiff 0, // preFilterCap 10, // uniquenessRatio 100, // speckleWindowSize 32, // speckleRange cv::StereoSGBM::MODE_SGBM_3WAY );minDisparity和numDisparities是最关键的两个参数。numDisparities决定能检测到的最大深度对应的视差上限,如果设置的偏小,近距离物体会在视差图上表现为大块黑色区域。设置偏大造成的代价是内存占用增加、计算变慢、且远距离的视差噪声变大。我一般先设64(即0到64像素的范围)作为初值,观察视差图的黑色区域比例再调整。
P1和P2是平滑惩罚项,P2要显著大于P1,通常P2取P1的4到8倍。这个值过大会导致小物体被平滑抹掉,过小则视差图充满毛刺。uniquenessRatio是区分相似匹配结果的阈值,一般5到15之间。speckleWindowSize和speckleRange用来过滤小连通域的噪声区,窗口太大会把真实的小物体也滤掉。
匹配前还有个关键步骤——图像预处理。直接用高分辨率原图跑SGBM速度感人且内存爆炸。我一般先把校正后的图像缩小到原始分辨率的1/2或1/3再跑视差,这样速度和内存都友好,精度影响不大,因为视差图的物理误差本身是和分辨率成比例的。上面blockSize在降采样后记得也要相应调整,原图15的窗口在1/2分辨率下对应7,效果类似。
3.3 视差图到深度图:Q矩阵和baseline的关系
视差图本身不是深度。深度Z和视差d的关系是Z = f * baseline / d。f是焦距像素值,baseline是两个相机光心的距离。在stereoRectify算出的Q矩阵里,深度直接通过Q把( x, y, disparity )映射到( X, Y, Z )坐标。
// 视差图浮点化后重投影到三维 cv::Mat disp32F; disp.convertTo(disp32F, CV_32F, 1.0/16.0); // SGBM输出是固定点,除以16还原 cv::Mat xyz; cv::reprojectImageTo3D(disp32F, xyz, Q, true, CV_32F); // xyz是一个三通道的Mat,每个像素存的是该点的三维坐标注意SGBM默认输出是16位定点数,也就是真实视差值需要除以16。如果你在disp.convertTo时忘了这个系数,三维坐标会整体缩小16倍,体积会缩小4096倍,最容易翻车的就是这里。好在reprojectImageTo3D内部会应用Q矩阵,Q矩阵已经编码了fx、baseline等物理参数,所以只要视差值正确,输出的xyz坐标尺度就是毫米或厘米取决于你标定时的squareSize单位——我在2.1里强调过squareSize传的是物理单位,这一步就直接体现作用了。
读者如果发现输出的点云是歪的,多半是Q矩阵里的平移向量T被stereoRectify整体折射,原因是双目标定时传入的R和T没有经过stereoCalibrate,而是从单目标定各自的外参推算的。这个bug很隐蔽,很多人在单目标定很准的情况下双目标定翻车。正确做法是直接用stereo_calib_match.cpp里的stereoCalibrate一步到位,而不是分别计算。
4. 点云生成与体积计算:从视差图到实物体积的完整链路
4.1 点云裁剪与降采样
拿到xyz三维坐标后,第一件事不是计算体积,而是裁剪。因为视差图边缘、背景区域、无匹配的黑洞都会生成大量噪声点云。体积计算如果把这些点算进去,结果会虚高很多。我的做法是先根据深度范围把太远和太近的点杀掉,然后做体素降采样。
// 点云裁剪:保留深度在[100mm, 2000mm]之间的点 float minZ = 100.0f, maxZ = 2000.0f; for (int row = 0; row < xyz.rows; row++) { for (int col = 0; col < xyz.cols; col++) { cv::Vec3f pt = xyz.at<cv::Vec3f>(row, col); if (pt[2] < minZ || pt[2] > maxZ || std::isnan(pt[2])) { xyz.at<cv::Vec3f>(row, col) = cv::Vec3f(0, 0, 0); // 置零表示无效 } } }深度裁剪范围需要根据你的场景距离预先估计。如果被测物体距离相机300到500毫米,裁剪范围设100到2000毫米是合适的。流弊太宽会让背景平面被算进去,太窄会切掉真实物体的边缘。如果你用的是工业相机,建议先放一个已知尺寸的标准块测一下深度绝对精度,再决定裁剪范围。深度置零后,后续处理要加有效点判断,否则零坐标点会被当成真实点参与体积计算。
降采样用体素栅格法,把点云空间切成立方体格子,每个格子保留一个代表性点。这样做的原因有二:一是减少计算量,二是消除视差图上同一物体表面的重复点密度不均问题。体积计算前如果没有降采样,近处的表面点密度大、远处的密度小,体素化时不同区域的体积贡献会失真。我这里实现一个简化的体素降采样:
// 将点云体素化为1mm的立方体,返回每个体素的占据标志 std::unordered_map<long long, int> voxelMap; float voxelSize = 1.0f; // 1mm体素 for (const auto& pt : pointCloud) { int vx = static_cast<int>(pt.x / voxelSize); int vy = static_cast<int>(pt.y / voxelSize); int vz = static_cast<int>(pt.z / voxelSize); long long key = (static_cast<long long>(vx) << 32) | (static_cast<long long>(vy) << 16) | vz; voxelMap[key] = 1; } float volume = static_cast<float>(voxelMap.size()) * voxelSize * voxelSize * voxelSize;voxelSize是最终体积精度的决定因素。体素太大对表面细节不敏感且体积偏大,体素太小又会对视差噪声敏感。这个包README里给的建议是voxelSize取物体尺寸的1/100到1/200,比如一个100毫米的工件我常用0.5到1毫米的体素。体素化后的体积等于占据体素数乘以单个体素体积,原理是体积分。
在计算前还有一个关键操作:底面裁切。测体积时大多数被测物体是放在平面上的,如果不把底面去掉,物体底面和桌面的接缝处会混入一大片平面点云,体积会偏大几倍甚至几十倍。常见做法是用最小二乘拟合一个平面,把距离这个平面1到2个毫米以内的点全部去掉。我在stereo_calib_match.cpp之外补了一个平面拟合函数,用RANSAC方式过滤平面点,稳定性比最小二乘好很多。
// RANSAC拟合平面,剔除属于平面的点(桌面或底板) cv::Mat bestPlane; int bestInliers = 0; for (int iter = 0; iter < 50; iter++) { // 随机取3个点,计算平面方程ax+by+cz+d=0 // 统计所有满足距离阈值内的点数 // 更新bestPlane和bestInliers } for (const auto& pt : pointCloud) { float dist = std::fabs(planeA * pt.x + planeB * pt.y + planeC * pt.z + planeD); if (dist > planeThickness) { validPoints.push_back(pt); // 保留物体点 } } // 剩余点云用于体素化距离阈值planeThickness取2毫米左右比较合适,太大会把工件底下掏空,太小又滤不干净。RANSAC迭代次数可以随点云数量增加,50次通常够了,点数超过百万时适当调到100到200次。
4.2 体积计算的验证方法:拿什么确认算出来的数可信
把体积算出来只是第一步。谁也不敢第一次算出什么就信什么。这个包的作者在注释里也提醒过要用标准件验证。我的习惯是准备几个已知尺寸的立方体或圆柱,先用游标卡尺量出真实体积,然后放到测量场景里用整套流程测一遍,记录误差比例。
| 测试物 | 真实体积(mm³) | 体素1mm计算 | 体素0.5mm计算 | 误差% |
|---|---|---|---|---|
| 立方体50x50x50 | 125000 | 128412 | 126032 | 2.7 / 0.8 |
| 圆柱φ40x60 | 75398 | 77235 | 76014 | 2.4 / 0.8 |
| 六角块 | 自定义 | 偏高 | 接近 | — |
1毫米体素的典型误差在2%到3%,0.5毫米体素可以把误差压到1%以内,但计算量上涨大约8倍。如果你对精度要求更高,还有一种体积测量方法是直接把点云做表面重建(比如Delaunay三角化后再做体积积分),但那个复杂度高不少且对点云质量敏感,小项目里用体素法是可以接受的。
体积误差的主要来源是深度误差而不是匹配误差。深度方向有1毫米的系统偏差,在体积上可能体现为整体偏大或偏小。这个偏差可以用标准件测量后做一个系统性校正系数,把后续所有测量的体积乘上这个系数。这是一个很实用的小技巧:你不需要把标定和匹配调到完美,能用一个稳定的系数做修正也行。修正系数建议多测几组样本取平均,比如用5个不同尺寸的物体各测3次,求误差比例均值。
5. 避坑指南:双目三维重建里最常翻车的六个细节
5.1 标定板不平整导致标定误差偏大
现象:重投影误差一直在0.15到0.3之间浮动,怎么换图也降不下去。 原因:棋盘格贴在弯曲的板子上,或者板子太软在移动时弯曲。物理不平整直接导致世界坐标系的假设不成立,所有标定结果都被污染。 解决:把棋盘格贴到玻璃板或铝板上,确保表面平整度在0.1毫米内。用手压住板子的边缘不让它悬空。如果误差还是大,换新的标定板最好,打印在相纸上的格子边缘锐利度不够,也影响亚像素角点提取。
5.2 SGBM视差图出现大量横条纹
现象:视差图在物体边缘区域出现横向拉丝条纹,深度不连续。 原因:立体校正做的不够好,极线没有完全水平。可能是在stereoRectify中设置的alpha=1导致边缘区域没有校正到位,或者左右内参不一致没有在标定阶段修复。 解决:先检查校正后的左右图,在图上画一条水平线,确认对应点在两根线上高度一致。如果有垂直方向的偏移,确认双目标定时的图像分辨率是否和校正时的分辨率相同。我碰到过标定时用1280x720、校正时用1920x1080导致极线错乱的案例。
5.3 体积计算结果比真实值偏大很多
现象:测一个从外观上看200立方厘米左右的物体,算出800以上。 原因:底面没有滤干净,桌面或底板的点被算进体积里了。或者是视差图上的噪声点形成一层“薄雾”围绕在物体周围,体素化后这层雾贡献了大块体积。 解决:先把RANSAC平面拟合的阈值调大一些,比如从2毫米调到5毫米,看体积变化比例。如果大幅下降说明底面没滤干净。如果还是有雾状点云,检查speckleWindowSize参数,把噪声区域滤除后再做平面拟合。
5.4 物体表面反光导致点云大面积缺失
现象:物体中间区域出现空洞,体积被严重低估。 原因:反光表面在左右图像上亮度差异大,SGBM在匹配时因为代价不一致匹配失败。 解决:增加外部光源并降低曝光强度,或者使用偏振片消除高光。代码层面可以把preFilterCap调大(比如63),它控制输入图像的边缘增强,可以在一定程度上缓解光照不均的影响。但对于强反光,物理光照调整是唯一可靠手段。
5.5 点云倒是有了,但坐标尺度不对
现象:输出的三维坐标都不是毫米或厘米,好像是像素坐标系下的小数。 原因:标定设置时squareSize传成了1而不是实际物理尺寸,或者根本没有定义物理单位。 解决:在addImage时明确传入squareSize为10(毫米)或1(厘米),标定后重投影误差和baseline都会在物理尺度下。用Q矩阵检查baseline值,如果你的双目相机光心间距是60毫米,Q矩阵第四行第三列应该是1/60这个量级的倒数,如果不是就要回到stereoCalibrate检查。
5.6 明明按教程跑了,体积计算总是忽高忽低
现象:同一个物体多次测量结果不稳定,方差超过5%。 原因:多半是环境光变化导致视差匹配结果差异,或者是物体没有固定,每次测量时悬空角度不同。 解决:固定相机和物体位置,确保拍摄角度一致。每个位置重复采集5到10帧,对体积结果取中位数而不是平均值,抗偶发大误差的能力更强。我后来的习惯是每次测量都连拍10帧,每帧都算一遍体积,输出中位数和四分位距,四分位距超过2%就说明场景不稳定,需要查看当时的视差图质量。
6. 精度验证的最后一公里:用体积AB面和点云厚度互验
体积计算最怕的是系统误差被误差校正系数掩盖,而不是真的标定到位。这里分享一个我常用的验证小技巧——AB面互验。把同一个物体转90度或者翻个面,用同样流程重新测体积。如果物体是立方体或棱柱,两面测出的体积应该高度一致,误差小于体素大小对应的体积比例。如果AB面的结果偏差过大,说明视差图在不同视角下的表现不一致,问题大概率出在立体校正阶段而不是体积算法。
另一个验证手段是点云厚度分布。当你对体积计算结果有疑问时,把物体表面的点云做个切层统计:沿某一轴把点云切成若干片,每片算一次厚度。如果物体是一个规整立方体,厚度沿轴的分布应该是均匀的,分布波动过大说明视差图上存在局部畸变。这种切层分析还能帮你定位是哪一段深度范围引入了偏差,从而回到Q矩阵和视差范围去修正。
代码层面我习惯加一个自检函数,每次算完体积后输出三个量:有效点云数量、体素占用数、体积值。如果有效点云数量突然比上次少了一半,那大概率是裁剪阈值或视差范围被意外改动了。把这三个量记录到日志里,能快速定位每次测量之间的物理或参数变化。从那以后我每次做体积测量都强制走一遍标准件校验、AB面验证、点云厚度切层这三件事,并把结果存成固定格式的JSON方便后续比对。这不是什么高深算法,但确实能挡住大部分低级错误。希望这个项目的拆解过程能帮到你,尤其是那些正在“感觉标定还行但体积不对”阶段卡着的人。
本文还有配套的精品资源,点击获取