Python单目双目三维重建实战:从标定到点云

发布时间:2026/10/1 13:44:58
Python单目双目三维重建实战:从标定到点云 简介这是一套基于Python实现的单目与双目视觉三维重建项目源码面向计算机、通信、人工智能、自动化等专业的学生、教师及从业者可用于课程设计、大作业、毕业设计或自学进阶。项目为个人毕设成果答辩评审得分98分代码经过调试测试可稳定运行。压缩包共41个文件约80.24MB包含3个Python脚本分别对应单目重建、双目重建与图像拼接另有34张jpg实验图片、2个txt参数或说明文件、1个md说明文档和1个png效果图覆盖从数据采集到结果展示的完整流程。目前已有853人学习下载。读者可借此理解相机标定、立体匹配、深度计算与点云生成等核心环节并参考实验图片与说明文档快速复现结果基础较好的学习者还能在此基础上修改调整扩展出不同功能具备较高的学习借鉴价值。1. 从两张照片到一份点云单目与双目三维重建到底在做什么你手里有一台普通 USB 摄像头或者一对标定好的双目模组想从图像里把物体的三维坐标算出来——这就是「基于 Python 实现的单目双目视觉三维重建源码」要解决的事。单目路线靠一台相机加已知参照物或先验尺寸用相似三角形把像素距离换算成物理距离适合测距、平面定位、简单尺寸测量双目路线靠左右两个视角的视差通过三角测量恢复稠密深度适合点云重建、体积估算、机器人避障。两条路线共用同一套底层工具链OpenCV 做标定与校正NumPy 做矩阵运算Open3D 做点云可视化。源码层面单目部分通常几百行就能跑通双目部分因为涉及立体匹配和视差优化代码量会翻两三倍。这篇文章按「先跑通单目测距再搭双目重建最后处理踩坑」的顺序展开每一步都给可复现的命令和参数新手能照着搭环境熟手能直接看边界条件。2. 单目测距的最小闭环标定、去畸变、像素到毫米2.1 为什么单目能测距以及它到底测不准什么单目测距的本质是「用已知推未知」。相机成像模型里同一个物体在图像上的像素高度和它到相机的距离成反比比例系数由焦距和传感器像素尺寸决定。如果你知道物体的真实宽度 W在图像里量出它的像素宽度 w又知道焦距 f像素单位那么距离 D ≈ f × W / w。这个公式成立的前提是物体平面尽量平行于像平面且相机内参已经标定准确。反直觉的地方在于很多人以为单目测距精度取决于相机分辨率实际上更取决于标定误差和物体边缘的像素定位精度。一个 1080P 相机如果焦距标定偏了 2%在 2 米处的测距误差可能超过 4 厘米。所以源码里第一步永远是标定不是直接套公式。常见做法是用棋盘格标定板拍 15 到 20 张不同角度的照片用cv2.calibrateCamera解出内参矩阵和畸变系数。标定板方格尺寸要实测比如 25 毫米这个数字直接进入后续所有计算写错一位后面全错。2.2 用 OpenCV 跑通相机标定与去畸变先装依赖建议用虚拟环境隔离避免和系统里的 OpenCV 冲突python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install opencv-python numpy open3d标定脚本的核心逻辑是读入一组棋盘格图片检测角点累积角点世界坐标和图像坐标最后调用标定函数。下面这段可以直接抄import cv2 import numpy as np import glob # 棋盘格内角点数比如 9x6 表示每行9个内角、每列6个内角 PATTERN (9, 6) # 每个方格的真实物理尺寸单位毫米必须实测 SQUARE_MM 25.0 objp np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32) objp[:, :2] np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE_MM objpoints, imgpoints [], [] for fname in glob.glob(calib_images/*.jpg): img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) ret, corners cv2.findChessboardCorners(gray, PATTERN, None) if not ret: continue # 亚像素优化把角点定位精度从整像素提到 0.1 像素级 corners2 cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) objpoints.append(objp) imgpoints.append(corners2) ret, K, dist, _, _ cv2.calibrateCamera( objpoints, imgpoints, gray.shape[::-1], None, None) print(内参矩阵 K:\n, K) print(畸变系数 dist:\n, dist.ravel()) np.savez(camera_calib.npz, KK, distdist)逻辑说明objp是棋盘格角点在真实世界里的坐标Z 恒为 0因为标定板是平面。findChessboardCorners负责粗定位cornerSubPix在局部窗口里迭代细化这一步不做标定重投影误差通常会在 0.5 像素以上。calibrateCamera返回的内参矩阵 K 里K[0,0]和K[1,1]分别是 x、y 方向焦距像素单位K[0,2]、K[1,2]是主点坐标。参数说明PATTERN必须和实际棋盘格内角点数一致多数人翻车在这里——把方格数当成内角点数差一个就检测不到。SQUARE_MM用卡尺量别信打印时标注的尺寸。亚像素窗口(11,11)对普通镜头够用广角镜头可以放大到(15,15)。标定完做去畸变单目测距前必须做否则边缘区域的像素距离会被畸变拉伸calib np.load(camera_calib.npz) K, dist calib[K], calib[dist] img cv2.imread(test.jpg) h, w img.shape[:2] # 获取去畸变后的新内参alpha0 表示裁掉无效边缘 newK, roi cv2.getOptimalNewCameraMatrix(K, dist, (w, h), 0, (w, h)) undist cv2.undistort(img, K, dist, None, newK) cv2.imwrite(undistorted.jpg, undist)getOptimalNewCameraMatrix的alpha参数是关键设 0 会裁掉畸变校正后出现的黑边但会损失视野设 1 保留全部像素但边缘有黑边。测距场景建议用 0保证有效像素都在画面内。2.3 单目测距的代码实现与焦距换算去畸变之后用相似三角形算距离。假设你已知目标物体真实宽度W_mm在去畸变图上量出它的像素宽度w_px那么距离def mono_distance(f_px, W_mm, w_px): f_px: x方向焦距(像素), W_mm: 物体真实宽度(mm), w_px: 图像中像素宽度 return f_px * W_mm / w_px f_px newK[0, 0] D mono_distance(f_px, 200.0, 150.0) # 物体宽200mm图像中占150像素 print(f估计距离: {D:.1f} mm)这里f_px直接取去畸变后的新内参newK[0,0]不要用原始 K因为去畸变改变了像素映射关系。物体像素宽度w_px的获取方式决定了最终精度手动框选误差大用颜色阈值或轮廓检测自动提取边缘更稳。如果目标是对比度明显的标定块可以用cv2.threshold加cv2.findContours拿最小外接矩形宽度。单目测距的边界很清楚物体必须和像平面近似平行倾斜超过 15 度误差会快速上升物体真实尺寸必须已知未知尺寸就只能测相对距离。所以源码里单目模块通常只用于固定场景比如传送带上已知宽度的零件测距。3. 双目立体视觉从标定到视差图再到点云3.1 双目重建的三角测量原理与标定差异双目和单目的根本区别在于单目靠已知尺寸反推距离双目靠两个相机之间的基线 B 和视差 d 直接算深度。公式是 Z f × B / d其中 f 是焦距像素B 是两相机光心距离毫米d 是同一个点在左右图像里的横坐标差。基线越长同样视差下的深度分辨率越高但基线太长会导致左右视图重叠区域变小匹配失败率上升。常见双目模组基线在 60 到 120 毫米之间。双目标定比单目多一步除了各自的内参还要标定两个相机之间的旋转矩阵 R 和平移向量 T。OpenCV 提供cv2.stereoCalibrate一次性解出所有参数然后用cv2.stereoRectify做极线校正让左右图像的对应点在同一水平线上这样立体匹配只需要在水平方向搜索速度提升一个数量级。标定数据采集有个硬性要求左右相机必须同时拍摄同一个棋盘格且棋盘格要覆盖画面各个区域。常见做法是用一个双相机同步触发板或者用软件触发让两个相机尽量同时曝光。如果左右图不同步运动物体标定会直接失败。3.2 双目标定与极线校正的完整代码下面这段代码假设你已经分别拍好了左右相机的棋盘格图片命名规则是left_*.jpg和right_*.jpg且左右图按序号一一对应import cv2 import numpy as np import glob PATTERN (9, 6) SQUARE_MM 25.0 objp np.zeros((PATTERN[0] * PATTERN[1], 3), np.float32) objp[:, :2] np.mgrid[0:PATTERN[0], 0:PATTERN[1]].T.reshape(-1, 2) * SQUARE_MM objpoints, imgpointsL, imgpointsR [], [], [] left_files sorted(glob.glob(stereo/left_*.jpg)) right_files sorted(glob.glob(stereo/right_*.jpg)) for lf, rf in zip(left_files, right_files): imgL cv2.imread(lf, 0) imgR cv2.imread(rf, 0) retL, cornersL cv2.findChessboardCorners(imgL, PATTERN, None) retR, cornersR cv2.findChessboardCorners(imgR, PATTERN, None) if not (retL and retR): continue cornersL cv2.cornerSubPix(imgL, cornersL, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) cornersR cv2.cornerSubPix(imgR, cornersR, (11, 11), (-1, -1), (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001)) objpoints.append(objp) imgpointsL.append(cornersL) imgpointsR.append(cornersR) # 先各自单目标定拿初值 _, K1, d1, _, _ cv2.calibrateCamera(objpoints, imgpointsL, imgL.shape[::-1], None, None) _, K2, d2, _, _ cv2.calibrateCamera(objpoints, imgpointsR, imgR.shape[::-1], None, None) # 双目标定imageSize 必须和实际图像尺寸一致 ret, K1, d1, K2, d2, R, T, E, F cv2.stereoCalibrate( objpoints, imgpointsL, imgpointsR, K1, d1, K2, d2, imgL.shape[::-1], flagscv2.CALIB_FIX_INTRINSIC) # 极线校正 R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( K1, d1, K2, d2, imgL.shape[::-1], R, T, alpha0) # 生成映射表后续每帧直接 remap mapLx, mapLy cv2.initUndistortRectifyMap(K1, d1, R1, P1, imgL.shape[::-1], cv2.CV_32FC1) mapRx, mapRy cv2.initUndistortRectifyMap(K2, d2, R2, P2, imgR.shape[::-1], cv2.CV_32FC1) np.savez(stereo_calib.npz, K1K1, d1d1, K2K2, d2d2, RR, TT, R1R1, R2R2, P1P1, P2P2, QQ, mapLxmapLx, mapLymapLy, mapRxmapRx, mapRymapRy) print(基线 B , np.linalg.norm(T), mm)逻辑说明stereoCalibrate的flags设成CALIB_FIX_INTRINSIC表示固定单目标定得到的内参只优化外参 R 和 T。这样做的好处是避免双目标定因为数据不足把内参也带偏。stereoRectify的alpha0同样表示裁掉无效边缘保证校正后左右图有效区域对齐。Q是重投影矩阵后面reprojectImageTo3D要用。参数说明T的模长就是基线打印出来和实际模组规格对比偏差超过 5% 说明标定数据有问题。initUndistortRectifyMap生成的映射表可以存下来实时运行时不用重复计算直接cv2.remap即可。3.3 视差计算与点云生成SGBM 参数怎么调校正之后左右图对应点在同一水平线上用半全局匹配SGBM算视差import cv2 import numpy as np import open3d as o3d calib np.load(stereo_calib.npz) mapLx, mapLy calib[mapLx], calib[mapLy] mapRx, mapRy calib[mapRx], calib[mapRy] Q calib[Q] imgL cv2.imread(stereo/left_01.jpg) imgR cv2.imread(stereo/right_01.jpg) rectL cv2.remap(imgL, mapLx, mapLy, cv2.INTER_LINEAR) rectR cv2.remap(imgR, mapRx, mapRy, cv2.INTER_LINEAR) grayL cv2.cvtColor(rectL, cv2.COLOR_BGR2GRAY) grayR cv2.cvtColor(rectR, cv2.COLOR_BGR2GRAY) # numDisparities 必须是16的倍数blockSize 必须是奇数 num_disp 16 * 8 block 5 stereo cv2.StereoSGBM_create( minDisparity0, numDisparitiesnum_disp, blockSizeblock, P18 * 3 * block * block, P232 * 3 * block * block, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange2, modecv2.STEREO_SGBM_MODE_SGBM_3WAY) disp stereo.compute(grayL, grayR).astype(np.float32) / 16.0 # 转成三维点云 points cv2.reprojectImageTo3D(disp, Q) mask disp disp.min() pts points[mask] colors rectL[mask][:, ::-1] / 255.0 pcd o3d.geometry.PointCloud() pcd.points o3d.utility.Vector3dVector(pts) pcd.colors o3d.utility.Vector3dVector(colors) o3d.io.write_point_cloud(cloud.ply, pcd) o3d.visualization.draw_geometries([pcd])逻辑说明remap用之前存的映射表做极线校正保证左右图行对齐。StereoSGBM_create输出的视差是 16 倍定点数所以要除以 16 还原成浮点。reprojectImageTo3D用 Q 矩阵把每个像素的视差转成三维坐标输出的points是 H×W×3 的数组。最后用 Open3D 过滤无效点并保存成 PLY。参数说明numDisparities决定最大可测深度范围值越大能测越近的物体但计算量也越大。blockSize影响匹配窗口大小纹理丰富场景用 5 或 7弱纹理场景可以降到 3 但噪声会上升。uniquenessRatio设 10 表示最佳匹配必须比次佳匹配好 10% 才保留调高会减少误匹配但也会让点云变稀疏。speckleWindowSize和speckleRange用来过滤小面积噪点室内场景 100 和 2 是常用起点。4. 避坑与排查标定、匹配、点云三个环节的翻车记录4.1 标定重投影误差大于 1 像素现象calibrateCamera返回的 ret 值超过 1.0去畸变后直线还是弯的。原因棋盘格图片太少或角度太单一角点检测把模糊边缘当成了角点。解决至少拍 15 张覆盖画面四个角和中心棋盘格倾斜角度在 20 到 45 度之间。拍的时候避免运动模糊快门速度不够就补光。如果某张图角点检测明显偏移直接删掉重拍不要硬塞进标定集。4.2 双目视差图大面积黑色现象SGBM 输出的视差图几乎全黑只有零星亮点。原因极线校正没做对左右图对应点不在同一行或者numDisparities设得太小真实视差超出搜索范围。解决先检查校正后的左右图用画图工具在左图找一个特征点看右图同一行能不能找到对应点。如果对不上回查stereoRectify的输入 R、T 是否正确。然后逐步增大numDisparities从 64 开始试到 256直到视差图出现连续区域。4.3 点云出现明显重影或分层现象重建出的平面点云分成两层厚度超过实际物体。原因双目基线标定不准或者左右相机没有硬件同步运动物体在两帧之间位移了。解决静态场景重标定一次确认T的模长和实际基线一致。动态场景必须用硬件触发同步软件触发在 USB 相机上延迟通常有几十毫秒快速运动物体根本对不齐。如果只是做静态重建这条可以忽略。4.4 单目测距在画面边缘误差翻倍现象物体在画面中心测距误差 2%移到边缘后误差超过 10%。原因去畸变不彻底或者用了原始内参而不是去畸变后的新内参。解决确认测距代码里用的是getOptimalNewCameraMatrix返回的newK不是原始 K。如果畸变系数里高阶项很大可以把alpha设成 0.5 折中牺牲一点视野换边缘精度。另外物体尽量放在画面中心区域测距边缘区域留给双目。4.5 Open3D 可视化卡死或点云不显示现象draw_geometries窗口弹出来是黑的或者直接卡住。原因点云里包含 NaN 或 InfOpen3D 渲染时崩溃。解决在构造点云前过滤掉无效值valid np.isfinite(pts).all(axis1) pts pts[valid] colors colors[valid]另外点云点数超过 200 万时可视化会明显卡顿可以先做体素降采样pcd pcd.voxel_down_sample(voxel_size2.0) # 单位毫米5. 把重建精度再提一档从源码到可交付结果的三个习惯第一个习惯是给标定结果留后悔药。每次标定完把camera_calib.npz或stereo_calib.npz连同标定日期、镜头型号、基线实测值写进一个calib_log.txt。换镜头、动基线、甚至温度变化大的场景标定参数都会漂。我一般会在项目里放一个check_calib.py每次跑重建前先算一遍重投影误差超过阈值就提示重新标定。第二个习惯是视差图后处理不要省。SGBM 原始输出噪声很大加一步左右一致性检查能去掉大部分误匹配# 右视差图也算一遍左右一致性检查 stereoR cv2.StereoSGBM_create( minDisparity0, numDisparitiesnum_disp, blockSizeblock, P18*3*block*block, P232*3*block*block, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange2, modecv2.STEREO_SGBM_MODE_SGBM_3WAY) dispR stereoR.compute(grayR, grayL).astype(np.float32) / 16.0 # 左图视差 d右图对应点视差应该接近 d h, w disp.shape xx np.arange(w)[None, :].repeat(h, axis0) valid (xx - disp) 0 valid (xx - disp) w dispR_warp np.zeros_like(disp) dispR_warp[valid] dispR[xx[valid], (xx - disp)[valid].astype(int)] consistency np.abs(disp - dispR_warp) 1.0 disp[~consistency] 0这段逻辑是对左图每个像素根据它的视差找到右图对应位置比较两个视差是否一致不一致就置零。disp12MaxDiff在 SGBM 内部也做类似检查但外部再做一遍能进一步压噪声。第三个习惯是点云保存前做统计滤波去掉离群点pcd, _ pcd.remove_statistical_outlier(nb_neighbors20, std_ratio2.0)nb_neighbors是邻域点数std_ratio是标准差倍数越小过滤越狠。室内重建一般 20 和 2.0 够用室外大场景可以放宽到 50 和 3.0。最后说一个我自己的教训早期做双目重建时总想着把numDisparities开到最大来覆盖所有深度结果计算时间从 200 毫秒涨到 1.5 秒实时性直接崩掉。后来改成根据场景先验深度范围反推需要的视差范围比如最近 0.5 米、最远 3 米算出来numDisparities只要 128 就够帧率立刻回到 15 FPS 以上。源码里的参数没有万能值先量场景再定参数比盲目调参快得多。希望帮到你。本文还有配套的精品资源点击获取