首页 / 资讯中心 / 文章详情

双目三维重建全流程:摄像头标定、视差计算与体积计算实战

双目三维重建全流程:摄像头标定、视差计算与体积计算实战 ★ FEATURED ARTICLE
简介面向计算机视觉与三维重建领域开发者这份项目案例完整覆盖摄像头标定、双目立体校正、点云生成与体积计算全流程。通过具体实例讲解相机内参、外参、畸变系数及视差计算原理帮助读者掌握从二维图像到三维空间数据的转化方法。包内共五个文件包含三个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::vectorcv::Mat getExtrinsics() const; // 每帧的外参 private: int m_width; // 棋盘格内角点数列方向 int m_height; // 棋盘格内角点数行方向 float m_squareSize; // 格子物理边长毫米或厘米 std::vectorcv::Mat m_rvecs; // 每帧旋转向量 std::vectorcv::Mat m_tvecs; // 每帧平移向量 };关键在这个addImage函数。它内部调用findChessboardCorners然后对检测到的角点做亚像素精细化cornerSubPix。如果你输入图片分辨率很大建议先缩放到合理范围再检测否则角点检测耗时很长。另外OpenCV的findChessboardCorners在纹理复杂的场景下可能会检测出错误角点虽然API返回true但角点顺序是错的。后面标定出的重投影误差会很大这种数据要直接丢弃不要进标定池。addImage对每帧图像的角点坐标做积累后calibrate里调用cv::calibrateCamera传入的是所有帧的角点坐标和对应的世界坐标。世界坐标的生成方式是假设棋盘格在Z0的平面上第i行第j列的角点世界坐标为(jsquareSize, isquareSize, 0)。注意这里OpenCV的坐标系约定x和y的顺序不要弄反否则标定出的外参会整体旋转90度不会报错但后续立体校正全部错误。2.2 标定数据采集的姿势和误差判断标准这个包里没有直接给出采集脚本但README里有一段建议至少采集15到20组有效图像每组要保证棋盘格在画面中的位置分布均匀。我补了一个小脚本用来从视频流里抽帧保存方便批量采集# 从USB摄像头抽帧按s保存按q退出 ffmpeg -f v4l2 -i /dev/video0 -vf selectnot(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::vectorcv::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参数控制重投影后的畸变去除程度alpha0表示所有像素都是有效视差区域但图像会被裁剪alpha1表示所有原始像素都保留但会有视差无效区域。工程上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::Ptrcv::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.atcv::Vec3f(row, col); if (pt[2] minZ || pt[2] maxZ || std::isnan(pt[2])) { xyz.atcv::Vec3f(row, col) cv::Vec3f(0, 0, 0); // 置零表示无效 } } }深度裁剪范围需要根据你的场景距离预先估计。如果被测物体距离相机300到500毫米裁剪范围设100到2000毫米是合适的。流弊太宽会让背景平面被算进去太窄会切掉真实物体的边缘。如果你用的是工业相机建议先放一个已知尺寸的标准块测一下深度绝对精度再决定裁剪范围。深度置零后后续处理要加有效点判断否则零坐标点会被当成真实点参与体积计算。降采样用体素栅格法把点云空间切成立方体格子每个格子保留一个代表性点。这样做的原因有二一是减少计算量二是消除视差图上同一物体表面的重复点密度不均问题。体积计算前如果没有降采样近处的表面点密度大、远处的密度小体素化时不同区域的体积贡献会失真。我这里实现一个简化的体素降采样// 将点云体素化为1mm的立方体返回每个体素的占据标志 std::unordered_maplong long, int voxelMap; float voxelSize 1.0f; // 1mm体素 for (const auto pt : pointCloud) { int vx static_castint(pt.x / voxelSize); int vy static_castint(pt.y / voxelSize); int vz static_castint(pt.z / voxelSize); long long key (static_castlong long(vx) 32) | (static_castlong long(vy) 16) | vz; voxelMap[key] 1; } float volume static_castfloat(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个点计算平面方程axbyczd0 // 统计所有满足距离阈值内的点数 // 更新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计算误差%立方体50x50x501250001284121260322.7 / 0.8圆柱φ40x607539877235760142.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中设置的alpha1导致边缘区域没有校正到位或者左右内参不一致没有在标定阶段修复。 解决先检查校正后的左右图在图上画一条水平线确认对应点在两根线上高度一致。如果有垂直方向的偏移确认双目标定时的图像分辨率是否和校正时的分辨率相同。我碰到过标定时用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方便后续比对。这不是什么高深算法但确实能挡住大部分低级错误。希望这个项目的拆解过程能帮到你尤其是那些正在“感觉标定还行但体积不对”阶段卡着的人。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站