简介:这份资源是面向计算机、通信、人工智能、自动化等专业学生与从业者的单目双目视觉三维重建Python源码,源自个人毕业设计项目,答辩评审分达98分,代码经过调试测试可稳定运行。包内共41个文件,以34张jpg图像样本、3个py核心脚本、2个txt说明文件及1个md文档、1个png示意图为主,压缩包约80.24MB,涵盖单目与双目两套重建流程的完整实现。项目围绕双目立体视觉的标定、校正、匹配与深度计算展开,同时提供单目重建的对照方案,图像样本可用于验证算法效果,脚本结构清晰便于逐模块阅读。已有853人学习下载,适合作为期末课程设计、课程大作业或毕业设计的参考模板,基础较好的读者可在此基础上调整算法与参数,扩展出不同功能,具备较高的学习借鉴与二次开发价值。
1. 从一张照片到三维点云:单目与双目重建到底怎么选
手里只有一台普通 USB 摄像头,或者一对便宜的双目模组,能不能把眼前的场景还原成带尺度的三维点云?这是很多做机器人、测量、AR 的工程师真正会问的问题,也是「基于 python 实现的单目双目视觉三维重建源码」这个方向最核心的诉求。单目方案硬件成本最低,一张图就能跑,但天然缺尺度,深度靠模型猜或靠运动推;双目方案多一个相机,靠视差直接算出真实距离,代价是标定和立体匹配的工程量翻倍。两条路线不是替代关系,而是互补:单目适合快速验证和稠密重建的骨架,双目适合要绝对尺度的测距和避障。这篇笔记按「先立住原理、再动手复现、最后讲坑」的顺序,把两条路线的 Python 实现路径拆开讲清楚,新手能照着跑通最小闭环,熟手能看到参数边界和翻车点。
2. 单目三维重建:从标定到稀疏点云的完整链路
单目重建的本质是「用二维信息反推三维结构」,常见做法分两类:一类是单张图的深度估计加相机内参反投影,另一类是运动恢复结构(SfM),靠多视角匹配点三角化。前者上手快但尺度是相对的,后者精度高但需要足够的视角变化。我一般先用标定把内参锁死,再决定走哪条路,因为内参错了后面全错。
2.1 相机内参标定:棋盘格与参数含义
标定的目的是拿到内参矩阵和畸变系数。内参矩阵里的 fx、fy 是焦距的像素表示,cx、cy 是主点,畸变系数 k1、k2、p1、p2、k3 描述径向和切向畸变。这些参数直接决定反投影的准确性,fx 差 5% 深度就偏 5%。
import cv2 import numpy as np import glob # 棋盘格内角点数量,注意是内角点不是格子数 pattern_size = (9, 6) # 棋盘格每个方格的物理尺寸,单位毫米,这个值决定后续尺度的可信度 square_size = 25.0 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) objp *= square_size objpoints = [] # 三维世界坐标 imgpoints = [] # 二维图像坐标 images = glob.glob('calib_images/*.jpg') for fname in images: 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) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners2) # 标定,返回内参矩阵、畸变系数、旋转和平移向量 ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None) print("内参矩阵:\n", mtx) print("畸变系数:\n", dist)这段代码的逻辑是:用已知物理尺寸的棋盘格建立三维点和二维点的对应关系,再解算相机参数。pattern_size必须和实际棋盘内角点一致,写错一位角点检测直接失败。square_size的单位要和后续重建单位统一,用毫米就全程毫米。cornerSubPix的窗口 (11,11) 是经验值,图像分辨率高时可以放大到 (15,15)。标定完成后建议用cv2.projectPoints把三维点重投影回图像,看平均误差,超过 0.5 像素就要重新采集标定图。
2.2 单目深度反投影:把深度图变成点云
拿到内参后,单目重建最直接的做法是接一个深度估计模型,把每个像素的深度值反投影成三维点。这里不依赖具体模型,只讲反投影这一步,因为无论深度从哪来,公式都一样。
import numpy as np import open3d as o3d def depth_to_pointcloud(depth_map, color_img, mtx, depth_scale=1000.0): """ depth_map: HxW 的深度图,单位毫米 color_img: HxWx3 的彩色图 mtx: 3x3 内参矩阵 depth_scale: 深度缩放因子,把深度值转成米 """ h, w = depth_map.shape fx, fy = mtx[0, 0], mtx[1, 1] cx, cy = mtx[0, 2], mtx[1, 2] # 生成像素坐标网格 u, v = np.meshgrid(np.arange(w), np.arange(h)) z = depth_map / depth_scale # 反投影公式:x = (u - cx) * z / fx x = (u - cx) * z / fx y = (v - cy) * z / fy points = np.stack((x, y, z), axis=-1).reshape(-1, 3) colors = color_img.reshape(-1, 3)[:, ::-1] / 255.0 # BGR 转 RGB 并归一化 # 过滤无效深度 valid = (z.reshape(-1) > 0) & (z.reshape(-1) < 10.0) pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points[valid]) pcd.colors = o3d.utility.Vector3dVector(colors[valid]) return pcd # 假设已有深度图和彩色图 # pcd = depth_to_pointcloud(depth, color, mtx) # o3d.visualization.draw_geometries([pcd])反投影的核心就是那两行除法:x = (u - cx) * z / fx。depth_scale取决于深度图来源,用毫米存就填 1000,用米存就填 1。valid过滤掉深度为 0 和超过 10 米的点,这两个阈值按场景调,室内 5 米就够,室外要放宽。点云出来后用 Open3D 的voxel_down_sample降采样,不然几十万个点可视化会卡。
2.3 运动恢复结构:多视角三角化的最小实现
如果只有普通相机没有深度模型,就走 SfM 路线。核心步骤是特征匹配、本质矩阵求解、三角化。下面是最小可跑版本,用 ORB 特征加recoverPose。
import cv2 import numpy as np def sfm_two_view(img1, img2, mtx): orb = cv2.ORB_create(nfeatures=2000) kp1, des1 = orb.detectAndCompute(img1, None) kp2, des2 = orb.detectAndCompute(img2, None) # 暴力匹配加比率测试 bf = cv2.BFMatcher(cv2.NORM_HAMMING, crossCheck=False) matches = bf.knnMatch(des1, des2, k=2) good = [] for m, n in matches: if m.distance < 0.75 * n.distance: good.append(m) pts1 = np.float32([kp1[m.queryIdx].pt for m in good]) pts2 = np.float32([kp2[m.trainIdx].pt for m in good]) # 求本质矩阵,阈值 1.0 像素 E, mask = cv2.findEssentialMat(pts1, pts2, mtx, method=cv2.RANSAC, prob=0.999, threshold=1.0) _, R, t, mask_pose = cv2.recoverPose(E, pts1, pts2, mtx) # 三角化 P1 = mtx @ np.hstack((np.eye(3), np.zeros((3, 1)))) P2 = mtx @ np.hstack((R, t)) pts4d = cv2.triangulatePoints(P1, P2, pts1.T, pts2.T) pts3d = (pts4d[:3] / pts4d[3]).T return pts3d, R, t # pts3d, R, t = sfm_two_view(img1, img2, mtx)nfeatures设 2000 是速度和精度的折中,纹理少的场景要加到 5000。比率测试的 0.75 是 Lowe 论文的经验值,调小匹配更严但点更少。findEssentialMat的 threshold 是 RANSAC 内点阈值,单位像素,图像噪声大就放宽到 2.0。三角化出来的点要做重投影误差筛选,误差大的点直接丢,不然点云会有一堆飞点。
3. 双目三维重建:标定、校正、匹配三步走
双目比单目多出来的核心是视差。两个相机看同一个点,在左右图上的横坐标差就是视差,深度等于焦距乘基线除以视差。所以双目的精度直接取决于标定质量和匹配质量。整个流程是:双目标定拿内外参,立体校正把左右图对齐到同一极线,立体匹配算视差图,最后反投影。
3.1 双目标定:内外参一起解
双目标定要同时拿到两个相机的内参、畸变,以及它们之间的旋转和平移。平移向量的模就是基线,这个值直接进深度公式,错一点深度就错一片。
import cv2 import numpy as np import glob pattern_size = (9, 6) square_size = 25.0 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) objp *= square_size objpoints = [] imgpoints_l = [] imgpoints_r = [] left_images = sorted(glob.glob('left/*.jpg')) right_images = sorted(glob.glob('right/*.jpg')) for lf, rf in zip(left_images, right_images): img_l = cv2.imread(lf, 0) img_r = cv2.imread(rf, 0) ret_l, corners_l = cv2.findChessboardCorners(img_l, pattern_size, None) ret_r, corners_r = cv2.findChessboardCorners(img_r, pattern_size, None) if ret_l and ret_r: criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners_l = cv2.cornerSubPix(img_l, corners_l, (11, 11), (-1, -1), criteria) corners_r = cv2.cornerSubPix(img_r, corners_r, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints_l.append(corners_l) imgpoints_r.append(corners_r) # 双目标定 ret, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F = cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, None, None, None, None, img_l.shape[::-1], flags=cv2.CALIB_FIX_INTRINSIC) print("基线(毫米):", np.linalg.norm(T)) print("右相机相对左相机的旋转:\n", R)stereoCalibrate的flags很关键。如果两个相机单独标定过且内参可信,用CALIB_FIX_INTRINSIC只优化外参,速度快且稳定。如果没单独标定,就去掉这个 flag 让它一起优化,但需要更多标定图,至少 15 对以上。基线np.linalg.norm(T)是深度公式的分母来源,标定完一定要打印出来核对,和实际卷尺量的值差太多说明标定有问题。
3.2 立体校正与视差图:让匹配只在水平方向找
校正的目的是把左右图重投影到同一平面,让对应点只在同一行上,这样匹配就从二维搜索降到一维,速度和准确率都上来了。
import cv2 import numpy as np # 基于上一步的标定结果做校正 ret_l, mtx_l, dist_l, ret_r, mtx_r, dist_r, R, T, E, F = \ cv2.stereoCalibrate(objpoints, imgpoints_l, imgpoints_r, None, None, None, None, img_l.shape[::-1], flags=cv2.CALIB_FIX_INTRINSIC) R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, img_l.shape[::-1], R, T, alpha=0, flags=cv2.CALIB_ZERO_DISPARITY) # 生成映射表 map1_l, map2_l = cv2.initUndistortRectifyMap( mtx_l, dist_l, R1, P1, img_l.shape[::-1], cv2.CV_16SC2) map1_r, map2_r = cv2.initUndistortRectifyMap( mtx_r, dist_r, R2, P2, img_l.shape[::-1], cv2.CV_16SC2) # 对每一帧做校正 img_l_rect = cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR) img_r_rect = cv2.remap(img_r, map1_r, map2_r, cv2.INTER_LINEAR) # SGBM 立体匹配 window_size = 5 min_disp = 0 num_disp = 16 * 5 # 必须是 16 的倍数 stereo = cv2.StereoSGBM_create( minDisparity=min_disp, numDisparities=num_disp, blockSize=window_size, P1=8 * 3 * window_size ** 2, P2=32 * 3 * window_size ** 2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY) disparity = stereo.compute(img_l_rect, img_r_rect).astype(np.float32) / 16.0alpha=0表示校正后只保留有效区域,alpha=1保留全部但会有黑边。numDisparities决定能测的最近距离,值越大能测越近但计算越慢,必须是 16 的倍数。blockSize是匹配窗口,5 适合纹理丰富场景,纹理弱就加到 7 或 9,但边缘会更糊。uniquenessRatio是唯一性检查,10 到 15 之间比较稳,调低会引入误匹配。disp12MaxDiff是左右一致性检查,设 1 能滤掉大部分错误视差。
3.3 视差转深度:Q 矩阵与点云生成
视差图出来后,用reprojectImageTo3D配合 Q 矩阵直接生成三维点,这是 OpenCV 封装好的反投影。
import cv2 import numpy as np import open3d as o3d # 视差转三维 points_3d = cv2.reprojectImageTo3D(disparity, Q) # 过滤无效视差 mask = disparity > disparity.min() mask = mask & (disparity < num_disp) points = points_3d[mask] colors = cv2.cvtColor(img_l_rect, cv2.COLOR_BGR2RGB)[mask] / 255.0 pcd = o3d.geometry.PointCloud() pcd.points = o3d.utility.Vector3dVector(points) pcd.colors = o3d.utility.Vector3dVector(colors) # 降采样和去噪 pcd = pcd.voxel_down_sample(voxel_size=0.005) pcd, _ = pcd.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0) o3d.visualization.draw_geometries([pcd])Q 矩阵是stereoRectify返回的 4x4 矩阵,它把视差和像素坐标映射到三维。reprojectImageTo3D输出的点单位跟标定时的square_size一致,用毫米就全是毫米。voxel_size按场景尺度调,室内 0.005 米合适,大场景要放大。remove_statistical_outlier的nb_neighbors和std_ratio是去飞点的关键,std_ratio 调小去得更狠但可能削掉真实细节。
4. 避坑与排查:双目重建里最容易翻车的五件事
这一章全是血泪经验,每条都按现象、原因、解决写,遇到问题直接对号入座。
4.1 视差图大片空洞,点云缺一块
现象:视差图里物体边缘和弱纹理区域全是黑色,点云对应位置没有点。原因:SGBM 在纹理不足时匹配失败,或者numDisparities设小了导致近处视差超出范围。解决:先把numDisparities加到 16 的倍数上限,比如 256,看空洞是否减少;再调blockSize到 7 或 9;弱纹理场景可以加一个前置的cv2.bilateralFilter平滑图像,但会损失边缘。
4.2 深度值整体偏大或偏小
现象:重建出来的点云尺度不对,物体比实际大一圈或小一圈。原因:基线 T 的模和实际不符,或者square_size单位不统一。解决:打印np.linalg.norm(T)和卷尺量的基线对比,差超过 5% 就重新标定;检查标定和重建是否用同一套单位,毫米和米混用是新手最常见的翻车点。
4.3 校正后左右图不对齐
现象:校正后的左右图同一物体不在同一行,视差图完全乱掉。原因:标定图数量不够或姿态太单一,导致外参不准。解决:标定图至少 15 对,棋盘格要覆盖图像各个区域,包括四角和中心,倾斜角度要有变化。标定完用cv2.stereoRectify后画水平线检查,对应点不在一条线上就重标。
4.4 点云有大量飞点
现象:点云里到处是离群的噪点,主体结构被淹没。原因:视差图的错误匹配没有过滤干净。解决:开启disp12MaxDiff和uniquenessRatio,再加speckleWindowSize和speckleRange做斑点过滤;后处理用remove_statistical_outlier,std_ratio从 2.0 往下调,但别低于 1.0,否则真实点也被删。
4.5 单目重建尺度漂移
现象:单目 SfM 跑出来点云形状对但尺度每次都不一样。原因:单目本身没有绝对尺度,三角化出来的尺度是任意的。解决:要么在场景里放一个已知尺寸的参照物,用它的重建尺寸反推全局尺度;要么接受相对尺度,只用于形状分析不做测量。要绝对尺度就上双目或加 IMU。
5. 进阶技巧:用 Open3D 做点云配准与网格化
点云出来只是半成品,真正能用往往要配准多帧和转网格。多帧配准用 ICP,网格化用泊松重建,这两个是 Open3D 里最实用的进阶操作。
import open3d as o3d import numpy as np # 假设有两帧点云 pcd1 和 pcd2 # 粗配准用 FPFH 特征 def preprocess(pcd, voxel_size): pcd_down = pcd.voxel_down_sample(voxel_size) pcd_down.estimate_normals( o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size * 2, max_nn=30)) fpfh = o3d.pipelines.registration.compute_fpfh_feature( pcd_down, o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size * 5, max_nn=100)) return pcd_down, fpfh voxel_size = 0.01 pcd1_down, fpfh1 = preprocess(pcd1, voxel_size) pcd2_down, fpfh2 = preprocess(pcd2, voxel_size) # 全局粗配准 result_ransac = o3d.pipelines.registration.registration_ransac_based_on_feature_matching( pcd1_down, pcd2_down, fpfh1, fpfh2, True, max_correspondence_distance=voxel_size * 1.5, estimation_method=o3d.pipelines.registration.TransformationEstimationPointToPoint(False), ransac_n=4, checkers=[o3d.pipelines.registration.CorrespondenceCheckerBasedOnEdgeLength(0.9), o3d.pipelines.registration.CorrespondenceCheckerBasedOnDistance(voxel_size * 1.5)], criteria=o3d.pipelines.registration.RANSACConvergenceCriteria(100000, 0.999)) # 精配准用 ICP result_icp = o3d.pipelines.registration.registration_icp( pcd1_down, pcd2_down, voxel_size * 0.5, result_ransac.transformation, o3d.pipelines.registration.TransformationEstimationPointToPlane()) # 合并后做泊松重建 pcd1.transform(result_icp.transformation) combined = pcd1 + pcd2 combined, _ = combined.remove_statistical_outlier(nb_neighbors=20, std_ratio=2.0) combined.estimate_normals( o3d.geometry.KDTreeSearchParamHybrid(radius=voxel_size * 2, max_nn=30)) mesh, densities = o3d.geometry.TriangleMesh.create_from_point_cloud_poisson( combined, depth=9) # 按密度裁剪低置信区域 densities = np.asarray(densities) vertices_to_remove = densities < np.quantile(densities, 0.01) mesh.remove_vertices_by_mask(vertices_to_remove) o3d.visualization.draw_geometries([mesh])voxel_size是配准的基准尺度,设成点云平均点距的 2 到 3 倍比较稳。max_correspondence_distance在粗配准里设 1.5 倍 voxel_size,精配准里设 0.5 倍,这个比例关系比绝对值更重要。泊松重建的depth=9控制细节层次,9 适合中等细节,11 以上会非常慢且容易过拟合噪声。np.quantile(densities, 0.01)裁掉密度最低的 1%,这是去泊松重建边缘伪影的常用手法。
参数速查表:
| 参数 | 典型值 | 作用 | 调整方向 |
|---|---|---|---|
| numDisparities | 16 的倍数,80-256 | 视差搜索范围 | 近处测不到就加大 |
| blockSize | 5-9 | SGBM 匹配窗口 | 纹理弱加大,边缘糊减小 |
| uniquenessRatio | 10-15 | 唯一性检查 | 误匹配多就加大 |
| voxel_size | 0.005-0.02 米 | 降采样和配准尺度 | 按场景尺度调 |
| depth (泊松) | 8-10 | 网格细节层次 | 细节不够加大,噪声多加小 |
我自己的习惯是每次重建完先看视差图的直方图,如果大部分视差挤在最小值附近,说明numDisparities或基线有问题,先别急着调后处理。双目这套东西,标定占七成,匹配占两成,后处理只占一成,标定没做好后面全是白费功夫。希望帮到你。
本文还有配套的精品资源,点击获取