简介:这份资源面向希望入门或进阶计算机视觉的学习者,提供基于 Python 实现的单目与双目视觉三维重建完整项目,可作为毕业设计、课程设计、大作业或工程实训的参考方案,帮助读者理解从图像采集到深度恢复的基本流程。压缩包共 41 个文件,约 80.24MB,以 jpg 实拍图像为主要数据来源,配合 3 个 py 脚本分别承担单目重建、双目重建与图像拼接功能,另有 txt 说明与 md 文档辅助理解项目结构与运行方式。目前已有 244 人学习下载。项目目录中单目与双目代码相互独立,便于对照阅读;配套的多组实拍图像覆盖不同物体与场景,可直接用于复现实验与调试。读者可借此掌握相机标定、立体匹配、视差计算与点云生成等关键环节,并参考现有脚本快速搭建自己的三维重建流程,适合作为视觉方向入门与项目立项的实践素材。
1. 从两张照片到一堆点云:这套 Python 三维重建资源到底能干什么
你手上有两张从不同角度拍的同一物体照片,想还原出它的三维结构,或者只有一颗摄像头,想从单帧图像里估出深度——这类需求在工业检测、逆向建模、机器人抓取里非常常见。这套基于 Python 实现的单目/双目视觉三维重建资源,核心就是解决「从二维图像恢复三维信息」这件事。它把双目立体匹配、单目深度估计、点云生成与可视化这几条链路串了起来,代码可直接跑,不依赖复杂编译环境。适合有 Python 基础、想快速上手三维重建的开发者,也适合做课程设计或原型验证的工程师。单目方案成本低但尺度模糊,双目精度高但标定麻烦,资源里两条路线都有对应实现,你可以按场景选。
2. 双目重建链路拆解:标定、校正、匹配、三角化
2.1 为什么双目能算出深度
双目视觉的底层逻辑不复杂:两个相机在同一时刻拍同一场景,同一个物点在左右图像上的像素位置有差异,这个差异叫视差。视差越大,物体越近;视差越小,物体越远。深度 Z 和视差 d 的关系是 Z = f × B / d,其中 f 是焦距(像素单位),B 是两个相机光心的基线距离。这个公式是整个双目重建的基石,所有后续步骤都是为了拿到准确的 d。
但直接拿原始图像做匹配会翻车。因为两个相机的镜头畸变、安装角度、焦距不可能完全一致,同一物点在左右图上的极线不水平,匹配搜索会变成二维搜索,计算量大且容易错配。所以必须先做立体校正,把左右图像拉到同一极线水平上,让匹配变成沿水平方向的一维搜索。这一步是双目链路里最容易被跳过、也最容易导致后续点云「重影」的环节。
资源里的双目部分,整体流程是:相机标定 → 立体校正 → 立体匹配 → 视差转深度 → 点云生成。下面按这个顺序拆。
2.2 相机标定:拿到内外参和畸变系数
标定是双目的第一步,目的是拿到每个相机的内参矩阵、畸变系数,以及两个相机之间的旋转和平移关系。常见做法是用棋盘格标定板,拍 15 到 20 组不同角度的照片。资源里用的是 OpenCV 的标定接口,代码结构如下:
import cv2 import numpy as np import glob # 棋盘格内角点数量,比如 9x6 chessboard_size = (9, 6) # 棋盘格方格实际物理尺寸,单位 mm square_size = 25.0 # 生成棋盘格三维坐标 objp = np.zeros((chessboard_size[0] * chessboard_size[1], 3), np.float32) objp[:, :2] = np.mgrid[0:chessboard_size[0], 0:chessboard_size[1]].T.reshape(-1, 2) objp *= square_size objpoints = [] # 三维点 imgpoints_left = [] # 左图角点 imgpoints_right = [] # 右图角点 left_images = sorted(glob.glob('calib/left/*.jpg')) right_images = sorted(glob.glob('calib/right/*.jpg')) for l_img, r_img in zip(left_images, right_images): img_l = cv2.imread(l_img) img_r = cv2.imread(r_img) gray_l = cv2.cvtColor(img_l, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(img_r, cv2.COLOR_BGR2GRAY) ret_l, corners_l = cv2.findChessboardCorners(gray_l, chessboard_size, None) ret_r, corners_r = cv2.findChessboardCorners(gray_r, chessboard_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(gray_l, corners_l, (11, 11), (-1, -1), criteria) corners_r = cv2.cornerSubPix(gray_r, corners_r, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints_left.append(corners_l) imgpoints_right.append(corners_r) # 分别标定左右相机 ret_l, mtx_l, dist_l, _, _ = cv2.calibrateCamera(objpoints, imgpoints_left, gray_l.shape[::-1], None, None) ret_r, mtx_r, dist_r, _, _ = cv2.calibrateCamera(objpoints, imgpoints_right, gray_r.shape[::-1], None, None) # 双目标定,拿到 R 和 T ret, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F = cv2.stereoCalibrate( objpoints, imgpoints_left, imgpoints_right, mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], flags=cv2.CALIB_FIX_INTRINSIC )这段代码的关键参数有三个:chessboard_size必须和实际棋盘格内角点数一致,写错会导致角点检测失败;square_size影响平移向量的物理尺度,如果只关心相对深度可以随便填,但要输出真实毫米单位就必须准确;CALIB_FIX_INTRINSIC表示双目标定时固定单目标定得到的内参,避免过拟合。标定完成后,重投影误差ret最好控制在 0.5 像素以内,超过 1 像素说明标定板角度不够丰富或者图像有运动模糊,需要重拍。
2.3 立体校正与匹配:把搜索降到一维
标定拿到 R 和 T 之后,用stereoRectify计算校正映射,再用initUndistortRectifyMap生成映射表,最后用remap把左右图拉到极线水平。这一步做完,同一物点在左右图上的 y 坐标应该基本一致,匹配只需要沿 x 方向找。
# 立体校正 R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], R, T, alpha=0 # alpha=0 表示校正后图像只保留有效像素 ) # 生成映射表 map1_l, map2_l = cv2.initUndistortRectifyMap(mtx_l, dist_l, R1, P1, gray_l.shape[::-1], cv2.CV_16SC2) map1_r, map2_r = cv2.initUndistortRectifyMap(mtx_r, dist_r, R2, P2, gray_r.shape[::-1], cv2.CV_16SC2) # 对后续每一帧做校正 img_l = cv2.imread('test/left.jpg') img_r = cv2.imread('test/right.jpg') rect_l = cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR) rect_r = 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 ) disparity = stereo.compute(rect_l, rect_r).astype(np.float32) / 16.0numDisparities决定搜索范围,设小了近处物体视差超出范围会丢深度,设大了远处噪声会增多,常见做法是先估一下场景最近距离对应的视差上限。blockSize影响匹配窗口大小,纹理丰富的场景可以小一点保留细节,纹理弱的场景要加大否则匹配失败。uniquenessRatio是唯一性检查,值越大越严格,能过滤掉一些误匹配,但太大会导致有效点变少。SGBM 输出的视差图边缘会有空洞,这是半全局匹配的固有特性,后面可以用左右一致性检查或者滤波补一下。
2.4 视差转点云:Q 矩阵与 reprojectImageTo3D
校正完成后stereoRectify返回的 Q 矩阵就是视差到三维坐标的映射矩阵。OpenCV 提供了reprojectImageTo3D直接做转换:
# 视差转三维点云 points_3d = cv2.reprojectImageTo3D(disparity, Q) # 过滤无效点 mask = disparity > disparity.min() mask = mask & (disparity < num_disp) points = points_3d[mask] colors = rect_l[mask] # 保存为 PLY def write_ply(filename, points, colors): with open(filename, 'w') as f: f.write('ply\nformat ascii 1.0\n') f.write(f'element vertex {len(points)}\n') f.write('property float x\nproperty float y\nproperty float z\n') f.write('property uchar red\nproperty uchar green\nproperty uchar blue\n') f.write('end_header\n') for p, c in zip(points, colors): f.write(f'{p[0]} {p[1]} {p[2]} {c[2]} {c[1]} {c[0]}\n') write_ply('output.ply', points, colors)Q 矩阵里包含了基线、焦距和主点信息,reprojectImageTo3D输出的坐标单位跟标定时square_size一致。如果标定时填的是毫米,点云单位就是毫米。过滤条件里disparity > disparity.min()是为了去掉匹配失败的区域,这些区域视差为负或零,转出来的三维点会飞到无穷远。PLY 文件可以直接用 MeshLab 或 CloudCompare 打开检查,如果点云整体倾斜,大概率是校正没做好或者 Q 矩阵用错了。
提示:双目重建对光照和纹理很敏感。纯色墙面、反光金属、透明玻璃这些场景,SGBM 基本拿不到有效视差,这不是代码问题,是算法边界。
3. 单目路线怎么走:深度估计与尺度恢复
3.1 单目重建的两种思路
单目只有一张图,没有视差,理论上无法直接恢复绝对深度。常见做法分两类:一类是基于几何线索,比如从运动恢复结构(SfM),用多帧图像估计相机位姿和稀疏点云;另一类是基于学习的方法,用预训练模型直接预测每像素深度图,比如 MiDaS、Depth Anything 这类。资源里的单目部分主要走的是深度估计加尺度对齐的路线,适合只有单相机或者只有单帧图像的场景。
单目深度估计模型输出的是相对深度,数值范围归一化过,没有物理单位。要拿到真实尺度,通常需要引入一个已知尺寸的参照物,或者用双目做一次标定来给单目定标。资源里给了一个简单的尺度恢复方案:在场景里放一个已知边长的标定块,用深度图里标定块区域的深度值和实际距离做线性拟合,得到缩放系数。
3.2 用预训练模型跑单目深度
资源里单目部分用的是 ONNX 格式的深度估计模型,不依赖 PyTorch 训练环境,推理只需要 OpenCV 的 dnn 模块:
import cv2 import numpy as np # 加载 ONNX 深度估计模型 net = cv2.dnn.readNetFromONNX('models/depth_model.onnx') img = cv2.imread('test/single.jpg') h, w = img.shape[:2] # 模型输入通常是 384x384 或 512x512,按模型要求调整 input_size = (384, 384) blob = cv2.dnn.blobFromImage(img, 1/255.0, input_size, swapRB=True, crop=False) net.setInput(blob) depth = net.forward() # 输出可能是 [1,1,H,W] 或 [1,H,W],统一 reshape depth = depth.reshape(input_size[1], input_size[0]) # 归一化到 0-255 方便可视化 depth_norm = cv2.normalize(depth, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) depth_color = cv2.applyColorMap(depth_norm, cv2.COLORMAP_INFERNO) # 恢复到原图尺寸 depth_resized = cv2.resize(depth, (w, h), interpolation=cv2.INTER_LINEAR)blobFromImage里的swapRB=True是因为 OpenCV 读图是 BGR 顺序,而模型训练时通常用 RGB。input_size必须和模型导出时一致,写错会直接报维度错误。输出深度图是相对值,近处数值大还是小取决于模型定义,用之前最好拿一张已知场景验证一下方向。可视化用COLORMAP_INFERNO是因为它在深度图上对比度比 JET 更自然,暗处细节不容易丢。
3.3 尺度对齐与点云生成
拿到相对深度后,要转成三维点云还需要相机内参。如果单目相机做过标定,直接用内参矩阵反投影:
# 假设已有内参 mtx fx = mtx[0, 0] fy = mtx[1, 1] cx = mtx[0, 2] cy = mtx[1, 2] # 尺度因子,由参照物确定 scale = 1000.0 # 示例值,需根据实际标定块计算 points = [] colors = [] for v in range(h): for u in range(w): z = depth_resized[v, u] * scale if z <= 0: continue x = (u - cx) * z / fx y = (v - cy) * z / fy points.append([x, y, z]) colors.append(img[v, u]) points = np.array(points) colors = np.array(colors) write_ply('mono_output.ply', points, colors)这段代码是逐像素反投影,速度慢但逻辑清晰。实际用的时候可以向量化,用 numpy 的 meshgrid 一次性算完。scale是单目重建里最玄学的参数,它直接决定点云的整体大小。如果场景里没有已知尺寸物体,只能得到相对结构,绝对尺度没有意义。常见做法是拿一个已知长度的物体放在场景里,量出它在深度图里对应的深度值,反推 scale。
注意:单目深度估计模型对训练集分布外的场景泛化能力有限。室内模型拍室外、或者拍训练时没见过的物体类别,深度图可能完全不可用。用之前先拿几张典型场景图跑一下,看深度边缘是否贴合物体轮廓。
4. 避坑与排查:标定、匹配、点云里最容易翻车的几件事
4.1 标定重投影误差大,点云整体扭曲
现象:双目标定后stereoCalibrate返回的误差超过 1.5 像素,生成的点云看起来像被拧过一样。
原因:标定板角度太单一,大部分照片都是正对相机,导致内参中的焦距和畸变系数耦合,解不稳定。或者标定板在左右图中检测到的角点顺序不一致。
解决:重拍标定图,保证标定板在画面中覆盖各个区域,倾斜角度至少覆盖 ±30 度。拍的时候左右相机要同步,如果物体动了而相机没同步,角点对应关系就错了。拍完先单独检查左右相机的单目标定误差,单目误差都大,双目肯定好不了。
4.2 SGBM 视差图大片黑色,点云稀疏
现象:视差图里大部分区域是黑色,只有纹理丰富的角落有值。
原因:numDisparities设得太小,近处物体的视差超出了搜索范围;或者blockSize太小,弱纹理区域匹配窗口内没有足够信息。
解决:先估算场景最近距离对应的视差。如果基线是 60mm,焦距 600 像素,最近物体 500mm,视差约 72 像素,那numDisparities至少要设到 80 以上(取 16 的倍数)。弱纹理场景把blockSize从 5 加到 11 或 15,同时把uniquenessRatio从 10 降到 5,放宽匹配条件。
4.3 点云颜色和位置对不上
现象:PLY 文件在 MeshLab 里打开,点云形状对但颜色错位,或者颜色整体偏蓝偏红。
原因:OpenCV 读图是 BGR 顺序,写 PLY 时如果直接按 BGR 写入,而查看器按 RGB 解析,红蓝通道就反了。另外reprojectImageTo3D输出的点云和原图是像素对齐的,但如果用了校正后的图做颜色采样,而点云是用校正前的参数生成的,位置就会偏。
解决:写 PLY 时确认通道顺序,OpenCV 的c[0]是蓝、c[2]是红,写入时按 RGB 顺序写。颜色采样统一用校正后的左图,因为视差图是基于校正图算的,点云坐标也是在校正坐标系下。
4.4 单目深度图边缘糊成一团
现象:深度图整体趋势对,但物体边缘模糊,前后景交界处深度渐变而不是突变。
原因:深度估计模型输出分辨率通常低于原图,上采样时用了双线性插值,边缘被平滑了。另外模型本身对细结构的分辨能力有限。
解决:如果模型支持多尺度输出,取高分辨率那一层。上采样时改用最近邻插值保留边缘,或者用引导滤波(guided filter)以原图为引导做边缘保持上采样。对精度要求高的场景,单目深度只做粗定位,精细结构还是得靠双目或者结构光。
4.5 点云文件太大,打开就卡死
现象:PLY 文件几百 MB,MeshLab 加载几分钟没响应。
原因:逐像素生成点云,一张 1920×1080 的图就是两百万个点,其中大量是背景和无效区域。
解决:生成点云前先做掩膜,只保留视差有效且深度在合理范围内的区域。另外可以做体素下采样,把空间上距离小于阈值的点合并,点数能降一个数量级。如果只是看结构,隔行隔列采样也够用。
5. 从能跑到好用:点云滤波、网格化与精度验证
点云生成出来只是第一步,原始点云里混着离群点、噪声和冗余数据,直接拿去做测量或者建模基本不能用。我一般会走一遍统计滤波加半径滤波,把明显飞出去的点去掉。统计滤波的思路是算每个点到最近 k 个邻居的平均距离,假设整体服从高斯分布,把距离超过均值加两倍标准差以上的点判为离群点。OpenCV 没有直接的点云滤波接口,资源里是用 numpy 手写的,几十行代码,比装 Open3D 轻量。
from scipy.spatial import cKDTree def statistical_outlier_removal(points, k=20, std_ratio=2.0): tree = cKDTree(points) dists, _ = tree.query(points, k=k+1) # 去掉第一个,是自己到自己的距离 mean_dists = dists[:, 1:].mean(axis=1) global_mean = mean_dists.mean() global_std = mean_dists.std() threshold = global_mean + std_ratio * global_std mask = mean_dists < threshold return points[mask], maskk取 20 左右比较稳,太小了对局部密度变化敏感,太大了计算慢且会误删细节。std_ratio设 2.0 是保守值,想滤得狠一点可以降到 1.0,但有可能把真实细节也删掉。滤波之后如果要做网格化,常见做法是用泊松重建或者贪婪三角化。泊松重建对噪声容忍度高,但会平滑掉尖锐边缘;贪婪三角化保留细节好,但对点云密度均匀性要求高。资源里两种都给了接口,按场景选。
精度验证这块,最直接的办法是拿一个已知尺寸的标准件,重建之后量它的尺寸,跟真实值对比。我习惯用一个小立方体,边长 50mm,重建后量三个方向的边长,误差在 1% 以内算合格。如果误差大,先查标定,再查视差,最后查 Q 矩阵。单目的话,尺度本身就是拟合出来的,验证意义不大,主要看结构比例对不对。
从那以后我每次跑完重建,都强制走一遍「标准件验证 → 统计滤波 → 体素下采样 → 再验证」的流程,不跳过任何一步。希望帮到你。
本文还有配套的精品资源,点击获取