简介:基于C++与OpenCV实现的增量式三维重建算法工程资料,覆盖计算机视觉与数字摄影测量课程设计内容,适合作为毕业设计、课程设计或初期项目参考,面向具有一定编程基础、希望深入理解SFM流程的学习者。资料共100个文件,压缩包约68.9MB,主体为2个cpp源码与1个h头文件,另含1个md说明文档、66个txt配置或数据文件以及28张测试图像,目录结构与代码组织清晰,便于根据README快速上手。算法实现重点解决航迹恢复问题,通过暴力匹配与最小生成树建立图像关联,同时提供经过检校的相机内参和背景处理后的模型数据,可基于OpenCV、Ceres与PCL环境复现完整的增量式重建流程。资源目前已有73人学习,主要价值在于可运行的代码骨架、环境依赖说明和真实数据样例;不过资源声明仅作为参考资料,需要读者自行调试排错并扩展功能,不适合直接照搬提交。
1. 增量式三维重建的工程切面:从航迹恢复说起
拿一套带检校内参的相机拍摄的模型照片,直接丢给colmap一键出点云并不难,难的是把重建过程拆成一块块能讲清楚原理、能自己写的代码。这套基于C++/OpenCV的增量式三维重建项目正好覆盖了这条完整链路:特征匹配、航迹恢复、初始像对选择、增量式位姿估计、三角化、BA平差,最后用PCL做点云后处理。项目文件里main.cpp负责重建主流程,bundle_adjustment.h封装了ceres优化,cloudpointprocess.cpp单独处理点云,一上来就是工程结构而不是算法玩具。真正作业时最花时间的是航迹恢复——判断哪些图相邻、以什么顺序加入重建。这里有个反直觉的结论:照片只有十来张时,手工排序比最小生成树更可靠;照片上百张时,MST才是被验证过的方案。本文用这个项目的数据和代码,把从匹配到BA的参数逐条过一遍,cams_1里的检校内参怎么用、哪些阈值值得调,都会说清楚。作为计算机视觉大作业或数字摄影测量课程设计,这套代码的模块拆分可以直接复用。
2. 特征匹配与初始外极几何:OpenCV 4.5.5下的选型和参数
特征匹配是整个重建质量的上限。后面的PNP和BA都是在对应关系上做数值优化,匹配错了,BA再强也拉不回来。项目README里标明的OpenCV版本是4.5.5,这个版本的特征模块接口和3.x相比变化不大,但细分模块之间有明显的性能差异,值得先讲清楚。
2.1 ORB还是SIFT:匹配距离、专利与工程取舍
项目没有把SIFT作为主特征,原因很实际:OpenCV 4.5.5主库里SIFT位于opencv_contrib的xfeatures2d模块,需要自己编译完整版OpenCV,课程设计和毕设环境下不必要的依赖能少则少。ORB是二进制描述子,用Hamming距离做暴力匹配,速度比SIFT浮点描述子的L2距离快一个数量级,对旋转和尺度缩放也有基本不变性。对本项目使用的模型测试数据来说,ORB提取2000个点的匹配数量足够支撑后续的PnP和BA。
| 特征 | 描述子类型 | 匹配距离 | 典型提取速度 | 项目适用性 |
|---|---|---|---|---|
| ORB | 二进制 | Hamming | 快 | 主库自带,课程设计首选 |
| SIFT | 浮点128维 | L2 | 慢,需contrib | 精度高,但编译链复杂 |
| SURF | 浮点64维 | L2 | 中,需contrib | 不推荐,新项目基本弃用 |
如果换用SIFT,描述子维度变高,BFMatcher的耗时和内存都会显著上升,匹配比例测试的0.75阈值依然有效,但计算距离的度量要从NORM_HAMMING改成NORM_L2。对增量式重建来说,特征点数量比特征质量更影响稳定性,ORB的2000个点正好是速度与覆盖率之间的平衡点。
2.2 BFMatcher、比例测试与RANSAC剔除的代码落地
读取两张图进行特征提取和匹配的代码,可以直接复制到main.cpp的初始化部分,我一般封装成一个matchTwoImages函数,返回内外点索引和基础矩阵F。
#include <opencv2/features2d.hpp> #include <opencv2/calib3d.hpp> cv::Ptr<cv::Feature2D> detector = cv::ORB::create( 2000, // nfeatures,单张图最多保留的特征点数 1.2f, // scaleFactor,金字塔每层尺度缩小比例 8, // nlevels,金字塔层数 31, // edgeThreshold,靠近图像边界31像素内的点不提取 0 // firstLevel,从第0层开始 ); std::vector<cv::KeyPoint> kp1, kp2; cv::Mat desc1, desc2; detector->detectAndCompute(img1, cv::noArray(), kp1, desc1); detector->detectAndCompute(img2, cv::noArray(), kp2, desc2); cv::BFMatcher matcher(cv::NORM_HAMMING, false); std::vector<std::vector<cv::DMatch>> knnMatches; matcher.knnMatch(desc1, desc2, knnMatches, 2); std::vector<cv::DMatch> goodMatches; for (const auto& m : knnMatches) { if (m.size() == 2 && m[0].distance < 0.75f * m[1].distance) { goodMatches.push_back(m[0]); } }BFMatcher构造第一个参数NORM_HAMMING表示用汉明距离比较ORB的二进制描述子,第二个参数crossCheck设为false,因为后面通过knnMatch取每个特征点的最近邻和次近邻。比例测试的0.75来自Lowe的SIFT论文:最近邻距离与次近邻距离之比小于0.75说明特征足够独特。这个阈值在ORB上可以放宽到0.8,但放到0.9时误匹配会明显增多。项目数据做过背景处理,物体边缘和纹理清晰,0.75能留下约一半匹配,后续RANSAC收敛很快。
匹配完必须过外极几何约束,否则误匹配会直接进入PnP:
std::vector<cv::Point2f> pts1, pts2; for (const auto& m : goodMatches) { pts1.push_back(kp1[m.queryIdx].pt); pts2.push_back(kp2[m.trainIdx].pt); } cv::Mat mask; cv::Mat F = cv::findFundamentalMat(pts1, pts2, cv::FM_RANSAC, 1.0, 0.99, mask); int inlierCount = cv::countNonZero(mask); double inlierRatio = static_cast<double>(inlierCount) / goodMatches.size();findFundamentalMat的第三个参数FM_RANSAC表示用RANSAC估计基础矩阵,1.0是Sampson距离阈值,单位像素,0.99是期望置信度。mask中非零元素对应的就是内点。如果inlierRatio小于40%,这对图的匹配质量就很差,直接跳过,不进初始对候选池。注意pts1、pts2拷贝成Point2f数组后原始索引关系丢失,我在实际代码中额外维护一个索引对容器,因为后面增量式加帧时还需要queryIdx和trainIdx来回溯三维点与图像点的对应关系。
3. 航迹恢复与初始像对:最小生成树和本质矩阵的四种解
航迹恢复是SFM里最容易被忽略但直接影响全局质量的一环。正文里提到“SFM最重要的问题就是航迹恢复”,这句话很真实:航迹排错了,后面的重建就是在一个错误的帧序列上做局部优化,误差不可逆。
3.1 把航迹恢复建模成图优化问题
把所有图片两两匹配,匹配点数量作为边的权重,目标是找到一个让所有图连通且权重之和最大的边集合,这就是最大生成树问题。把权重取倒数转为代价后,等价于最小生成树问题。代码里排序时我不用原始匹配数,而用RANSAC后的内点数作为权重,因为原始匹配数里可能混入纹理重复区域的误匹配。
struct Edge { int i, j; int matchesInliers; int rawMatches; }; // 按内点数从大到小排序,Kruskal最大生成树 std::sort(edges.begin(), edges.end(), [](const Edge& a, const Edge& b) { return a.matchesInliers > b.matchesInliers; }); std::vector<int> uf(n); std::iota(uf.begin(), uf.end(), 0); // 滑动窗口维护并查集,连通性判断省略 for (const auto& e : edges) { if (findRoot(e.i) != findRoot(e.j)) { unionRoot(e.i, e.j); mst.push_back(e); } }实际工程中,我用“内点占比×内点数”作为排序权重,在质量和数量之间取平衡。照片只有十几张时,肉眼能直接看出拍摄顺序,代码里可以先跑两两匹配,打印匹配矩阵,人工确认后再指定初始链,这比盲目相信MST更稳。真实航测任务几百张图时,暴力匹配O(n^2)对图的开销太大,常见做法是先做词袋检索候选对,再对候选对做特征匹配,最后用MST恢复航迹。项目自带的测试数据经过背景处理,四组图片005、010、029、079之间的匹配数差异明显,MST选出的初始对通常就是纹理最丰富的那一帧。
3.2 本质矩阵分解与四种解的消歧义
确定初始像对后,用cams_1文件夹中检校过的相机内参K1、K2,通过E = K2.t() * F * K1算出本质矩阵,再做SVD分解得到候选旋转和平移。这是外极几何的标准步骤,数字摄影测量课程里对应的是相对定向元素的计算。
cv::Mat E = K2.t() * F * K1; cv::SVD svd(E, cv::SVD::FULL_UV); // 本质矩阵的奇异值理论上为(1,1,0),实际有噪声需要强制归一化 cv::Mat W = (cv::Mat_<double>(3,3) << 0,-1,0, 1,0,0, 0,0,1); cv::Mat R1 = svd.u * W * svd.vt; cv::Mat R2 = svd.u * W.t() * svd.vt; cv::Mat t1 = svd.u.col(2); cv::Mat t2 = -svd.u.col(2);需要注意,噪声环境下svd.w并不严格等于(1,1,0),比较严谨的做法是用E = U * diag(1,1,0) * V.t()对E做投影后再分解。R1、R2与t1、t2组合成四种相机姿态,只有一种是真实解。判定标准是三角化后的三维点在两个相机坐标系下的深度都为正。实际代码中我对四种组合分别做一次三角化,统计正深度点数。
| 组合 | 旋转 | 平移 | 判定原则 |
|---|---|---|---|
| A | R1 | t1 | 正深度点数最多,通常为正确解 |
| B | R1 | t2 | 点在两相机后方或左右矛盾 |
| C | R2 | t1 | 与A共享平移,旋转错配 |
| D | R2 | t2 | 镜像翻转,直接排除 |
注意:正深度点数的统计必须左右相机同时满足z>0,不能只看左相机。只统计单侧深度的做法在相机朝向相近时会把错误解也选进去。
如果最优解的左右正深度占比都小于80%,说明这个初始像对本身不可靠,我一般直接放弃,换下一对匹配内点数最多的图重试。这一步选错,后续所有增量式位姿估计都在错误的坐标框架下累积漂移,而且不可逆。cams_1的内参在求F之前,先用undistortPoints把像素坐标转成去畸变后的归一化坐标,比先求F再单独处理畸变更稳定。
4. 增量式位姿估计与三角化:一张一张把地图长出来
初始对重建出第一批三维点后,系统进入增量式循环。每加入一张新图,分成两个步骤:先用solvePnPRansac求新帧位姿,再对新增可见的匹配点做三角化。这个循环是bundle_adjustment.h前面主循环的核心,代码结构不复杂,但参数和顺序很讲究。
4.1 solvePnPRansac:利用已重建三维点约束新帧位姿
当已有的三维点云中有一部分能在新帧中找到对应2D投影时,新帧位姿就是一个标准的2D-3D配准问题。
std::vector<cv::Point3f> objPts; std::vector<cv::Point2f> imgPts; cv::Mat rvec, tvec; bool ok = cv::solvePnPRansac( objPts, imgPts, K, distCoeffs, rvec, tvec, true, // useExtrinsicGuess,用上一帧的R/t作为初值 1000, // 迭代次数 8.0, // 重投影误差阈值,单位像素 0.99, // 置信度 cv::SOLVEPNP_ITERATIVE);SOLVEPNP_ITERATIVE内部用Levenberg-Marquardt做非线性优化,useExtrinsicGuess为true时把上一帧的姿态作为初值,连续帧之间运动小,收敛快。8.0像素的重投影误差阈值在这个测试数据上偏宽松,原因是模型表面纹理重复度高,少量误匹配进入RANSAC后,足够高的迭代次数能保证内点率稳定。如果实际图集中特征更干净,阈值收到3像素效果更好。这里有一个经常踩的坑:objPts和imgPts的顺序必须一一对应,生成二者时如果处理不当,比如一个对应第一张图的匹配索引而另一个对应第二张图的索引,整个RANSAC结果都是错的。我维护一个带三个字段的结构体,matchIndex、point3DIndex、point2DIndex,从匹配链建立对应关系,不靠两个vector的隐式顺序。
求解出rvec和tvec之后,常见做法是把新帧的可见三维点都做一次重投影,删掉残差过大的观测,再用ceres对位姿做一次单独的精修,最后才进入三角化阶段。不做这步精修而直接三角化,新插入的三维点会带着位姿误差进入BA,增加后续优化的负担。
4.2 triangulatePoints的输入约束与三重过滤
新帧与已有关键帧之间的新增匹配对,通过两个已知位姿恢复三维坐标。
cv::Mat proj1 = K * cv::Mat(rt1); // rt1为3x4的[R|t] cv::Mat proj2 = K * cv::Mat(rt2); cv::Mat points4D; cv::triangulatePoints(proj1, proj2, pts1, pts2, points4D); // 齐次坐标转非齐次 std::vector<cv::Point3f> points3D; for (int i = 0; i < points4D.cols; ++i) { double w = points4D.at<double>(3, i); points3D.push_back(cv::Point3f( points4D.at<double>(0, i) / w, points4D.at<double>(1, i) / w, points4D.at<double>(2, i) / w)); }triangulatePoints的输入是3x4投影矩阵,输出是4xN的齐次坐标,每个点的w分量做归一化后才能得到非齐次XYZ。最容易出错的是投影矩阵的构造顺序,cv::Mat拼接R和t时要确认是[R|t]而不是[t|R],这个错误不会报错,但会把所有三角化点投到错误位置。distCoeffs参与PnP但不参与triangulatePoints,正确做法是先undistortPoints把像素坐标转成去畸变坐标系,再传给三角化接口。
三角化之后必须过滤,我按三个条件依次执行:
- 重投影误差:把新三维点投到两帧上,与原始2D点距离超过2像素的删除。
- 三角化角度:三维点与两相机光心的夹角在2度到60度之间,角度太小深度不确定,角度太大匹配误差放大。
- 深度符号:三维点在两帧相机坐标系下的z值必须为正。
三个条件分开过滤,并且分别打印删除了多少点。如果某一轮角度过滤删除比例突然超过一半,说明新帧与参考帧基线过短,视差不足。这种情况下PnP能算出姿态,但三角化出来的点质量很差。我一般用平均视差作为插入关键帧的条件:新帧与最近关键帧的平均像素视差大于30像素才允许三角化,否则只跟踪位姿,不新增点。这样可以显著减少无效计算,避免点云在同一区域堆叠过于密集。
5. 用ceres做Bundle Adjustment:重投影误差最小化的边界
增量式位姿估计是局部最优化,误差随帧数累积。Bundle Adjustment把所有相机位姿和三维点放在一起重新优化,把累计误差重新分布到所有变量上。项目里bundle_adjustment.h封装的是ceres 2.0.0,与OpenCV的solvePnP不同,ceres没有内置PnP,但它给BA提供了更自由的代价函数定义。
5.1 9维相机参数残差块的写法
BA的相机参数一般有两种表达方式:一种是旋转矩阵加平移向量,另一种是旋转向量加平移向量。ceres里用旋转向量配合AngleAxisRotatePoint更稳妥,因为它避免了欧拉角的万向锁,同时让雅可比计算保持连续。
struct ReprojectionError { ReprojectionError(double fx_, double fy_, double cx_, double cy_, const Eigen::Vector2d& obs) : fx_(fx_), fy_(fy_), cx_(cx_), cy_(cy_), obs_(obs) {} template <typename T> bool operator()(const T* const camera, const T* const point, T* residuals) const { // camera: [rx, ry, rz, tx, ty, tz, fx, fy, cx, cy] T p[3]; ceres::AngleAxisRotatePoint(camera, point, p); p[0] += camera[3]; p[1] += camera[4]; p[2] += camera[5]; T xp = p[0] / p[2]; T yp = p[1] / p[2]; residuals[0] = camera[6] * xp + camera[8] - obs_(0); residuals[1] = camera[7] * yp + camera[9] - obs_(1); return true; } private: double fx_, fy_, cx_, cy_; Eigen::Vector2d obs_; };这个残差块里,camera数组的前三维是旋转向量,中间三维是平移,最后四维是fx、fy、cx、cy。ceres的AutoDiffCostFunction通过operator()模板自动计算雅可比,残差值是归一化坐标投影后与观测值的差值。ceres 2.0.0对自动求导做了不少优化,9维相机参数的雅可比矩阵计算开销比手写解析雅可比低不少,写起来也省事。
这里要强调一个边界问题:如果fx、fy、cx、cy全部放开优化,它们会和相机平移产生尺度耦合。单目序列本身没有绝对尺度,内参如果参与优化,优化器可能把fx调到极小来吸收深度误差,导致重建的点云整体变形。常见做法是固定cx、cy,只优化fx、fy,或者干脆全部固定,只优化位姿和三维点。
5.2 损失函数与内参是否放开优化
cams_1文件夹中提供了经过检校的内参,这说明相机内参是可信的。实际代码里我倾向于把fx、fy也设成常量,只保留位姿和三维点作为优化变量。这样BA的变量个数减少,迭代收敛更快,而且避免了内参与平移的耦合。如果你的图集没有标定文件,内参未知,才需要把fx、fy加入优化,但要把cx、cy固定为图像中心。
损失函数推荐HuberLoss而不是TrivialLoss:
- TrivialLoss等于最小二乘,没有鲁棒性,几个误匹配就能把优化结果拉偏。
- HuberLoss(1.0)在残差小于1像素时按二次惩罚,大于1像素时按线性惩罚,既能平滑中等噪声,又不会过度压制真实的大残差。
- CauchyLoss(0.5)在极端离群值上更平滑,但它会把本属于正常范围的重投影误差也压平,导致优化提前收敛,最终的RMSE反而偏高。
BA的触发时机比损失函数的选择更重要。每加入一到两帧新关键帧,做一次local BA,只优化最近N个关键帧以及它们共同观测到的三维点。全局BA放到所有帧加完后,连续跑三轮,每轮迭代不少于50次。ceres的options里num_threads=4,linear_solver_type选SPARSE_SCHUR,因为BA问题的信息矩阵是稀疏块结构,SCHUR消元利用了这一特性。
6. PCL点云后处理与重建质量的三个验证指标
cloudpointprocess.cpp是独立于重建主流程的处理模块,输入是BA后的稀疏三维点,输出是经过滤波和降采样的PLY点云。这个模块的存在说明项目作者把重建和点云后处理解耦了,替换算法时不需要动主流程。
6.1 统计滤波与体素滤波的调参
BA输出的稀疏点云带有离群点和重复观测点,直接可视化会很乱。统计滤波按每个点与近邻的平均距离剔除离群点,体素滤波再做均匀降采样。
pcl::StatisticalOutlierRemoval<pcl::PointXYZ> sor; sor.setInputCloud(cloud); sor.setMeanK(30); sor.setStddevMulThresh(1.0); pcl::PointCloud<pcl::PointXYZ>::Ptr cloudFiltered(new pcl::PointCloud<pcl::PointXYZ>); sor.filter(*cloudFiltered); pcl::VoxelGrid<pcl::PointXYZ> voxel; voxel.setInputCloud(cloudFiltered); voxel.setLeafSize(0.005f, 0.005f, 0.005f); pcl::PointCloud<pcl::PointXYZ>::Ptr cloudDownsampled(new pcl::PointCloud<pcl::PointXYZ>); voxel.filter(*cloudDownsampled);setMeanK=30表示统计每个点最近30个邻居的平均距离,与全局平均距离比较,超过1.0倍标准差就删除。稀疏点云中每个点的邻居本来就少,MeanK可以降到10到20,否则邻近统计会把合法的边界点也当成离群点。体素滤波的leaf size取决于被重建物体的物理尺度。如果重建的是建筑或场景,0.005m会让点云被抽稀得面目全非,至少要放大到0.05m以上。
6.2 验证重建质量的三个维度
重建质量不能只看可视化效果,要有量化指标。
第一个是重投影误差RMSE。BA完成后对所有观测计算残差均方根,正常应该在0.5到1.5像素之间。超过2像素说明有大量误匹配进入了优化,或相机内参与实际不符。第二个是三维点的track长度分布。每个三维点被多少个相机观测到,被4帧以上观测的点占比高说明三角化稳定;如果大量点只有单帧观测,说明增量式循环里有断链,通常是新帧与参考帧基线太短或匹配被过滤过严。第三个是绝对尺度验证。单目SFM无法恢复真实尺度,本质矩阵分解出的t是归一化后的,用模型上已知长度的线段(比如标定板格子边长或物体上的刻度)对比重建结果,计算一个全局scale系数乘到点云上,点云才有测量的意义。
最后一个实用技巧:从cams_1直接拷入内参后,注意cv::Mat的类型必须是CV_64F,solvePnPRansac和triangulatePoints对矩阵类型敏感,类型不对会报type error。保存点云时用PLY二进制格式,检查重建结果用CloudCompare打开,比pcl_viewer的交互方便很多。
本文还有配套的精品资源,点击获取