ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

单目三维重建实战:从棋盘格标定到三角化生成点云

单目三维重建实战:从棋盘格标定到三角化生成点云 简介这是一套基于 Python 的单目三维重建毕设项目源码与文档答辩评审得分 98 分面向计算机、通信、人工智能、自动化等专业的学生、老师或从业者可作为课程设计、大作业及毕业设计的完整参考。项目覆盖棋盘格标定、相机参数读取、特征匹配和三维重建输出的完整流程源码经过调试测试既适合零基础学习也便于在此基础上修改扩展。压缩包共 12 个文件大小约 25.07MB主要包含 Python 主程序、相机参数与运行说明、项目说明文档以及标定图像、特征匹配图和重建效果图可对照图像与代码逐步理解算法实现。目前已有 180 人学习下载。对于希望快速搭建单目重建实验、理解标定与匹配原理或在高分毕设基础上继续完善功能的读者这份资料具有较高的学习借鉴与复现价值。1. 单目三维重建不是黑匣子一台相机怎么还原三维坐标先说结论这个基于 Python 的单目三维重建项目是我拆过的最适合当毕业设计模板的视觉项目之一。它不依赖 Kinect、双目相机或激光雷达只靠普通手机拍两张不同角度的照片就能还原出一片空间里的三维坐标。项目里包含了棋盘格标定图像、两张实际拍摄图、特征匹配中间结果、重建后的点云图以及一个可以直接运行的3D_image_calibration.py主脚本从标定到三角化整条链路都是闭环的。适合正在做课程设计或毕业设计的计算机、自动化、通信类专业学生——你不需要懂太多数学推导先照着跑通再沿着代码去理解每个矩阵在干什么。我当初第一次跑通时最大的感触是三维重建的难点不在算法本身而在每一步的中间结果有没有被正确验证。2. 标定先行棋盘格、内参矩阵与 CameraParam.txt 解读2.1 内参矩阵为什么是单目重建的地基单目三维重建要回答的核心问题是图像上某个像素点在真实世界里对应哪个三维坐标。像素坐标是二维的相机坐标系是三维的两者之间靠相机内参矩阵搭桥。内参矩阵一般写成 3×3 形式包含四个核心参数fx、fy 是焦距在 x、y 方向上的像素长度cx、cy 是主点光轴与成像平面的交点的像素坐标。K [fx 0 cx] [ 0 fy cy] [ 0 0 1]在这个项目里标定环节输出的就是这组参数最后写进CameraParam.txt。为什么标定必须是第一步因为后续用本质矩阵分解相机位姿、用三角化求三维点全部建立在像素坐标已经被内参矩阵转换成归一化坐标的基础上。如果你跳过标定直接拿一个假想的焦距去算特征点匹配得再准重建出来的点云也是变形的。这也是为什么项目里专门放了Checkerboard_Image和SubstitutionCalibration_Image两组棋盘格图像——它们就是用来求解内参标定靶。棋盘格为什么是首选标定靶因为它的角点特征极其明确OpenCV 的cv2.findChessboardCorners可以根据棋盘格的行列数稳定提取亚像素级角点而且棋盘格平面本身是一个刚体平面多角度拍摄后能覆盖画面不同区域的畸变信息。项目里用黑白棋盘格拍摄了多张不同位姿的图像这比用一张图硬算要可靠得多——单张图求出的畸变系数是过拟合的换一个视角就失效。2.2 用 3D_image_calibration.py 跑通标定并输出 CameraParam.txt主脚本3D_image_calibration.py的第一段就是标定流程。常见的实现方式是用cv2.calibrateCamera完成角点检测到参数求解的全过程下面是一个典型实现骨架import cv2 import numpy as np import glob # 棋盘格内角点行列数项目里的棋盘格是 9x6按实际标定板调整 pattern_size (9, 6) objp np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) obj_points [] # 世界坐标系下的棋盘格角点 img_points [] # 图像坐标系下的棋盘格角点 for fname in glob.glob(Checkerboard_Image/*.jpg): img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, pattern_size, None) if ret: # 亚像素细化提高角点精度 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_refined cv2.cornerSubPix(gray, corners, (5, 5), (-1, -1), criteria) obj_points.append(objp) img_points.append(corners_refined) ret, mtx, dist, rvecs, tvecs cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None) np.savetxt(CameraParam.txt, mtx) # 保存内参矩阵 print(内参矩阵:\n, mtx) print(畸变系数:\n, dist)这段代码的逻辑是先把棋盘格角点在真实世界中的坐标固定下来z0 的平面网格然后让 OpenCV 去每张图里找对应的像素角点最后通过最小化重投影误差拟合出内参和畸变系数。calibrateCamera的返回值里mtx是内参矩阵dist是畸变系数。要特别注意两点一是pattern_size的单位是“内角点数”不是棋盘格格子数很多初学者在这里数错导致检测失败二是参与标定的图片最好在 10 张以上并且要有俯仰、左右倾斜、远近变化这个项目里的SubstitutionCalibration_Image目录就是用来放补充标定图片的。跑完之后如果终端打印出了内参矩阵说明标定链路已经通了。脚本里用np.savetxt把矩阵写到CameraParam.txt后面重建阶段直接读文件就能拿到相机参数不用重新标定。2.3 读透 CameraParam.txtfx、fy、cx、cy 与畸变系数标定完成后打开CameraParam.txt里面就是一组 3×3 的浮点数矩阵。我拆过的项目里不少初学者看到这堆数字就跳过了其实这几个数能告诉你很多信息fx 和 fy 与照片分辨率、镜头焦距直接相关cx 和 cy 理论上应该在图像中心附近。如果你的主点偏移很大比如 cx 偏离画面中心超过 5%说明标定图片质量有问题或者图片被裁剪过。参数含义正常范围参考fxx 方向焦距像素与传感器分辨率和镜头焦距相关通常在 500~3000 之间fyy 方向焦距像素与 fx 接近差异大说明像素不是正方形cx主点 x 坐标应在图像宽度的一半附近cy主点 y 坐标应在图像高度的一半附近k1, k2, p1, p2畸变系数数量级在 1e-3 到 1e-1 之间过大说明镜头畸变严重dist畸变系数是一个 1×5 的向量包含径向畸变 k1、k2 和切向畸变 p1、p2。后续在做特征匹配之前通常要用cv2.undistort或cv2.undistortPoints对图像或特征点坐标做去畸变否则匹配点坐标本身就有几像素到几十像素的误差三角化的结果会被明显放大。一个自己的经验不要相信一次标定的结果。把CameraParam.txt读出来之后用同一组棋盘格图片跑一遍cv2.projectPoints看重投影误差的像素均值稳定在 0.5 像素以内才是可用的内参。3. 特征匹配与三角化从同名点到稀疏点云3.1 SIFT 特征提取与 Lowe 比值筛选选对匹配点拿到内参之后重建的核心就转移到两张图的特征匹配上。项目里MatchesImage.jpg和MatchesPoints.jpg两张图展示的就是从两张拍摄图IMG_20210620_104927.jpg和IMG_20210620_104919.jpg中提取特征点并匹配的结果。这个项目选用的是 SIFT 特征而不是 ORB 或 SURF原因在于 SIFT 对尺度变化和视角旋转的鲁棒性最好而单目重建的两张图恰恰是不同视角拍摄的存在明显的仿射变形。典型的特征提取与匹配代码长这样import cv2 img1 cv2.imread(IMG_20210620_104927.jpg) img2 cv2.imread(IMG_20210620_104919.jpg) sift cv2.SIFT_create() kp1, des1 sift.detectAndCompute(img1, None) kp2, des2 sift.detectAndCompute(img2, None) # FLANN 匹配器比暴力匹配快适合特征点较多的场景 FLANN_INDEX_KDTREE 1 index_params dict(algorithmFLANN_INDEX_KDTREE, trees5) search_params dict(checks50) flann cv2.FlannBasedMatcher(index_params, search_params) matches flann.knnMatch(des1, des2, k2) # Lowes ratio test最近距离与次近距离之比小于阈值的匹配才保留 good_matches [] for m, n in matches: if m.distance 0.75 * n.distance: good_matches.append(m) pts1 np.float32([kp1[m.queryIdx].pt for m in good_matches]) pts2 np.float32([kp2[m.trainIdx].pt for m in good_matches])这段代码里最关键的是0.75这个比值阈值。它衡量的是最近邻和次近邻之间的区分度如果最近邻远小于次近邻说明这个匹配是唯一且可信的如果两个候选距离差不多说明特征点本身缺乏区分度匹配很可能是错的。阈值调哪边都有代价调小到 0.6匹配数量变少但精度高调到 0.8数量多但混入误匹配的概率升高。项目里生成MatchesPoints.jpg用的就是这个流程你可以直接数一数匹配点的连线数量来评估阈值是否合理。3.2 从本质矩阵估计相机位姿R 和 T 的四种解匹配点筛选完之后下一步就是通过两幅视图之间的对极几何约束估计出第二张相机相对于第一张相机的旋转矩阵 R 和平移向量 T。这里用的工具是本质矩阵 E它满足对极约束 x2^T * E * x1 0。OpenCV 里可以用cv2.findEssentialMat一步到位前提是传入的匹配点要先通过内参矩阵归一化。# 先把匹配点转成归一化坐标 K np.loadtxt(CameraParam.txt) pts1_norm cv2.undistortPoints(pts1.reshape(-1, 1, 2), K, None) pts2_norm cv2.undistortPoints(pts2.reshape(-1, 1, 2), K, None) E, mask cv2.findEssentialMat( pts1_norm, pts2_norm, focal1.0, pp(0.0, 0.0), # 归一化坐标系下焦距为 1主点为 0 methodcv2.RANSAC, prob0.999, threshold1.0 ) # 从本质矩阵恢复 R 和 T返回四种可能组合 _, R, T, mask_pose cv2.recoverPose( E, pts1_norm, pts2_norm, maskmask )这里容易被忽略的是findEssentialMat为什么要把焦距设为 1、主点设为 0。因为传入的坐标已经通过内参做了归一化本质矩阵工作在归一化相机坐标系下不应该再叠加一次相机参数。recoverPose返回四种 R、T 组合OpenCV 内部通过“三角化点必须位于两个相机前方”这个条件自动选了正确的一组。但自动选择并不总是可靠——它依赖于输入匹配点的质量如果有较多误匹配选出的方向可能正好相反这是后面避坑章节要展开的内容。3.3 三角化生成三维坐标cv2.triangulatePoints 的输入与输出有了 R 和 T理论上就可以把任意一组匹配点投影到三维空间。三角化的数学含义是已知两个相机位姿和一组同名点求两条视线在空间中的交点。由于图像坐标存在噪声两条视线通常不相交cv2.triangulatePoints会用最小二乘方式求一个近似交点。# 构造两个相机的投影矩阵 P1 和 P2 # 第一个相机设为世界坐标系原点旋转为单位阵 proj1 K np.hstack((np.eye(3), np.zeros((3, 1)))) proj2 K np.hstack((R, T)) # triangulatePoints 输入输出都是齐次坐标 points_4d cv2.triangulatePoints(proj1, proj2, pts1.T, pts2.T) points_3d points_4d[:3] / points_4d[3] # 除以齐次分量 w points_3d points_3d.T # 滤除深度为负和离相机太远的噪点 valid (points_3d[:, 2] 0) (np.abs(points_3d[:, 2]) 100) points_3d points_3d[valid]这段代码的逻辑是proj1和proj2是 3×4 的投影矩阵分别描述世界坐标到两个相机像素坐标的映射。第一相机位姿固定为原点第二相机位姿由 R、T 决定。triangulatePoints返回 4×N 的齐次坐标矩阵必须除以最后一个分量 w 才能还原成三维坐标。为什么要做这一步因为齐次坐标里 x、y、z 同时乘以一个非零常数代表同一个点不除以 w坐标大小没有物理意义。后面的valid过滤非常必要。三角化对噪声极度敏感尤其是两幅图像视差很小的匹配点三角化出来的深度会异常大或异常小形成长条状尾巴。这就是为什么项目里Reconstruction.jpg显示的点云看起来干净而你自己跑出来可能有一堆飞点——大概率是没做深度过滤或者视差太小。4. 重建可视化把三维点云画出来并验证可靠性4.1 将齐次坐标转成三维点云并滤除异常点三角化产出的是一组浮点三维坐标但直接拿去画图之前还要做几件整理工作。首先是剔除明显异常的点深度为负在相机后方、距离过远超过场景尺度、以及重投影误差过大说明这个匹配点本身是错的。项目里的Reconstruction.jpg生成前应该做了类似处理否则画面里会充满噪声。常见的点云整理流程如下# 用当前 R、T 把三角化点重投影回图像计算重投影误差 points_2d_proj1 proj1 points_4d points_2d_proj1 points_2d_proj1[:2] / points_2d_proj1[2] points_2d_proj2 proj2 points_4d points_2d_proj2 points_2d_proj2[:2] / points_2d_proj2[2] # 计算每个点与原始匹配点的像素距离 err1 np.linalg.norm(points_2d_proj1.T - pts1, axis1) err2 np.linalg.norm(points_2d_proj2.T - pts2, axis1) # 重投影误差大于 2 像素的点直接丢弃 mask_ok (err1 2.0) (err2 2.0) points_3d_filtered points_3d[mask_ok]重投影误差是检验三角化质量的硬指标。一个可靠的三维点应该能精确地投影回原始图像的匹配点位置。如果投影回去的像素位置和原来的匹配点差了 5 个像素说明这个点要么匹配错了要么三角化时视差太小。2 像素的阈值并不是绝对的高分辨率图像可以放宽到 3 像素但比这个更松的点云质量就没什么保障了。4.2 用 matplotlib 渲染重建结果与原始图片对照Reconstruction.jpg和重建.PNG展示的应该是最终的三维点云。项目里用 matplotlib 就能完成可视化的需求好处是零额外依赖、代码量小。核心渲染代码如下import matplotlib.pyplot as plt fig plt.figure(figsize(10, 8)) ax fig.add_subplot(111, projection3d) # 取点云坐标x 向右y 向下z 为深度 xs points_3d_filtered[:, 0] ys points_3d_filtered[:, 1] zs points_3d_filtered[:, 2] # 用深度值作为颜色映射能直观看出远近关系 sc ax.scatter(xs, ys, zs, czs, cmapviridis, s2) plt.colorbar(sc, labelDepth) ax.set_xlabel(X) ax.set_ylabel(Y) ax.set_zlabel(Z) plt.savefig(Reconstruction.jpg, dpi150)这段代码里czs是关键把 z 值映射到颜色点云的距离关系一目了然比单一颜色的散点图信息量大得多。s2控制点的大小点云密集时可以调到 1稀疏时可以调到 4。写savefig而不是show是因为脚本在服务器或命令行环境下运行时没有 GUI直接保存图片文件可以随时打开检查。渲染完之后一定要做一件事打开MatchesImage.jpg数一数有效匹配的数量再打开Reconstruction.jpg看点云是否分布在合理的三维空间里。如果点云全部集中在一条线上说明两张图像的视差不足或者 R、T 估计错了如果点云很散说明匹配错误太多要去调整特征匹配阈值。5. 避坑指南单目三维重建最容易翻车的五个环节5.1 标定图像拍得不够内参畸变系数全是噪声现象CameraParam.txt里的 cx、cy 严重偏离图像中心畸变系数大得离谱重建出的点云整体弯曲。原因标定图片太少或者拍摄角度过于单一。我在自己的项目里就干过只用 5 张图标定的事结果内参矩阵每跑一次变一次后来才发现是角度覆盖不够棋盘格始终在画面正中央附近转动画面边缘的畸变信息完全没有被采集到。解决最少用 12~15 张标定图覆盖画面左上、右上、左下、右下四个角区每张图的棋盘格大小占画面三分之一左右。标定完成后用cv2.calibrateCamera返回的重投影总误差来判断像素误差超过 0.8 就重新采集。5.2 OpenCV 版本差异SIFT 的函数签名变了现象在 OpenCV 3.x 上写的cv2.xfeatures2d.SIFT_create()在 OpenCV 4.x 上直接报模块不存在反之 OpenCV 4.4 上cv2.SIFT_create()在旧版本里也不识别。原因SIFT 是专利算法OpenCV 从 4.4 开始把它从xfeatures2d移到了主命名空间并且默认包含在opencv-contrib-python里。还有部分历史版本里 SIFT 需要单独编译 contrib 模块。解决项目里的env.txt如果已经锁定了 Python 环境和 OpenCV 版本优先按它来装。如果没有锁定直接用pip install opencv-contrib-python4.5.5.64这类 4.4 以上版本代码里统一写cv2.SIFT_create()。5.3 本质矩阵分解后方向选错三维点全跑到相机后方现象点云渲染出来所有点聚集在相机后方或者点云与真实物体镜像对称。原因recoverPose返回的 R、T 组合虽然有正深度校验但它是基于已匹配点做的校验。当匹配点中包含较多误匹配时校验结果会出错选中的 T 方向与实际拍摄方向正好相反。解决在三角化之后主动加一道校验统计points_3d[:, 2] 0的比例如果正向深度点的比例低于 60%把 T 取反重新三角化。这是一个非常有效的兜底手段。5.4 特征匹配用 ORB 导致重建精度崩盘现象匹配点数量很多但MatchesPoints.jpg里大量连线是交叉错乱的重建出来的点云完全不像物体轮廓。原因ORB 特征没有尺度不变性而两张拍摄图存在视角和距离差异时特征点描述子在尺度变化下会失效。我看到不少初学者为了图快用 ORB结果在单目重建场景里效果确实很差。单目重建的核心是视角变化下的匹配稳定性SIFT 依然是这个场景下的默认选择。解决坚持用 SIFT 或 AKAZE。如果追求速度可以把 SIFT 的nfeatures参数从默认值调低到 1000 左右匹配耗时能明显下降精度损失在可接受范围内。5.5 两张图拍摄基距太小三角化误差被放大现象点云出来之后呈长条状同一物体上的点深度值差异极大甚至出现“飘在空中的尾巴”。原因两张图的拍摄位置太接近视差过小。三角化在视差小时会退化为一个病态问题深度方向的误差被严重放大。视觉 SLAM 里有个经验法则相机基线长度与场景深度之比最好在 0.1 到 0.3 之间。解决重建前先观察两张图的视差在同一物体边缘取一个点看它在两幅图里的像素横坐标差了多少。如果差值小于图像宽度的 3%换一个拍摄角度重新拍。这个检查比任何代码调试都来得快。6. 进阶技巧用重投影误差校准整个重建流程单目重建的流程走通之后真正决定重建质量的是验证闭环。我的习惯是每次跑完都单独重放一遍验证逻辑把优化前的匹配点、内参矩阵、R、T 全部保留下来然后计算所有点的重投影误差分布看中位数和最大值。如果中位数在 1 像素以内、最大值不超过 3 像素这组重建结果基本是可信的如果中位数已经到 3 像素以上说明内参或匹配有一个环节出问题了这时候不要急着调三角化参数而是先回头检查标定误差和匹配质量。另一个实用的进阶方向是拿到稀疏点云之后做一次局部优化。把cv2.triangulatePoints求出的三维点作为初始值用scipy.optimize.least_squares做集束调整优化变量是三维点和相机位姿目标函数是所有重投影误差的平方和。对本科毕设来说这一步能显著提升点云质量而且代码量不大。不过要注意的是集束调整对初始值敏感如果三角化出来的点本身是错的优化后会收敛到局部极小值所以前面的过滤步骤不能省。最后分享一个踩坑换来的习惯项目里那两张拍摄图IMG_20210620_104927.jpg和IMG_20210620_104919.jpg我一开始直接用原图跑发现重建效果一般。后来把两张图读了进来剪裁到相同的分辨率异常点马上就变少了。从那以后我每次做单目重建都会先做一次图像分辨率统一和去畸变再进特征匹配流程。这个步骤虽然不产生任何“技术含量”但它能让后面所有环节的阈值设定变得稳定。希望帮到你。本文还有配套的精品资源点击获取
返回列表