双目立体视觉实验全流程:从标定、SGBM视差到深度图与点云

发布时间:2026/10/6 22:10:52
双目立体视觉实验全流程:从标定、SGBM视差到深度图与点云 简介这份PDF面向计算机视觉初学者与课程实验学习者聚焦双目立体视觉中由立体匹配生成视差图并进一步恢复深度图的完整流程帮助读者理解区域相关匹配、误差能量计算与深度换算等核心环节。资源包共1个PDF文件大小约122KB内容以实验讲义形式组织涵盖立体匹配研究背景与意义、基于灰度的匹配算法思想、误差能量函数与最小平均误差能量视差图、可靠度计算与可靠视差筛选、由视差图生成深度图及3D显示等模块并附有实现步骤与结果展示。已有279人学习下载适合正在完成计算机视觉实验五或需要补齐双目匹配与深度估计基础的中高级学习者参考可据此梳理算法脉络、对照代码实现并理解视差到深度的三角关系。1. 双目立体视觉建立深度图从视差图到点云这套实验流程到底值不值得跑一遍很多人第一次接触双目立体视觉都是被一张花花绿绿的深度图吸引进来的。但真正动手跑一遍「双目立体匹配获得视差图、再转深度图」的完整实验你会发现事情远没有想象中那么顺滑——左右相机标定参数对不上、视差图边缘全是空洞、深度值算出来量纲不对这些问题几乎每个新手都会撞上。这篇笔记就围绕计算机视觉实验里最经典的双目立体视觉实验展开把从标定、校正、立体匹配到深度图生成的完整链路拆开讲清楚。适合正在做计算机视觉大作业的学生、刚转行做三维重建的工程师以及想搞清楚深度图到底怎么来的从业者。读完你至少能自己跑通一套可复现的流程知道每个参数该调什么、坑在哪。2. 双目立体视觉的几何原理与实验选型为什么视差能变成深度2.1 从三角测量到视差公式一次把几何关系讲透双目立体视觉的核心就一句话两个相机从不同位置看同一个点这个点在左右图像上的横坐标差视差和它到相机的距离成反比。这个反比关系来自三角测量。假设左右相机光心之间的距离是基线 $B$相机焦距是 $f$同一个空间点在左图像上的横坐标是 $x_l$在右图像上是 $x_r$那么视差 $d x_l - x_r$。深度 $Z$ 的公式就是$$Z \frac{f \cdot B}{d}$$这个公式看着简单但它成立有一个硬前提左右图像必须已经经过极线校正也就是两个相机的光轴平行、成像平面共面、极线水平对齐。没有这一步$x_l$ 和 $x_r$ 根本不在同一行上视差就无从谈起。所以整个实验的流程一定是先标定拿到内外参再做立体校正把左右图拉到同一水平线上然后做立体匹配算视差最后用上面的公式转深度。顺序不能乱乱了结果就是错的。这里有个容易忽略的点公式里的 $f$ 和 $B$ 用的是同一套单位。如果你标定出来的 $f$ 是像素单位$B$ 是毫米那算出来的 $Z$ 单位就是乱的。常见做法是把基线也换算成像素单位或者干脆统一用毫米关键是别混。2.2 实验方案选型棋盘格标定 Bouguet 校正 SGBM 匹配做这个实验工具链的选择直接决定你能不能在一周内出结果。我一般推荐的组合是标定张正友棋盘格标定法OpenCV 的calibrateCamera和stereoCalibrate直接支持校正Bouguet 算法OpenCV 的stereoRectifyinitUndistortRectifyMapremap匹配半全局块匹配SGBMOpenCV 的StereoSGBM_create为什么选 SGBM 而不是 BM块匹配BM 速度快但视差图噪点多、空洞大做实验报告拿不出手。SGBM 在精度和速度之间平衡得比较好参数虽然多但调通了效果明显更稳。至于深度学习的方法比如 PSMNet、RAFT-Stereo那是另一个量级的事情需要 GPU 和训练数据不适合作为入门实验的第一套方案。棋盘格标定板的规格也有讲究。常见的是 9x6 或 11x8 的内角点方格边长 25mm 或 30mm。太小了标定不稳定太大了打印出来不平整也会影响精度。我一般用 A3 纸打印 9x6、25mm 的棋盘格贴在硬纸板上保证平整。提示标定用的棋盘格图像至少准备 15 对以上覆盖画面不同位置和角度否则标定参数容易过拟合校正后边缘畸变残留明显。2.3 标定与校正的完整代码实现下面这段代码是标定 校正的核心流程我把它拆成两步方便你定位问题。import cv2 import numpy as np import glob # 第一步单目标定 chessboard_size (9, 6) # 内角点数 square_size 25.0 # 方格边长单位 mm # 生成棋盘格三维坐标 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_l [] # 左图角点 imgpoints_r [] # 右图角点 left_images sorted(glob.glob(left/*.png)) right_images sorted(glob.glob(right/*.png)) for fname_l, fname_r in zip(left_images, right_images): img_l cv2.imread(fname_l) img_r cv2.imread(fname_r) 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_l.append(corners_l) imgpoints_r.append(corners_r) # 单目标定 ret_l, mtx_l, dist_l, _, _ cv2.calibrateCamera(objpoints, imgpoints_l, gray_l.shape[::-1], None, None) ret_r, mtx_r, dist_r, _, _ cv2.calibrateCamera(objpoints, imgpoints_r, gray_r.shape[::-1], None, None) # 第二步双目标定 ret, mtx_l, dist_l, mtx_r, dist_r, R, T, E, F cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], flagscv2.CALIB_FIX_INTRINSIC # 固定单目标定结果只优化外参 ) # 第三步立体校正 R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, gray_l.shape[::-1], R, T, alpha0 # alpha0 裁剪掉无效区域alpha1 保留全部像素 ) # 生成映射表 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) # 对测试图做校正 test_l cv2.imread(test_left.png) test_r cv2.imread(test_right.png) rect_l cv2.remap(test_l, map1_l, map2_l, cv2.INTER_LINEAR) rect_r cv2.remap(test_r, map1_r, map2_r, cv2.INTER_LINEAR) cv2.imwrite(rect_left.png, rect_l) cv2.imwrite(rect_right.png, rect_r)这段代码的逻辑分三层。第一层是单目标定分别拿到左右相机的内参矩阵和畸变系数。cornerSubPix做亚像素精化很关键跳过它角点定位精度大概差 0.5 个像素最终深度误差会被放大。第二层是双目标定CALIB_FIX_INTRINSIC标志表示固定单目内参只优化两个相机之间的旋转矩阵 R 和平移向量 T。第三层是立体校正stereoRectify算出校正后的投影矩阵 P1、P2 和重映射矩阵 QinitUndistortRectifyMap生成映射表remap执行校正。参数方面alpha0会裁剪掉校正后产生的黑色无效区域适合做匹配alpha1保留全部像素但边缘有黑边。做实验报告一般用alpha0画面干净。校正完一定要做一件事在左右校正图上画水平线检查同一物体是否在同一行上。如果不对齐后面视差全是错的。# 验证校正效果画水平线 for i in range(0, rect_l.shape[0], 50): cv2.line(rect_l, (0, i), (rect_l.shape[1], i), (0, 255, 0), 1) cv2.line(rect_r, (0, i), (rect_r.shape[1], i), (0, 255, 0), 1) combined np.hstack((rect_l, rect_r)) cv2.imwrite(rect_check.png, combined)如果棋盘格角点或者场景中的特征点落在同一条绿线上说明校正成功。这一步是后面所有计算的基石别省。3. SGBM 立体匹配与视差图生成参数怎么调、空洞怎么补3.1 SGBM 的六个核心参数逐个拆解OpenCV 的StereoSGBM_create有一堆参数新手最容易懵。我按重要性排个序逐个说清楚。stereo cv2.StereoSGBM_create( minDisparity0, # 最小视差默认 0 numDisparities128, # 视差搜索范围必须是 16 的整数倍 blockSize5, # 匹配块大小奇数3~11 之间 P18 * 3 * 5 ** 2, # 视差平滑惩罚项 1 P232 * 3 * 5 ** 2, # 视差平滑惩罚项 2 disp12MaxDiff1, # 左右一致性检查最大差异 uniquenessRatio10, # 唯一性比率过滤误匹配 speckleWindowSize100, # 斑点滤波窗口大小 speckleRange32, # 斑点滤波视差范围 modecv2.STEREO_SGBM_MODE_SGBM_3WAY # 3 路 SGBM精度更高 ) disparity stereo.compute(rect_l_gray, rect_r_gray).astype(np.float32) / 16.0numDisparities是最关键的参数。它决定了视差搜索范围直接对应你能测到的最近距离。如果物体离相机很近但视差超过了这个范围就会匹配失败。常见做法是先跑一遍看最大视差大概多少再往上取 16 的倍数。比如你估计最大视差在 100 左右就设 112 或 128。blockSize控制匹配窗口大小。窗口越大视差图越平滑但边缘越糊窗口越小细节保留好但噪点多。3 到 11 之间试一般 5 是起点。P1和P2是平滑惩罚项控制视差图的连续性。P1惩罚相邻像素视差变化 1 的情况P2惩罚变化大于 1 的情况。经验公式是P1 8 * channels * blockSize^2P2 32 * channels * blockSize^2。调大P2会让视差图更平滑但可能抹掉细节。uniquenessRatio是唯一性检查值越大过滤越严格误匹配越少但空洞越多。一般 5 到 15 之间。disp12MaxDiff是左右一致性检查设 1 或 2 能有效去掉遮挡区域的错误视差。speckleWindowSize和speckleRange做斑点滤波去掉小块的噪点区域。窗口设 100 左右范围设 32 左右能清掉大部分零散噪点。3.2 视差图后处理空洞填充与亚像素优化原始 SGBM 视差图一定会有空洞尤其是遮挡区域和弱纹理区域。常见的后处理有两步左右一致性检查和空洞填充。# 左右一致性检查 stereo_right cv2.StereoSGBM_create( minDisparity-128, numDisparities128, blockSize5, P18*3*5**2, P232*3*5**2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32, modecv2.STEREO_SGBM_MODE_SGBM_3WAY ) disparity_right stereo_right.compute(rect_r_gray, rect_l_gray).astype(np.float32) / 16.0 # 检查左右视差是否一致 h, w disparity.shape mask np.zeros_like(disparity, dtypenp.uint8) for y in range(h): for x in range(w): d int(disparity[y, x]) if d 0 and d w: if abs(disparity[y, x] - disparity_right[y, x - d]) 1.0: mask[y, x] 255 disparity_filtered cv2.bitwise_and(disparity, disparity, maskmask)左右一致性检查的逻辑是左图某个像素的视差是 d那它在右图对应位置应该是 x-d如果右图那个位置的视差和 d 差太多说明这个匹配不可靠置为无效。这一步能去掉大部分遮挡区域的错误视差。空洞填充我一般用两种方法。简单的是用cv2.inpaint做修复复杂一点的是用邻域有效视差做中值填充。# 方法一inpaint 修复 disp_vis cv2.normalize(disparity_filtered, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U) disp_inpaint cv2.inpaint(disp_vis, (disparity_filtered 0).astype(np.uint8), 3, cv2.INPAINT_TELEA) # 方法二中值填充 disp_filled disparity_filtered.copy() kernel np.ones((5, 5), np.uint8) for _ in range(3): # 迭代 3 次逐步填充 dilated cv2.dilate(disp_filled, kernel) mask_invalid (disp_filled 0) disp_filled[mask_invalid] dilated[mask_invalid]中值填充的思路是用膨胀操作把有效视差往空洞区域扩散迭代几次就能填满大部分小空洞。大空洞填不了也没关系那些区域本来就没有可靠信息。3.3 从视差图到深度图Q 矩阵与单位换算拿到稠密视差图之后转深度图有两种方式。一种是用公式 $Z fB/d$ 逐像素算另一种是用stereoRectify输出的 Q 矩阵做reprojectImageTo3D。# 方式一公式法 f P1[0, 0] # 校正后的焦距像素单位 B np.linalg.norm(T) # 基线单位 mm depth np.zeros_like(disparity_filtered) valid disparity_filtered 0 depth[valid] f * B / disparity_filtered[valid] # 方式二Q 矩阵法 points_3d cv2.reprojectImageTo3D(disparity_filtered, Q) depth_from_q points_3d[:, :, 2]两种方式结果应该一致但 Q 矩阵法还能同时拿到 X、Y 坐标方便生成点云。注意reprojectImageTo3D输入的视差图必须是 float32 且未归一化的原始视差值如果你之前做了归一化到 0-255要先还原。单位换算是个大坑。f是像素单位B是毫米算出来的Z就是毫米。但如果你标定时square_size用的是毫米T的单位就是毫米没问题。如果标定时用了米那深度就是米。关键是标定和计算保持一致。深度图可视化的时候因为深度值范围可能很大比如 300mm 到 5000mm直接显示会一片黑。常见做法是截断到有效范围再做归一化depth_clipped np.clip(depth, 300, 2000) # 截断到 300mm~2000mm depth_vis cv2.normalize(depth_clipped, None, 0, 255, cv2.NORM_MINMAX, cv2.CV_8U) depth_color cv2.applyColorMap(depth_vis, cv2.COLORMAP_JET) cv2.imwrite(depth_color.png, depth_color)用 JET 色图近处偏红、远处偏蓝一眼就能看出深度层次。4. 双目深度图实验避坑这五个翻车点我全踩过4.1 标定重投影误差大于 1 像素后面全白做现象校正后左右图同一物体不在同一行视差图全是乱的。原因标定图像数量不够、角度覆盖不全或者棋盘格不平整。stereoCalibrate返回的重投影误差如果大于 1 像素说明标定质量不行。解决重新采集标定图至少 20 对覆盖画面四个角和中心棋盘格倾斜角度在 15 到 45 度之间。打印的棋盘格贴在玻璃或硬板上别用手拿着。标定完打印ret值小于 0.5 像素才算合格。4.2 numDisparities 设太小近处物体视差溢出现象近处物体在视差图上是全黑或者一片噪声。原因numDisparities设成了 64但实际最大视差有 90 多超出的部分匹配不到。解决先跑一遍大范围比如 256看视差图里最大有效值大概多少再取最近的 16 倍数。或者直接用minDisparity和numDisparities覆盖你估计的视差范围。4.3 深度图量纲混乱数值差了几个数量级现象算出来的深度值是 0.003 或者 30000 这种离谱数字。原因焦距用了归一化值基线用了毫米或者标定时square_size单位是米但你以为 是毫米。解决统一单位。标定时square_size用毫米基线T就是毫米焦距f从P1[0,0]取是像素算出来深度就是毫米。检查一遍P1矩阵P1[0,0]应该是几百到几千的量级取决于分辨率不应该是 0 到 1。4.4 弱纹理区域视差全是空洞现象白墙、桌面这些没有纹理的区域视差图上一大片无效值。原因SGBM 依赖局部纹理做匹配弱纹理区域没有足够特征匹配失败是正常的。解决这是算法本身的局限不是参数能完全解决的。可以尝试减小blockSize、降低uniquenessRatio但效果有限。实际项目中会在弱纹理区域投影散斑图案来增加纹理实验环境下接受这个局限就行报告里说明即可。4.5 校正后图像边缘出现黑边影响匹配现象校正后的左右图边缘有黑色区域视差图对应位置也是黑的。原因stereoRectify的alpha1保留了全部像素但校正后有效区域之外是黑的。解决设alpha0裁剪掉无效区域或者在校正后裁剪掉边缘 5% 到 10% 的区域再做匹配。另外remap之后可以用roi1和roi2裁剪到有效区域。5. 深度图精度验证与点云导出一个被低估的收尾技巧5.1 用已知距离验证深度精度跑完整个流程你肯定想知道深度图到底准不准。最直接的方法是在场景里放一个已知距离的物体比如把棋盘格放在卷尺量好的 500mm 处看深度图对应区域的数值。# 在深度图上取棋盘格中心区域的中值 center_region depth[300:400, 500:600] valid_depths center_region[center_region 0] if len(valid_depths) 0: measured np.median(valid_depths) ground_truth 500.0 # mm error abs(measured - ground_truth) / ground_truth * 100 print(f测量深度: {measured:.1f}mm, 真值: {ground_truth}mm, 误差: {error:.2f}%)误差在 2% 以内算不错5% 以内可以接受。如果误差超过 10%回去检查标定和校正。注意取中值而不是均值避免个别异常值拉偏。5.2 导出点云并在 MeshLab 里看一眼深度图加上像素坐标和相机内参就能反投影出三维点云。OpenCV 的reprojectImageTo3D直接给你三维坐标导出成 PLY 格式就能在 MeshLab 里看。# 生成点云 points_3d cv2.reprojectImageTo3D(disparity_filtered, Q) colors cv2.cvtColor(rect_l, cv2.COLOR_BGR2RGB) # 过滤无效点 mask disparity_filtered disparity_filtered.min() 1 points points_3d[mask] colors colors[mask] # 写入 PLY with open(pointcloud.ply, w) as f: f.write(fply\nformat ascii 1.0\nelement 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\nend_header\n) for p, c in zip(points, colors): f.write(f{p[0]:.3f} {p[1]:.3f} {p[2]:.3f} {c[0]} {c[1]} {c[2]}\n)导出后在 MeshLab 里旋转看看如果点云形状和实际场景一致说明整个链路是通的。如果点云扭曲或者分层大概率是 Q 矩阵用错了检查stereoRectify的输出有没有被覆盖。5.3 一个提升视差图质量的小技巧最后说一个我踩了很多次坑才总结出来的技巧在做 SGBM 之前先对校正后的图像做一次高斯滤波kernel用 3x3 或者 5x5。这一步能平滑掉传感器噪声让视差图明显更干净。但别用太大的核否则边缘会被糊掉视差图边缘精度下降。rect_l_gray cv2.cvtColor(rect_l, cv2.COLOR_BGR2GRAY) rect_r_gray cv2.cvtColor(rect_r, cv2.COLOR_BGR2GRAY) rect_l_gray cv2.GaussianBlur(rect_l_gray, (3, 3), 0) rect_r_gray cv2.GaussianBlur(rect_r_gray, (3, 3), 0)这个操作成本极低但效果立竿见影。我现在的习惯是只要做 SGBM预处理里一定加这一步。希望帮到你。本文还有配套的精品资源点击获取