简介:这份源码包围绕 Python 实现的双目立体视觉深度图生成任务,面向计算机视觉学习者、相机标定与三维重建相关开发者,旨在通过左右视图的视差计算输出深度图。资源共 59 个文件,压缩包约 2.95MB,主要包含 Python 脚本、JPEG/PNG 左右相机图像样本、XML/JSON 配置文件、pyc 编译文件与 Git 忽略文件等。其中 Python 脚本覆盖图像采集、单目/双目相机标定、视差计算与深度图生成等环节;图像与配置文件则分别用于算法验证和参数调整。已有 346 人学习下载。压缩包中还附带 requirements.txt 环境依赖、README/readme.txt 说明,以及左右场景样例集与深度图/视差图结果示例,便于对照复现。目录按 image、result、配置等区分,适合在此基础上扩展标定工程或理解双目视觉全流程。
1. 为什么 Stereo_DepthMap 值得动手做一遍
双目立体视觉解决的核心问题就一个:单目相机拍出来的画面没有尺度信息,你不知道画面里那辆车距离你 5 米还是 50 米。而双目方案通过两个固定间距的相机同时拍摄同一场景,利用视差(disparity)反推深度,是机器人避障、自动驾驶、三维重建领域最经典也最可落地的深度获取手段。这里要讲的 Stereo_DepthMap,本质上是把「从双图到深度图」的完整链路打通:标定、极线校正、立体匹配、视差到深度的转换。对 Python 开发者来说,OpenCV 把这条链路的绝大多数环节都封装好了,你不需要从零实现特征匹配或块匹配算法,但你必须理解每个环节的输入输出和参数含义,否则出来的深度图会是一张噪声图。这篇内容按从业者的实操路径展开,目标是你拿到两个相机(哪怕是两个 USB 摄像头)就能跑通并调出可用的深度图。
2. 相机标定:Stereo_DepthMap 的第一块基石
2.1 为什么标定结果直接决定深度图质量
双目深度估计的数学基础是三角测量,而三角测量的前提是知道两个相机的内参(焦距、主点、畸变系数)和外参(两相机之间的旋转和平移)。很多人第一次跑双目代码,直接拿两张图像进立体匹配,出来的视差图完全是噪声——问题几乎都出在没标定或标定板图像数量不足上。内参错了,极线校正就歪了,立体匹配的搜索范围也随之失效,这是整条链路里最不能偷懒的一步。
OpenCV 提供cv2.calibrateCamera做单目标定,cv2.stereoCalibrate做双目标定。常见做法是打印一张 9×6 的棋盘格,用两个相机同时采集 20~30 组不同姿态的图片对。注意棋盘格必须完全在画面内,而且姿态要覆盖倾斜、远近、上下左右偏移,避免只在一个平面内平移。
2.2 单目标定的最小代码
import cv2 import numpy as np # 棋盘格内角点数 pattern_size = (9, 6) # 棋盘格每个格子的实际边长,单位 mm square_size = 25.0 # 准备对象点:所有角点的三维坐标(z=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 obj_points = [] # 真实世界坐标 img_points = [] # 图像像素坐标 images = [...] # 你的标定图片路径列表 for fname in images: img = cv2.imread(fname) gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) found, corners = cv2.findChessboardCorners(gray, pattern_size, None) if found: obj_points.append(objp) # 亚像素精细化角点坐标 criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) corners2 = cv2.cornerSubPix(gray, corners, (11, 11), (-1, -1), criteria) img_points.append(corners2) else: print(f"{fname} 未检出角点") ret, mtx, dist, rvecs, tvecs = cv2.calibrateCamera( obj_points, img_points, gray.shape[::-1], None, None ) np.savez("calib_left.npz", mtx=mtx, dist=dist)cornerSubPix做亚像素细化是因为findChessboardCorners返回的角点精度只有像素级,而标定对精度极其敏感。objp里的坐标单位是 mm,后续立体匹配出的视差也在这个尺度下换算深度,这里写错单位,深度图的数值整体就会偏差一个数量级。np.savez保存内参矩阵和畸变系数,建议左右相机各存一份。
2.3 双目标定与极线校正的前置参数
单目标定完成后,用cv2.stereoCalibrate求两相机的外参:
ret, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate( obj_points, # 同一组对象点 img_points_left, # 左相机图像角点 img_points_right, # 右相机图像角点 mtx_left, dist_left, mtx_right, dist_right, gray_size, # 图像尺寸 (width, height) flags=cv2.CALIB_FIX_INTRINSIC # 内参已标定,只优化外参 )R是右相机相对左相机的旋转矩阵,T是平移向量。这两个矩阵直接决定了后续stereoRectify的投影矩阵 Q,而 Q 是把视差图转成三维坐标的关键。CALIB_FIX_INTRINSIC标志位意味着只优化外参,这要求前面单目标定足够准;如果单目标定质量一般,可以去掉这个 flag 让双目标定同时微调内参,代价是计算更慢且可能过拟合。
3. 极线校正:把二维搜索降成一维搜索
3.1 为什么要校正:对极约束的工程意义
立体匹配最朴素的想法是:左图某个像素,在右图同一行上去找最相似的点,行差就是视差。但实际安装的两个相机几乎不可能做到光轴完全平行、成像平面完全共面。如果不对图像做校正,匹配点根本不在同一行上,你就得做二维搜索,计算量爆炸且错误匹配率极高。极线校正通过stereoRectify计算两个单应性变换,把左右图像重投影到同一个理想平面上,使对应点落在同一水平线上。这一步做对了,立体匹配就从二维搜索退化成沿极线的一维搜索,效率和质量都是质的飞跃。
3.2 stereoRectify 与 initUndistortRectifyMap 的完整流程
# 图像原始尺寸 h, w = gray_size[1], gray_size[0] # 校正参数:alpha 控制裁剪范围,0 表示黑色区域最少 R1, R2, P1, P2, Q, _, _ = cv2.stereoRectify( K1, D1, K2, D2, gray_size, R, T, alpha=0, flags=cv2.CALIB_ZERO_DISPARITY ) # 生成校正映射表 map1_left, map2_left = cv2.initUndistortRectifyMap( K1, D1, R1, P1, gray_size, cv2.CV_32FC1 ) map1_right, map2_right = cv2.initUndistortRectifyMap( K2, D2, R2, P2, gray_size, cv2.CV_32FC1 ) # 对每帧图像做重映射 rect_left = cv2.remap(img_left, map1_left, map2_left, cv2.INTER_LINEAR) rect_right = cv2.remap(img_right, map1_right, map2_right, cv2.INTER_LINEAR)P1和P2是校正后的投影矩阵,Q是视差到三维坐标的转换矩阵,最后一步从视差图生成深度图时直接用它做矩阵乘法。alpha=0意味着校正后的图像会裁剪掉黑色无效区域,缺点是丢失一部分视野;如果做三维重建需要完整图像,可以调大到alpha=1,但边缘区域的立体匹配质量会变差。
校正完成后务必做一步验证:在左右校正图上手动画几条水平线,肉眼确认对应特征点在同一条水平线上。这一步是排除标定和校正参数错误的最快手段,不要省。
4. 立体匹配核心:SGBM 的最小可用实现与参数调法
4.1 SGBM 的原理认知与选型理由(为什么这里不用 Python 直接写块匹配)
立体匹配算法分三类:局部方法(如 BM)、半全局方法(SGBM)和全局方法(如 Graph Cuts)。纯 Python 写逐像素块匹配,循环一多就卡成 PPT,而 OpenCV 的 SGBM 是基于 C++ 实现的半全局匹配,用动态规划在多个方向上进行代价聚合,效果接近全局方法但速度能跑实时。SGBM 的基本思想是:对每个像素的候选视差计算匹配代价,然后在多个路径上聚合代价,最后通过胜者为王(WTA)选出最优视差。之所以不选 BM,是因为 BM 只考虑局部窗口,在纹理稀疏区域和深度不连续区域容易出现大面积错误;SGBM 的代价聚合机制让它在这些区域明显更稳。
在 Python 生态里做立体匹配,SGBM 不是唯一的选项,但它是精度和速度平衡得最好的默认选择。深度学习方案(如 RAFT-Stereo)精度更高,但需要 GPU 和预训练模型,落地成本高;SGBM 在 CPU 上跑 VGA 分辨率能达到实时帧率,这是它至今仍是工程首选的原因。
4.2 StereoSGBM_create 的关键参数与推荐值
import cv2 # 创建 SGBM 匹配器 sgbm = cv2.StereoSGBM_create( minDisparity=0, numDisparities=16 * 6, # 必须能被 16 整除 blockSize=5, # 奇数,3~11 之间 P1=8 * 3 * blockSize**2, # 平滑惩罚参数,控制视差变化小的情况 P2=32 * 3 * blockSize**2, # 平滑惩罚参数,控制视差变化大的情况 disp12MaxDiff=1, # 左右一致性检查的最大容许差异 preFilterCap=63, # 预处理滤波器的截断值 uniquenessRatio=10, # 视差唯一性百分比阈值 speckleWindowSize=100, # 滤除散斑噪声的窗口大小 speckleRange=2, # 散斑滤波的视差范围 mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY ) # 计算视差图(输入必须是校正后的灰度图) disparity = sgbm.compute(rect_left_gray, rect_right_gray).astype(np.float32) / 16.0numDisparities决定了深度探测范围:视差越大表示物体越近,这个值越小算法越快但近距离物体测不到。P1和P2是 SGBM 最核心的平滑项:P2 吸收视差突变,P2 太小会让深度边缘产生裂缝,太大则会让细小物体被平滑掉。常用经验是 P2 ≈ 4~5 × P1。disp12MaxDiff=1开启左右一致性检查,能剔除遮挡区域的错误匹配,代价是这些像素会被标记为无效。speckleWindowSize用于滤除视差图上的小斑点,如果物体边缘出现大量小黑点,把这个值调大。
4.3 从视差图到深度图的几何转换
# 使用 stereoRectify 得到的 Q 矩阵把视差转成三维坐标 points_3d = cv2.reprojectImageTo3D(disparity, Q) # 深度 = Z 坐标 depth = points_3d[:, :, 2] # 将无效视差(<=0)的深度置为 0 或者一个最大距离 depth[disparity <= 0] = 0.0Q矩阵的数学含义是:[X, Y, Z, W]^T = Q * [x, y, disparity, 1]^T,其中 Z = W 的倒数取负。这里有个常见坑:stereoRectify返回的离散视差图存的是整数(左移了 4 位),需要用disparity.astype(np.float32) / 16.0还原真实视差,很多初学者忘了这步,导致深度值整体放大 16 倍。reprojectImageTo3D输出的是 N×M×3 的数组,直接用切片取 Z 通道就是深度图。
提示:
disparity[disparity <= 0] = 0.0这行不能省,因为disp12MaxDiff检查会把遮挡区域的视差置为无效负值,这些点直接参与计算会产生极大的错误深度。
5. 从标定到深度图的完整工作流:一个能跑通的 Python 代码骨架
import cv2 import numpy as np class StereoDepth: def __init__(self, calib_file): data = np.load(calib_file) self.K1, self.D1 = data["K1"], data["D1"] self.K2, self.D2 = data["K2"], data["D2"] self.R, self.T = data["R"], data["T"] self.img_size = (int(data["width"]), int(data["height"])) # 极线校正参数 self.R1, self.R2, self.P1, self.P2, self.Q, _, _ = cv2.stereoRectify( self.K1, self.D1, self.K2, self.D2, self.img_size, self.R, self.T, alpha=0, flags=cv2.CALIB_ZERO_DISPARITY ) # 生成映射表 self.map1_l, self.map2_l = cv2.initUndistortRectifyMap( self.K1, self.D1, self.R1, self.P1, self.img_size, cv2.CV_32FC1) self.map1_r, self.map2_r = cv2.initUndistortRectifyMap( self.K2, self.D2, self.R2, self.P2, self.img_size, cv2.CV_32FC1) # SGBM 参数 self.sgbm = cv2.StereoSGBM_create( minDisparity=0, numDisparities=16 * 6, blockSize=5, P1=8 * 3 * 5**2, P2=32 * 3 * 5**2, disp12MaxDiff=1, preFilterCap=63, uniquenessRatio=10, speckleWindowSize=100, speckleRange=2 ) def process_frame(self, img_left, img_right): # 校正 rect_l = cv2.remap(img_left, self.map1_l, self.map2_l, cv2.INTER_LINEAR) rect_r = cv2.remap(img_right, self.map1_r, self.map2_r, cv2.INTER_LINEAR) # 转灰度 gray_l = cv2.cvtColor(rect_l, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(rect_r, cv2.COLOR_BGR2GRAY) # 立体匹配 disp = self.sgbm.compute(gray_l, gray_r).astype(np.float32) / 16.0 # 深度图 points = cv2.reprojectImageTo3D(disp, self.Q) depth = points[:, :, 2] depth[disp <= 0] = 0.0 return rect_l, rect_r, disp, depth # 使用示例 stereo = StereoDepth("calib_result.npz") cap_l = cv2.VideoCapture(0) cap_r = cv2.VideoCapture(1) while True: ret_l, frame_l = cap_l.read() ret_r, frame_r = cap_r.read() if not (ret_l and ret_r): break rect_l, rect_r, disp, depth = stereo.process_frame(frame_l, frame_r) # 可视化:深度图归一化到 0~255 便于显示 depth_viz = cv2.normalize(depth, None, 0, 255, cv2.NORM_MINMAX) depth_viz = cv2.applyColorMap(depth_viz.astype(np.uint8), cv2.COLORMAP_JET) cv2.imshow("Depth", depth_viz) if cv2.waitKey(1) & 0xFF == ord('q'): breakStereoDepth类把标定参数加载、校正映射表生成和 SGBM 匹配器初始化都放在构造函数里,避免每帧重复计算。process_frame的四个步骤(校正-灰度-匹配-转换)就是整个 Stereo_DepthMap 设计的核心链路。注意normalize和applyColorMap只是为了可视化,如果后续要做目标检测或测距,直接用原始depth数据,不要用归一化后的图像。
cv2.VideoCapture打开两个摄像头时,有些 USB 摄像头不支持同时被两个进程打开,常见的做法是把两个摄像头接在不同的 USB 控制器上,或者在打开前先设置分辨率一致:
cap_l.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap_l.set(cv2.CAP_PROP_FRAME_HEIGHT, 480) cap_r.set(cv2.CAP_PROP_FRAME_WIDTH, 640) cap_r.set(cv2.CAP_PROP_FRAME_HEIGHT, 480)分辨率不一致会导致两个图视角范围不同,极线校正失效。如果遇到帧率不匹配,最简单的方案是同步读取后比对时间戳,丢弃不配对的那一帧。
6. 深度图质量验证与三个实用排错技巧
6.1 用 WLS 滤波提升深度图平滑度
SGBM 输出的原始视差图在纹理稀疏区域会有大量空洞和毛刺,一个低成本的改进是使用cv2.ximgproc模块的 WLS(Weighted Least Squares)滤波。WLS 利用左图的颜色边缘作为引导,在不破坏深度边缘的前提下平滑内部区域:
import cv2.ximgproc as xip # 创建 WLS 滤波器(需要右图的 SGBM 视差用于一致性检查) sgbm_right = cv2. StereoSGBM_create( minDisparity=0, numDisparities=16 * 6, blockSize=5, P1=8 * 3 * 5**2, P2=32 * 3 * 5**2, disp12MaxDiff=1, preFilterCap=63, uniquenessRatio=10, speckleWindowSize=100, speckleRange=2 ) disp_left = sgbm.compute(gray_l, gray_r).astype(np.float32) / 16.0 disp_right = sgbm_right.compute(gray_r, gray_l).astype(np.float32) / 16.0 wls = xip.createDisparityWLSFilter(sgbm) wls.setLambda(8000.0) # 平滑强度,越大越平滑 wls.setSigmaColor(1.5) # 颜色相似度权重 filtered_disp = wls.filter(disp_left, gray_l, None, disp_right) depth_filtered = cv2.reprojectImageTo3D(filtered_disp, self.Q)[:, :, 2]setLambda控制平滑程度,setSigmaColor控制颜色边缘的保持力度。lambda 太大会把真实的深度边缘也抹平,太小则滤波等于白做,一般从 8000 开始调。这个滤波通常能给深度图带来肉眼可见的质量提升,而且计算开销不高,值得加到正式流程里。
6.2 验证深度准确度的相机到标定板距离法
深度图做出来不是看颜色好看就完事,必须量化验证。最直接的方法:把标定棋盘格放在相机前方已知距离 d 处,运行你的深度图程序,取棋盘格中心区域的深度均值,和 d 对比。误差在 3% 以内说明标定和匹配参数基本可靠。如果偏差大于 5%,优先怀疑标定板的square_size单位写错,其次是numDisparities太小导致近处视差被截断。
# 假设棋盘格中心深度均值 measured_depth,真实距离 real_dist error_percent = abs(measured_depth - real_dist) / real_dist * 100 print(f"深度误差: {error_percent:.2f}%")这个方法不需要额外传感器,几行代码就能把整个链路的精度测出来,比肉眼看深度图可靠得多。
6.3 SGBM 参数速调参考表
| 参数 | 现象 | 调整方向 |
|---|---|---|
numDisparities过小 | 近距离物体深度值失效或为 0 | 增大到能覆盖最近探测距离 |
P1/P2过大 | 物体边界模糊,细小物体丢失 | 减小 P1/P2,或同时调小 blockSize |
P1/P2过小 | 深度图噪声多,表面粗糙 | 增大 P2,让平滑项起效 |
uniquenessRatio过低 | 纹理重复区域出现条纹状错误 | 增大到 15~20 |
speckleWindowSize过小 | 深度图上散落大量孤立噪点 | 增大到 150~200,并同时调大speckleRange |
blockSize过大 | 深度边缘偏移严重 | 减到 3~5,代价是噪声略增 |
调参的顺序有讲究:先调numDisparities保证动态范围,再调P1/P2控制平滑,最后调uniquenessRatio和speckle清理噪声。一次只动一个参数,看它对整张深度图的影响,而不是盲目组合。
一个容易被忽略的细节:SGBM 对输入图像的亮度一致性敏感。左右相机如果是自动曝光,同一场景下的亮度差异会导致匹配代价偏高。工程上通常在标定前就把两个相机的曝光和增益设为固定值,而不是自动模式。如果做不到,至少要在进入 SGBM 前对左右图做直方图均衡化,cv2.equalizeHist一行代码就能缓解大部分亮度不一致的问题。这不算理论上的最优解,但在实际项目中往往比换更复杂的匹配算法更管用。
本文还有配套的精品资源,点击获取