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

双目立体视觉三维重建:标定到点云体积测量全流程实践

双目立体视觉三维重建:标定到点云体积测量全流程实践 ★ FEATURED ARTICLE
简介面向三维重建与计算机视觉领域的开发者与研究者这份优质项目资源完整覆盖摄像头标定、双目立体校正、点云生成及体积计算的技术流程。压缩包体量仅12KB共5个文件包含3个C源文件、1个头文件和1个说明文档代码划分清晰分别对应单目标定、双目标定与立体匹配等模块README则梳理了工程架构与算法流程便于快速定位关键环节。已有137人学习适合需要掌握三维重建落地实现的中高级工程师、科研人员及高年级学生。项目以具体案例演示了从左右相机内外参解算、立体校正消除畸变到生成稠密点云、最终量化目标体积的全过程代码可直接编译运行也可作为二次开发基础或教学参考应用于机器人导航、工业测量、虚拟现实等场景。1. 三维重建项目解析一份能跑通双目标定到点云体积计算的源码包做三维重建的都知道摄像头标定这步看着不起眼实际占了整个项目一半的坑。很多开发者拿过双目摄像头就直接算视差出来的点云要么拧成麻花要么四处漏风折腾几周最后才发现问题出在标定上。这份源码包好就好在把流程拆得清清楚楚先用棋盘格做单目标定算出内参和畸变系数再用双目立体校正让左右图像严格对齐接着从视差图生成点云数据最后在点云上完成物体体积测量。整个链路用纯 C 和 OpenCV 实现代码量不大适合做机器视觉测量、工业零件尺寸估算、或者还在纠结怎么把深度数据变成可量化结果的开发者。如果你也准备搭一套双目三维重建的测量管线这份资源值得从头到尾过一遍。2. 摄像头标定模块拆解single_calib 如何算出内参、外参与畸变系数2.1 源码包结构与文件职责先把这份源码包的文件结构摆出来。拆开之后内容不多一共五个文件但每个文件的职责边界非常清楚这是一个典型的三段式三维重建工程标定 → 校正匹配 → 点云计算。三维重建_摄像头标定双目摄像头立体校正计算点云三维重建体积/ ├── README.md # 环境配置、编译步骤、运行顺序说明 ├── single_calib.h # 单目相机标定模块的头文件 ├── single_calib.cpp # 单目相机标定实现角点检测、内参/畸变求解 ├── stereo_calib_match.cpp # 双目标定 立体校正 视差匹配 └── test.cpp # 主流程读取校正参数计算点云并测量体积源码包里没有附带工程文件我一般会自己补一个 CMakeLists.txt 把这三个 cpp 分别编成可执行文件。如果你也是用 CMake 管理 C 工程这个步骤几乎是必须的不然后面两个程序没法链接 OpenCV。我补的构建配置长这样cmake_minimum_required(VERSION 3.10) project(stereo_3d_recon) find_package(OpenCV REQUIRED) add_executable(single_calib single_calib.cpp) target_link_libraries(single_calib ${OpenCV_LIBS}) add_executable(stereo_calib_match stereo_calib_match.cpp) target_link_libraries(stereo_calib_match ${OpenCV_LIBS}) add_executable(test test.cpp) target_link_libraries(test ${OpenCV_LIBS})2.2 单目标定核心实现从棋盘格到内参矩阵单目标定的输入是一组不同位姿的棋盘格照片输出是相机内参矩阵和畸变系数。这个模块的核心逻辑在 single_calib.cpp 里先用findChessboardCorners提取棋盘格内角点再用cornerSubPix做亚像素精细化最后把物理坐标和像素坐标喂给calibrateCamera求解。// single_calib.cpp 核心片段棋盘格角点提取与亚像素细化 #include opencv2/opencv.hpp #include vector #include string using namespace cv; using namespace std; // 棋盘格内角点数量比如 9x6 表示横向9个角点、纵向6个角点 const Size BOARD_SIZE(9, 6); // 每个棋盘格方格的边长单位毫米必须用尺子量准 const float SQUARE_SIZE 25.0f; bool collectCorners(const vectorstring imgPaths, vectorvectorPoint2f imagePoints, vectorMat images) { for (const auto path : imgPaths) { Mat img imread(path); if (img.empty()) continue; vectorPoint2f corners; bool found findChessboardCorners(img, BOARD_SIZE, corners, CALIB_CB_ADAPTIVE_THRESH | CALIB_CB_NORMALIZE_IMAGE); if (found) { Mat gray; cvtColor(img, gray, COLOR_BGR2GRAY); // 亚像素细化在角点邻域内迭代搜索更精确的位置 cornerSubPix(gray, corners, Size(11, 11), Size(-1, -1), TermCriteria(TermCriteria::EPS TermCriteria::COUNT, 30, 0.001)); images.push_back(img); imagePoints.push_back(corners); cout 采集到有效棋盘格: path endl; } } // 至少需要一定数量的有效图像否则标定结果不可信 return imagePoints.size() 10; }这段代码有两个细节值得注意。第一findChessboardCorners的 board size 填的是内角点数而不是格子数比如一张 10x7 的棋盘格内角点数是 9x6填错的话角点检测几乎必失败。第二cornerSubPix的窗口大小Size(11, 11)不是玄学窗口太小则细化不足窗口太大则容易跳到邻近角点对于常见的棋盘格照片11x11 是比较稳的经验值。TermCriteria里的30表示最大迭代次数0.001是亚像素精度阈值。拿到角点之后下一步是构建三维物理坐标点集合并调用calibrateCamera。这里有一个常见的理解偏差标定板的坐标系是自定义的通常把棋盘格平面当作 Z0 平面角点间距直接用SQUARE_SIZE乘上行列索引即可不需要知道标定板在世界坐标系中的真实位姿。// single_calib.cpp 核心片段求解内参与畸变系数 void runCalibration(const vectorvectorPoint2f imagePoints, const vectorMat images, Mat cameraMatrix, Mat distCoeffs) { vectorvectorPoint3f objectPoints; vectorPoint3f basePattern; // 以棋盘格左上角为原点Z0 平面按方格边长生成物理坐标 for (int r 0; r BOARD_SIZE.height; r) { for (int c 0; c BOARD_SIZE.width; c) { basePattern.emplace_back(c * SQUARE_SIZE, r * SQUARE_SIZE, 0.0f); } } // 每张标定图像对应同一组物理坐标 for (size_t i 0; i imagePoints.size(); i) { objectPoints.push_back(basePattern); } vectorMat rvecs, tvecs; double rms calibrateCamera(objectPoints, imagePoints, images[0].size(), cameraMatrix, distCoeffs, rvecs, tvecs); cout 重投影误差 RMS rms endl; cout 内参矩阵: cameraMatrix endl; cout 畸变系数: distCoeffs endl; }calibrateCamera的输出里cameraMatrix是 3x3 内参矩阵包含焦距 fx、fy 和主点 cx、cydistCoeffs是畸变向量通常包含 k1、k2、p1、p2鱼眼镜头还会多一个 k3。rvecs和tvecs是每张标定图像对应的外参也就是标定板相对于相机的旋转和平移。重投影误差 RMS 是一个可以量化的质量指标我的经验是 RMS 小于 0.3 像素说明标定结果可用超过 0.5 就需要检查角点检测是否部分失败。2.3 标定结果怎么用于双目系统单目标定本身可以独立使用但在双目三维重建里单目内参只是第一步。左右两个相机还需要知道彼此之间的相对位置关系也就是旋转矩阵 R 和平移向量 T这部分由stereo_calib_match.cpp里的stereoCalibrate完成。它会复用单目标定得到的角点数据把左右相机的内参作为初始值再联合优化出双目的外参最后输出本征矩阵 E 和基础矩阵 F。标定到这一步才算完整因为后续的立体校正和视差计算都依赖这些参数。3. 双目立体校正实现从极线约束到视差图生成3.1 立体校正的数学原理与 OpenCV 接口双目立体校正要解决的核心问题是把两台相机拍摄的图像通过重投影变换到同一个理想平面上让左右图像的同名点落在同一水平扫描线上。这样后续的视差搜索就从二维问题降维成一维问题计算量大幅下降匹配准确率也会提高。OpenCV 提供了一套完整的接口链先用前面标定得到的 R、T 调用stereoRectify计算左右相机的校正旋转矩阵 R1、R2 和投影矩阵 P1、P2以及深度转换矩阵 Q再用initUndistortRectifyMap生成校正查找表最后用remap把原始图像映射到校正后的平面。这套流程封装度很高真正需要动脑筋的是理解 Q 矩阵的含义因为后面点云生成时要直接用它。3.2 stereo_calib_match 核心代码逐段拆解// stereo_calib_match.cpp 核心片段立体校正 SGBM 视差计算 #include opencv2/opencv.hpp #include opencv2/calib3d.hpp using namespace cv; using namespace std; void rectifiyAndMatch(const Mat leftImg, const Mat rightImg, const Mat K1, const Mat D1, const Mat K2, const Mat D2, const Mat R, const Mat T) { Mat R1, R2, P1, P2, Q; // CALIB_ZERO_DISPARITY 让校正后的视差在零附近对称分布 stereoRectify(K1, D1, K2, D2, leftImg.size(), R, T, R1, R2, P1, P2, Q, CALIB_ZERO_DISPARITY); // 生成左右图的校正映射表CV_32FC1 精度足够且省内存 Mat mapLx, mapLy, mapRx, mapRy; initUndistortRectifyMap(K1, D1, R1, P1, leftImg.size(), CV_32FC1, mapLx, mapLy); initUndistortRectifyMap(K2, D2, R2, P2, rightImg.size(), CV_32FC1, mapRx, mapRy); Mat rectifiedL, rectifiedR; remap(leftImg, rectifiedL, mapLx, mapLy, INTER_LINEAR); remap(rightImg, rectifiedR, mapRx, mapRy, INTER_LINEAR); // 到这里左右图像已经完成极线校正可以直接做视差匹配了 // SGBM 参数blockSize 取奇数P1/P2 随 blockSize 增大而增大 PtrStereoSGBM sgbm StereoSGBM::create( 0, // minDisparity最小视差通常为 0 128, // numDisparities视差搜索范围必须是 16 的倍数 11, // blockSize匹配窗口大小 8 * 3 * 11 * 11, // P1平滑惩罚参数一 32 * 3 * 11 * 11, // P2平滑惩罚参数二约为 P1 的 4 倍 1, // disp12MaxDiff左右视差图最大允许差异 63, // preFilterCap预处理裁剪值 10, // uniquenessRatio唯一性比例越小越容易匹配 100, // speckleWindowSize去除噪声斑点的窗口大小 32, // speckleRange斑点连通阈值 StereoSGBM::MODE_SGBM); Mat disparity; sgbm-compute(rectifiedL, rectifiedR, disparity); // SGBM 输出的视差值是定点数需要除以 16 得到真实视差 disparity.convertTo(disparity, CV_32F, 1.0 / 16.0); }这段代码里的 SGBM 参数值得多说两句因为它们直接决定视差图质量。blockSize是匹配窗口的边长窗口越大对纹理稀疏区域越鲁棒但会牺牲边缘精度11 是中等偏保守的选择。P1和P2是平滑惩罚参数它们和经验公式相关一般按8 * 3 * blockSize^2和32 * 3 * blockSize^2设置。如果视差图出现明显的横向条纹优先调大uniquenessRatio或减小blockSize而不是盲目增加搜索范围。numDisparities要根据被测物体的实际深度范围定基线越长、物体越近需要的视差范围越大。3.3 从视差到深度Q 矩阵与三角测量立体校正完成后深度信息的提取就变得非常直接。对于校正后的图像深度 Z 与视差 d 之间的关系是 Z f * B / d其中 f 是焦距像素值B 是双目基线长度。但这个公式只适用于理想模型实际工程里直接用stereoRectify给出的 Q 矩阵一步到位。Q 矩阵是一个 4x4 矩阵它封装了主点偏移和基线信息作用是把视差图上的像素坐标直接映射到相机坐标系下的三维点。// Q 矩阵的典型形式实际数值由 stereoRectify 计算得出 // Q [1 0 0 -cx // 0 1 0 -cy // 0 0 0 f // 0 0 -1/Tx (cx - cx)/Tx] // 其中 cx、cy 是左相机主点Tx 是基线平移向量的 x 分量理解 Q 矩阵的结构有助于排查问题。如果生成的点云在 Z 方向整体偏移多半是标定棋盘格的SQUARE_SIZE填错了单位如果点云左右镜像多半是 T 向量的符号反了。这些都属于标定参数层面的低级错误调试时用一张已知距离的标定板先验证深度值比直接算体积要高效得多。4. 点云生成与体积计算test.cpp 的完整测量链路4.1 从视差图到三维点云test.cpp 是整个工程的收口模块它做的事情可以概括为三步读入左右图像和标定结果生成视差图再把视差图转换成三维点云并计算体积。点云转换的 OpenCV 接口是reprojectImageTo3D它会用 Q 矩阵对视差图的每个像素做矩阵乘法一步到位得到三维坐标。// test.cpp 核心片段视差图转三维点云 Mat points3D; // 第四参数 true 表示把无效点标记为大数值便于后续过滤 reprojectImageTo3D(disparity, points3D, Q, true, CV_32F); vectorPoint3f cloud; cloud.reserve(points3D.rows * points3D.cols); for (int r 0; r points3D.rows; r) { const Vec3f* row points3D.ptrVec3f(r); for (int c 0; c points3D.cols; c) { Vec3f p row[c]; // 剔除无效点Z 为非有限值或小于等于 0 的点都属于测量噪声 if (!isfinite(p[2]) || p[2] 0.0f) continue; cloud.push_back(Point3f(p[0], p[1], p[2])); } }这里有一个性能习惯值得注意用ptrVec3f按行遍历 Mat而不是用atVec3f(r, c)逐点访问。前者直接拿行指针做内存偏移后者每次都要做边界检查在百万级像素的图像上二者差距非常明显。reprojectImageTo3D输出的三维坐标单位与标定时棋盘格SQUARE_SIZE一致如果标定时用的是毫米点云单位就是毫米。4.2 体积计算的两种实现思路有了点云体积计算就有多种算法可选。源码包里给的是最直观的体素积分法思路是把三维空间切分成小立方体网格统计点云覆盖了哪些网格网格总数乘以单个网格体积就是物体体积。这个方法对不规则物体也适用但网格大小直接影响精度网格越大越省内存误差也越大。// test.cpp 核心片段体素积分法计算体积 // 体素边长按毫米设定测量小零件时 1mm 是比较合理的起点 const float VOXEL_SIZE 1.0f; settupleint, int, int voxelSet; for (const auto p : cloud) { int gx static_castint(p.x / VOXEL_SIZE); int gy static_castint(p.y / VOXEL_SIZE); int gz static_castint(p.z / VOXEL_SIZE); voxelSet.emplace(gx, gy, gz); } double volume voxelSet.size() * VOXEL_SIZE * VOXEL_SIZE * VOXEL_SIZE;体素积分有一个明显的短板如果点云密度不均匀或者物体表面有大量空洞统计出来的体积会偏小。这时候可以用第二种思路兜底——凸包逼近。OpenCV 的convexHull可以直接对点云在二维投影面上求凸包再把凸包面积沿深度方向积分。这个方法对近似规则形状的物体误差更稳定但会把内凹的结构也包进去适合做零件毛坯体积估算不适合测量凹槽类特征。实际工程中我一般先跑体素法再用一个已知体积的标准块校准误差比例。4.3 体积测量的精度边界不要指望这套流程算出来的体积能当精密量具用。深度精度受三方面制约标定误差、基线长度、像素分辨率。基线越长近处深度精度越高但视野范围会变小边缘遮挡也更严重。标定误差会成倍放大到深度上这也是为什么反复强调 RMS 要小于 0.3。体素积分的误差来源之一是物体边缘的离散化1mm 的体素在 100mm 尺度的物体上带来的误差大约在 3% 到 5% 之间想进一步缩小就得减小体素边长但内存和耗时会指数级上升。理解这个精度边界很重要否则拿测量结果去和生产部门对线很容易被现实教育。5. 避坑指南标定、立体校正、体积计算里的翻车记录5.1 重投影误差居高不下角点检测一半是错的现象calibrateCamera输出的 RMS 大于 1.0甚至到 2 以上标定结果做出来的校正图像完全没法看。原因最常见的是标定图像数量不足或者位姿覆盖不够。少于 10 张、只在小范围内平移棋盘格、每张图片中棋盘格占比过小都会导致内参求解病态。另一个隐蔽原因是棋盘格图片有反光角点检测在亮斑区域跳变。解决重新采集至少 15 张标定图像保证棋盘格在每个画面中占比超过三分之一并且覆盖画面的四个角和中心区域。采集时让标定板倾斜不同角度不要只做平面平移。每张图采集后立刻画角点检查发现出错当时就丢弃不要等全部拍完再统一验证。5.2 立体校正后垂直视差明显左右图对不齐现象用remap校正后的左右图像在显示器上并排看时棋盘格的横线不在同一水平高度垂直方向有明显错位。原因左右相机不是严格同步抓帧拍摄标定板时板子动了导致左右图像对应点存在时间差。另一个原因是左右相机分辨率或曝光参数不一致图像明暗差异大影响角点检测的一致性。解决标定采集时务必使用硬件同步或静止场景保证左右图严格同帧。如果相机支持触发模式用外部硬触发采集如果不支持把标定板固定在三脚架上慢慢移动。曝光参数也要手动固定不能自动调整。5.3 视差图大范围空洞或条纹物体表面像被刮花现象SGBM 生成的视差图中物体表面出现大量黑色空洞区域或者有明显的横向条纹纹理。原因空洞通常来自低纹理区域比如纯色纸箱、白墙SGBM 在这些区域找不到可靠的匹配点。条纹往往是因为P1、P2惩罚值过大视差在平滑约束下变成台阶状。解决先给左右图做一次高斯滤波或直方图均衡化提升纹理对比度。然后调参把uniquenessRatio从 10 升到 15 到 20 减少误匹配把blockSize从 11 降到 7 保留更多边缘细节P1、P2按新 blockSize 重新计算。如果是透明或反光物体直接换纹理投影仪比调参更现实。5.4 体积计算结果偏大把背景也量进去了现象对同一个物体重复测量体积结果波动大且明显大于物体的真实体积。原因点云里混入背景点。体素积分法用的set只统计点云覆盖的网格没有做前景和背景的区分桌面、支架、远处的墙面都会进入统计。解决在生成点云之后加一步 ROI感兴趣区域裁剪用已知的测量范围把点云限制在物体附近。更稳妥的做法是先做深度阈值分割只保留目标深度区间内的点再用中值滤波去掉离散噪声点。我在做零件测量时习惯先放一个已知体积的标准块标定比例系数再测目标物体。5.5 编译报错OpenCV 版本差异导致接口找不到现象stereo_calib_match.cpp在编译时报StereoSGBM未定义或者reprojectImageTo3D找不到匹配的重载。原因OpenCV 3.x 和 4.x 的接口有差异。3.x 里StereoSGBM在opencv2/calib3d/calib3d.hpp中4.x 把它挪到了opencv2/calib3d.hpp并全面改用create静态工厂。如果项目里混用了不同版本的 OpenCV很容易踩到这种坑。解决先检查CV_VERSION宏确认当前版本再按版本写条件编译。常见的做法是统一使用PtrStereoSGBM sgbm StereoSGBM::create(...)这种新接口它在 3.4 以上版本都兼容不要直接用new StereoSGBM这种老写法。链接时确认find_package(OpenCV REQUIRED)找到的是你预期的那一套库。6. 二次开发把一次性标定流程改造成配置驱动的测量管线这套源码包的代码是按脚本思路写的标定参数硬编码在 const 变量里换一台相机就要重新改代码编译。我拿到之后做的第一件事就是把所有标定参数外置到配置文件里让标定流程变成配置驱动的管线。// 改造后的配置读取用 OpenCV FileStorage 读写 YAML 配置 FileStorage fs(../config/stereo_params.yaml, FileStorage::READ); if (!fs.isOpened()) { cerr 无法打开配置文件 endl; return -1; } int boardW (int)fs[board_width]; int boardH (int)fs[board_height]; float squareSize (float)fs[square_size]; int numDisparities (int)fs[num_disparities]; int blockSize (int)fs[block_size]; Mat K1, D1, K2, D2, R, T; fs[camera_matrix_left] K1; fs[dist_coeffs_left] D1; fs[camera_matrix_right] K2; fs[dist_coeffs_right] D2; fs[rotation_matrix] R; fs[translation_vector] T; fs.release();配置文件驱动的收益很快就能体现出来。换相机焦距、换标定板规格、调视差搜索范围都只需要改 YAML不需要动 C 代码。我还会把单目标定、双目标定、立体校正、点云生成、体积计算拆成五个独立的类或函数每个环节只依赖上一个环节的输出文件这样任何一步出问题都能单独重跑不用把整个链路从头走一遍。验证方法也很直接重投影 RMS 必须小于 0.3然后用一个边长精确已知的标准块测体积误差控制在 5% 以内才算标定过关。这套改造的思路对这个项目特别适用因为三维重建的整个链路是强顺序依赖的标定的输出是校正的输入校正的输出是点云的输入。分段验证、配置驱动能省下大量联调时间。从那以后我每次换相机或者换测量场景都强制自己走一遍标准块校验流程不跳过任何一环。这个习惯帮我避掉了至少三次标定参数出了问题、算出来的体积还自我感觉良好的尴尬场面希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站