简介:这份PDF面向计算机视觉初学者与课程实验学习者,聚焦双目立体视觉中由立体匹配生成视差图、再换算深度图的完整流程,帮助读者理解区域相关匹配算法的原理与实现。内容围绕误差能量函数、最小平均误差能量视差图、可靠度筛选与深度图生成展开,并配有左相机图、右相机图、视差图对比度增强及3D深度显示等结果展示,适合作为实验五的参考材料。资源包共1个PDF文件,约122KB,轻量便于下载与查阅。目前已有279人学习下载,说明其在课程实验场景中具有一定参考价值。读者可从中获得基于灰度的匹配思路、中值滤波去噪与阈值筛选可靠视差的具体做法,以及由视差计算深度的三角关系实现,便于对照完成实验报告与代码调试。
1. 双目立体匹配到底怎么把两张图变成深度图
很多人第一次接触双目立体视觉,脑子里想的都是"两个摄像头拍两张照片,一减就出深度",真上手才发现完全不是这么回事。这份《双目立体视觉建立深度图》实验文档,走的是最经典也最容易被低估的一条路:基于灰度的区域匹配。它不依赖深度学习,不需要训练权重,核心就是误差能量函数加可靠度筛选,最后用三角关系把视差换算成深度。整套流程用 Python 加 OpenCV 就能跑通,适合计算机视觉课程实验、大作业复现,也适合想搞明白立体匹配底层逻辑再去看 SGBM、RAFT 这些方法的人。文档里给了完整的代码骨架和参数含义,但坑也不少——窗口大小怎么定、视差范围设多少、可靠度阈值怎么调,每一步都直接影响最终深度图能不能看。下面按"原理立住→动手复现→踩坑排查→进阶验证"的顺序拆一遍。
2. 误差能量函数与视差搜索:匹配算法的骨架怎么搭
2.1 为什么选基于灰度的区域匹配而不是特征匹配
立体匹配分两大类:基于灰度的和基于特征的。特征匹配先提取角点、边缘再配对,速度快但对纹理稀疏区域几乎无能为力,而且特征点之间的视差需要插值,深度图会变得很稀疏。基于灰度的区域匹配反过来——它对每个像素都算一遍,稠密但计算量大,对噪声敏感。这份文档选的是后者,原因很实际:实验目的是获得稠密视差图进而生成深度图,特征匹配给出的稀疏点云没法直接做后续的 3D 显示。
区域匹配的核心思想用一句话说清楚:在左图里以某个像素为中心取一个 m×n 的窗口,然后在右图同一行附近滑动这个窗口,找哪个位置的窗口和左图窗口最像,那个位置的水平偏移量就是视差 d。衡量"像不像"的标准就是误差能量函数。
2.2 误差能量函数的计算逻辑与代码实现
文档里用的误差能量是平方差之和再取平均。先看单点误差:
# 对每个视差 d,计算左图窗口与右图偏移窗口的平方差 for d in range(dmax): for i in range(size1): for j in range(size2): sum_val = 0 for m in range(window_size): for n in range(window_size): for k in range(3): # 三个颜色通道 x = min(size1 - 1, i + m) y = min(size2 - 1, j + n) # 右图取 y+d 位置,越界则钳到边界 square_diff = (int(left[x][min(y + d, size2 - 1)][k]) - int(right[x][y][k])) ** 2 sum_val += square_diff e[i][j] = sum_val / (3 * window_size * window_size)这段代码有几个关键参数需要说清楚。dmax=40是视差搜索范围,意思是假设场景中最近的点在右图里最多偏移 40 个像素。这个值设小了,近处物体匹配不到;设大了,计算量成倍增长。window_size是匹配窗口边长,文档里用的是方形窗口。窗口越大,匹配越稳定但边缘越模糊;窗口越小,细节保留好但噪声多。常见做法是从 5 或 7 开始试。
min(size1-1, i+m)和min(y+d, size2-1)是边界钳制,防止数组越界。这个处理方式比较粗暴——越界像素直接重复边缘值,会在图像边缘产生虚假匹配。更稳妥的做法是把边界区域标记为无效,但实验代码为了简洁没这么做。
单点误差算完后,还要做一次窗口平均,这就是"平均误差能量":
# 对误差能量图再做一次窗口平均,平滑噪声 for i in range(size1): for j in range(size2): e_temp = 0 for m in range(window_size): for n in range(window_size): x = min(size1 - 1, i + m) y = min(size2 - 1, j + n) e_temp += e[x][y] e_temp /= (window_size * window_size) if e_temp < e_avg[i][j]: # 取最小误差对应的视差 e_avg[i][j] = e_temp disparity[i][j] = d注意这里有个容易看漏的细节:e_avg应该在循环外初始化成一个极大值,否则第一轮比较时它可能是 0,导致所有视差都变成 0。文档代码里没显式写初始化,实际跑的时候必须补上e_avg = np.full((size1, size2), np.inf)。
2.3 视差图增强显示:直方图均衡化为什么必要
原始视差图的灰度值集中在很窄的范围内,直接 imshow 出来几乎全黑或全灰,看不出层次。文档用 OpenCV 的直方图均衡化做增强:
temp = cv2.imread('disparity_base.png') gray = cv2.cvtColor(temp, cv2.COLOR_RGB2GRAY) # 均衡化必须基于单通道 dst = cv2.equalizeHist(gray) cv2.imwrite('disparity_base_enhanced.png', dst) temp2 = cv2.imread('disparity_base_enhanced.png') cv2.imshow('视差图(原图->对比度增强显示)', np.hstack([temp, temp2])) cv2.waitKey() cv2.destroyAllWindows()cv2.equalizeHist只能处理单通道灰度图,所以必须先转灰度。增强后的视差图对比度明显提升,近处物体和远处背景的灰度差异一眼能看出来。但要注意,均衡化只是显示层面的处理,不影响后续深度计算的数值。如果你把增强后的图存下来再拿去算深度,数值就全错了。
3. 可靠度筛选与深度换算:从噪声视差图到可用深度图
3.1 中值滤波去噪与可靠度阈值的设定
上一步得到的视差图噪声很重,文档原话是"很多视差是不可靠的"。先做一次中值滤波:
cv2.medianBlur(disparity, 3)中值滤波核大小选 3 是保守做法,选 5 去噪更狠但会抹掉细小结构。这一步只是预处理,真正关键的是可靠度筛选。
可靠度的逻辑是:如果某个像素的最小平均误差能量e_avg[i][j]高于阈值ve,说明这个匹配不可信,把视差置零。阈值ve由全局平均误差乘以系数 alpha 得到:
ve = alpha * e_avg.mean() count_not_ne = 0 sum_e = 0 for i in range(size1): for j in range(size2): if e_avg[i][j] > ve: disparity[i][j] = 0 # 标记为不可靠 else: sum_e += e_avg[i][j] count_not_ne += 1 reliability = float(count_not_ne) ** (-1) * sum_e print("可靠度:", reliability)alpha 是核心调节参数。文档说"系数越低,可靠度越高,但是去噪效果越不好"——这句话需要反过来理解:alpha 越低,阈值越低,被判定为可靠的像素越少,剩下的确实更可信,但视差图会变得稀疏;alpha 越高,保留的像素越多,但噪声也混进来了。我一般会从 0.5 开始试,看视差图的稠密程度和噪声水平再调。
reliability的计算公式是count_not_ne^(-1) * sum_e,即可靠像素的平均误差能量的倒数。这个值越大说明可靠像素的匹配质量越好。但它是个全局标量,只能用来对比不同参数下的整体表现,不能定位具体哪里出了问题。
3.2 视差转深度的三角关系与代码实现
视差和深度的关系来自双目三角测量:
depth = f * T / disparity其中 f 是焦距(像素单位),T 是两个相机之间的基线距离(毫米或米,取决于标定单位)。文档代码里把视差小于 5 的直接当噪声处理:
depth = np.ones_like(disparity, dtype=np.uint8) for i in range(size1): for j in range(size2): if disparity[i][j] < 5: # 视差过小视为噪声 depth[i][j] = 0 else: depth[i][j] = f * T // disparity[i][j] cv2.medianBlur(depth, 3)这里有几个实操中必须注意的点。第一,f和T必须来自相机标定,不能随便填。如果只是做实验演示,可以用近似值,但深度图的绝对尺度就是错的。第二,//是整数除法,会丢失精度。如果深度范围跨度大,建议用浮点除法再归一化显示。第三,视差小于 5 置零这个阈值是经验值,取决于你的 dmax 和场景。如果 dmax 只有 20,那阈值 5 就偏高了,会砍掉大量有效视差。
3.3 3D 深度图显示与 matplotlib 的坑
文档最后用 matplotlib 的 bar3d 做 3D 显示:
X = range(size1) Y = range(size2) Z = depth xx, yy = np.meshgrid(X, Y) X, Y = xx.ravel(), yy.ravel() bottom = np.zeros_like(X) Z = Z.ravel() width = height = 1 fig = plt.figure() ax = fig.gca(projection='3d') ax.bar3d(X, Y, bottom, width, height, Z, shade=True) ax.set_xlabel('X') ax.set_ylabel('Y') ax.set_zlabel('Z(depth)') plt.show()这段代码在 matplotlib 3.4 之后会报错,因为projection='3d'的传参方式改了。正确写法是ax = fig.add_subplot(projection='3d')或者ax = plt.axes(projection='3d')。另外bar3d画的是柱状图,像素多的时候渲染极慢,建议先降采样再画。如果只是想看深度图的形状,用ax.plot_surface或者ax.scatter会快很多。
4. 避坑与排查:这份实验代码跑不通的五个常见原因
4.1 现象:视差图全黑或全灰,看不出任何结构
原因:e_avg没有初始化为极大值,导致第一轮比较时所有视差都被写成 0;或者dmax设得太小,场景中所有点的真实视差都超出了搜索范围。
解决:在视差搜索循环之前加e_avg = np.full((size1, size2), np.inf, dtype=np.float64)。同时检查左右图的极线是否对齐——如果两张图没有经过立体校正,同一物体在左右图中的行坐标不一致,区域匹配根本找不到对应点。常见做法是先用cv2.stereoRectify做校正。
4.2 现象:深度图近处全黑,远处反而有值
原因:视差小于 5 置零的阈值设得过高,把近处物体的有效视差也砍掉了。近处物体视差大,远处物体视差小,如果阈值卡在 5,而你的 dmax 只有 20,那视差在 5 以下的远处物体反而被保留了,近处视差 10 以上的却被误判。
解决:把阈值降到 1 或 2,或者干脆不做这个硬阈值,改用可靠度筛选的结果来控制。如果标定参数已知,可以根据最近工作距离反推最小有效视差。
4.3 现象:程序跑得极慢,一张图要几分钟
原因:五层嵌套循环(d、i、j、m、n)加上 Python 的解释执行,复杂度是 O(dmax × H × W × window_size²)。640×480 的图、dmax=40、窗口 7×7,光内层就是几十亿次操作。
解决:用 NumPy 向量化替代内层循环,或者直接用 OpenCV 的cv2.StereoSGBM做对比。如果必须用这个算法,把图像降采样到 320×240 再跑,速度能快四倍。另外把left和right转成 NumPy 数组而不是嵌套列表,索引速度快一个数量级。
4.4 现象:可靠度数值正常但视差图仍然很脏
原因:中值滤波只做了一次,而且核大小是 3,对椒盐噪声以外的噪声效果有限。另外 alpha 设得偏高,大量低质量匹配被保留。
解决:中值滤波做两次,或者改用双边滤波保边去噪。alpha 从 0.5 降到 0.3 试试,观察视差图稀疏程度和噪声水平的平衡点。如果还是脏,检查左右图是否做了去噪预处理——文档明确说了"该算法对噪声很敏感,需要搭配去噪滤波使用"。
4.5 现象:3D 显示报错projection参数无效
原因:matplotlib 版本更新后fig.gca(projection='3d')的写法被废弃。
解决:改成ax = fig.add_subplot(projection='3d')。如果还报错,检查 matplotlib 版本,3.4 以上都必须用新写法。另外bar3d的shade参数在某些版本里默认值变了,显式写上shade=True更稳妥。
5. 进阶验证:用 Middlebury 数据集检验你的匹配质量
跑通实验代码只是第一步,真正要知道自己的实现到底什么水平,得拿标准数据集测。Middlebury 立体匹配数据集是这个领域最常用的基准,提供了左右图和真实视差图(ground truth),可以直接算误差。
验证流程分三步。第一步,下载 Middlebury 的 Tsukuba 或 Cones 图像对,它们尺寸小、纹理丰富,适合调试。第二步,用你的代码生成视差图,和 ground truth 做逐像素对比。第三步,统计误差大于 1 个像素的像素占比,这个指标叫 bad pixel rate。
# 假设 gt 是 ground truth 视差图,disp 是你的结果 valid = gt > 0 # 只统计有真实视差的区域 error = np.abs(disp[valid] - gt[valid]) bad_pixel_rate = np.sum(error > 1.0) / np.sum(valid) print(f"Bad pixel rate (>1px): {bad_pixel_rate:.4f}")这个指标比肉眼看视差图靠谱得多。我自己的经验是,窗口 7×7、dmax=16、alpha=0.4 在 Tsukuba 上能跑到 15% 左右的 bad pixel rate,再往下压就得换算法了。如果超过 30%,说明参数或者预处理有问题,回头检查极线校正和去噪步骤。
还有一个容易被忽略的验证点:深度图的尺度一致性。用f*T/disparity算出来的深度,单位取决于 f 和 T 的单位。如果 f 是像素单位、T 是毫米,深度就是毫米。但很多实验代码里 f 和 T 是随便填的,导致深度值只有相对意义。要验证绝对精度,得用标定板做实际测量,对比深度图和卷尺量出来的距离。常见做法是在场景里放一个已知距离的平面,看深度图对应区域的数值是否吻合。
从那以后我每次跑立体匹配实验,都会先用 Middlebury 的小图验证参数,再上自己的数据。直接拿实际场景调参,很容易被纹理和光照带偏,最后连问题出在算法还是数据上都分不清。希望帮到你。
本文还有配套的精品资源,点击获取