简介:双目立体视觉建立深度图的实验资料,围绕双目立体匹配这一计算机视觉核心环节,系统讲解由左右视图计算视差图并生成深度图的完整思路,帮助读者理清从像素误差能量到视差图、再到深度数据的转换逻辑,适合高校学生、科研初学者以及准备视觉相关项目的人参考。压缩包内仅1个PDF文档,约122KB,体量轻巧,重点集中,方便对照阅读。文档内容包含立体匹配研究背景与意义、误差能量函数定义、基于最小平均误差能量的视差图生成、可靠度计算与视差筛选、由视差图还原深度图,并配有Python/OpenCV实现步骤,覆盖视差图对比度增强、中值滤波去噪和3D显示等关键细节。文档按研究背景、算法原理、实现结果组织,层次清晰,便于系统掌握实验全流程。已有279人学习下载,既可作为计算机视觉课程实验五的作业参考,也能帮助读者快速搭建双目深度估计的代码框架、理解区域匹配与深度映射中的常见处理技巧。
1. 双目立体视觉建立深度图:为什么这是计算机视觉大作业里最能拉开差距的一题
双目立体视觉建立深度图,落到实验五是一条任务链:左右图像先立体校正,再做双目立体匹配得到视差图,最后换算成深度图。
真正动手会发现每一环都有坑:标定方向反了、匹配参数没调、遮挡没处理,深度图都会翻车。深度恰好是 SLAM、三维重建、自动驾驶感知的前置条件,因此被排进几乎所有计算机视觉入门路线的大作业清单。
这篇笔记面向正在赶计算机视觉大作业的学生,也想帮刚入门的朋友把视差图和深度图的关系一次捋清。你会先看到视差的几何来源,再跟着可复现的代码路径走完实验,最后对照五个经典翻车现场排错。
2. 深度图从哪来:三角测距、极线约束与深度分辨率
先把结论放前面:深度图不是"算"出来的,是"测"出来的。双目相机测量深度的原理和人的眼睛一样,靠的是同一个三维点落在左右两张图上的位置差,也就是视差。视差越大,物体越近。下面按几何模型、搜索策略、深度换算三层讲清楚,每一层都会直接影响后面代码参数怎么设。
2.1 双目成像几何:视差不是玄学,而是三角测距
先建立一个最简单的理想模型:两个完全相同、光轴平行的相机,水平间距为基线 B。空间里有一个点 P,它到相机平面的垂直距离是 Z。P 在左相机成像平面上的投影点是 p_left,在右相机上的投影点是 p_right。由于两相机位置不同,两个投影点的像素列坐标不一样,这个差值 d = x_left - x_right 就是视差(disparity)。根据相似三角形,Z 和 d 满足一个非常干净的关系:Z = f * B / d,其中 f 是焦距,单位是像素。
这个公式值得亲手推一遍。在左相机坐标系里,P 的 x 坐标和投影点满足 x_left = f * X / Z;右相机相对左相机平移了 B,同一空间点在右相机坐标系里满足 x_right = f * (X - B) / Z。两式相减得到 d = f * B / Z,移项就是深度公式。看起来简单,但它解释了两个你在实验里一定会观察到的现象:一是近处物体视差大、远处物体视差小;二是为什么远处深度数据抖得像噪声。
给一组具体数字感受一下。假设标定得到 f = 700 像素,基线 B = 120 毫米:
| 视差 d(像素) | 深度 Z(米) |
|---|---|
| 120 | 0.70 |
| 60 | 1.40 |
| 30 | 2.80 |
| 15 | 5.60 |
| 7 | 12.00 |
| 3 | 28.00 |
注意最后两行:视差从 7 像素变成 3 像素,深度却从 12 米跳到 28 米。也就是说,远处物体只要视差差 1 个像素,深度就差出好几米。把深度公式对 d 求导,得到 dZ = -Z² / (f·B) · dd,这个式子说明深度误差随距离平方增长。这就是所谓的"几何偏置":不是代码不行,是双目几何在远距离上天然把视差的微小误差放大。做实验时如果发现 5 米外的一面墙深度波动特别大,先别怀疑算法,拿这个公式算一下理论误差就明白了。
另外,这里顺带说清楚一个很多入门者会混淆的边界:计算机视觉和机器学习的区别。视差估计这一支传统做法是几何加优化,SGBM、BM 都属于这一类,不需要训练数据;另一支是学习型方法,用卷积网络回归视差图,但输出仍然是视差图。cs231n 这类明星课程主要覆盖分类、检测、分割等识别任务,课件里几乎不涉及立体匹配;如果你想找立体匹配的入门参照,反而该去看 Middlebury 的评测榜单和算法描述。实验课大作业用 SGBM 更稳妥,因为它在 CPU 上就能跑、参数可控、结果可解释;学习型方法对弱纹理更鲁棒,但需要带真值深度的训练数据,对只有一对图像的实验五来说投入产出比不高。
2.2 极线约束:把二维匹配问题压成一维搜索
知道了深度公式,下一个问题是怎么找左右图上对应的同名点。最直接的想法是:在左图取一个像素块,到右图整个平面上去滑窗找最相似的块。这个做法计算量巨大,而且容易匹配错。好在双目几何给了一个关键约束:极线约束。空间点 P、两个相机光心构成的平面叫极平面,P 在左右图上的投影点一定落在极平面与两个像平面的交线上,这条线叫极线。也就是说,左图上的一个点,它在右图上的同名点必然在一条确定的直线上,不用整幅图搜索。
这个约束落到工程上就是"立体校正"存在的理由。实际把两个相机摆在一起,光轴很难做到完全平行,极线是斜的;如果直接拿斜极线的图像去做水平方向搜索,匹配代价天然是错的。于是要先做立体校正(stereo rectification):用标定得到的 R、T,把左右图像重投影到一对虚拟的、光轴平行的相机平面上,让所有极线变成水平线。校正之后,同名点的 y 坐标一致,只需要在水平方向、沿着同一行搜索视差,这就是后面 SGBM 能高效工作的前提。
这里要提醒一点:极线约束给出的是搜索范围,不是匹配的充分条件。一排相似纹理的区域,比如墙面上的条纹、窗帘的褶皱,在水平搜索时会有很多候选位置都匹配得很好,这时候匹配代价分不出真假,输出就容易出现条纹状噪点。SGBM 里的平滑惩罚项 P1、P2 就是用来压制这种歧义的,到第三章参数部分再展开。
2.3 从视差图到深度图:Q 矩阵与 Z=fB/d 的适用边界
校正完之后,SGBM 输出的是一张视差图,每个像素的灰度值代表这个点在左右图上的视差。从视差图到深度图,OpenCV 的做法是用 stereoRectify 返回的 Q 矩阵做一次齐次变换:给定像素坐标 (u, v) 和视差 d,[X, Y, Z, W]^T = Q · [u, v, d, 1]^T,最后用 X/W、Y/W、Z/W 得到三维坐标。Q 矩阵由校正后的内外参构造,所以在 3.2 节里,stereoRectify 返回的 Q 必须留着,后面 reprojectImageTo3D 要用。
实际做实验时,我的习惯是先用 Z = f·B / d 这个简化公式理解数据,再用 Q 矩阵算精确结果。简化公式能帮你快速估算量级:取视差 d 的中位数,代进公式看深度是不是在相机到场景的合理范围内;如果算出来是负的或者几十公里,说明视差符号反了或者标定方向反了,这时候根本不用看 Q 矩阵也知道链路出错。
这一节最后必须讲适用边界,否则你会在两种场景里困惑很久。第一种是遮挡区域:前景物体挡住背景时,背景点只在左图可见、右图看不到,这一类像素没有真正的视差,SGBM 只能靠猜,输出通常是前景边缘的"拖尾"。第二种是极近距离:物体太近时视差很大,可能超出 numDisparities 设置的范围,直接落在无效区。第三种是远距离:视差只有几个像素甚至 1 像素,深度分辨率急剧下降。理解这三个边界,排错的时候就不会对着无效值发呆。
3. 把实验五跑通:从标定参数到深度图输出的完整代码路径
这一章给的是能直接复现的完整路径,环境就是 Python 加 OpenCV。IDE 不用纠结 VS Code 和 PyCharm 装哪个,两个都能跑,能开 Jupyter 更好,因为每一步中间结果都要可视化验证;我自己的习惯是用 VS Code 开 Jupyter notebook,边跑边看。依赖只有 opencv-python 和 numpy,缺了就用 pip 装,不再展开。
3.1 数据准备:图像、标定参数与基线的读取
实验五的标准输入是左右各一组灰度图,外加一份标定结果。标定结果最常见的载体是 npz 文件,里面存了 K1、D1、K2、D2、R、T 一共六样东西;也有实验把标定结果写成 OpenCV 的 yml 格式。下面代码按 npz 读取,yml 的读取方式放在注释里。
import cv2 import numpy as np # 左右图像,SGBM 输入用单通道灰度图 left = cv2.imread("data/left01.png", cv2.IMREAD_GRAYSCALE) right = cv2.imread("data/right01.png", cv2.IMREAD_GRAYSCALE) # 标定结果读取,常见做法是 npz 打包 calib = np.load("data/stereo_params.npz") K1, D1 = calib["K1"], calib["D1"] # 左相机内参矩阵、畸变系数 K2, D2 = calib["K2"], calib["D2"] # 右相机内参矩阵、畸变系数 R = calib["R"] # 右相机相对左相机的旋转矩阵 T = calib["T"] # 右相机相对左相机的平移向量 # 如果是 yml 格式,用 FileStorage 读取,代码形如: # fs = cv2.FileStorage("stereo_params.yml", cv2.FILE_STORAGE_READ) # K1 = fs.getNode("K1").mat() baseline = np.linalg.norm(T) # 基线长度,单位由标定时棋盘格尺寸决定 print("fx =", K1[0, 0], "fy =", K1[1, 1], "基线 B =", baseline, "m")这段代码有三个要点。其一,图像用灰度读取,因为 SGBM 的匹配代价定义在单通道上,彩色图反而会引入颜色噪声;如果手里只有彩色图,先用 cvtColor 转灰度。其二,R、T 的约定方向是"右相机相对左相机",这是 cv2.stereoCalibrate 的输出约定,后面 stereoRectify 直接吃这个方向,千万别自己把它反过来,我见过不止一次有人在这里把 T 取反,导致校正后左右图上下错位。其三,基线 B 从 T 的模长算出来,单位由标定时的棋盘格尺寸决定,一般标定时用毫米,算深度时记得换算成米。
3.2 立体校正:让左右图的极线变成水平线再谈匹配
立体校正这一步完成后,你要能从图上直接验证"极线已经水平"这个事实。先看代码:
h, w = left.shape # stereoRectify 返回校正后的投影矩阵和 Q 矩阵 R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( K1, D1, K2, D2, (w, h), R, T, flags=cv2.CALIB_ZERO_DISPARITY, alpha=0 ) # 生成重映射表,把校正映射预先算好,避免每帧重复计算 map1x, map1y = cv2.initUndistortRectifyMap( K1, D1, R1, P1, (w, h), cv2.CV_32FC1) map2x, map2y = cv2.initUndistortRectifyMap( K2, D2, R2, P2, (w, h), cv2.CV_32FC1) left_rect = cv2.remap(left, map1x, map1y, cv2.INTER_LINEAR) right_rect = cv2.remap(right, map2x, map2y, cv2.INTER_LINEAR)用 remap 完成重采样之后,必须做一次视觉验证:把左右校正图横向拼起来,每隔 40 像素画一条水平参考线,然后盯着图像里明显的边缘点,看它是否落在同一条线上。
check = np.hstack([left_rect, right_rect]) for y in range(0, h, 40): cv2.line(check, (0, y), (2 * w, y), 128, 1) cv2.imwrite("rectify_check.png", check)参数说明:flags 用 CALIB_ZERO_DISPARITY,会让校正后的主点在同一水平线上,视差全部为正值,方便后面取有效范围;alpha=0 表示校正后裁剪掉非公共视野的黑色区域,如果想保留全部像素可以设成 1,代价是边缘出现黑边。map 类型用 CV_32FC1,这是 remap 最常用的浮点映射格式,配合 INTER_LINEAR 做双线性插值。如果验证图上边缘点不在同一行,先检查 R、T 方向,再用 cv2.stereoCalibrate 重新标定,不要跳过标定直接调匹配参数。
提示:stereoRectify 返回的 Q 矩阵必须和 reprojectImageTo3D 配套使用。不要用简化公式 Z=fB/d 的值去替换 Q 矩阵的输出,两者在数值上很接近,但 Q 矩阵已经考虑了校正后的主点偏移,直接替换会让深度图在图像边缘出现系统性偏差。
3.3 SGBM 参数:三个必调参数与输出解读
校正通过后进入核心步骤——双目立体匹配。OpenCV 里有两个经典实现:BM 快但质量差,SGBM 慢一点但质量好得多,实验五默认用 SGBM。我常用的初始化参数如下:
blockSize = 5 stereo = cv2.StereoSGBM_create( minDisparity=0, numDisparities=64, # 必须为 16 的倍数 blockSize=blockSize, # 匹配窗口边长,取奇数 P1=8 * 3 * blockSize ** 2, P2=32 * 3 * blockSize ** 2, disp12MaxDiff=1, # 左右一致性检查阈值 uniquenessRatio=10, # 匹配唯一性比例 speckleWindowSize=100, # 斑点滤波窗口 speckleRange=32, preFilterCap=63, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY, ) disparity = stereo.compute(left_rect, right_rect).astype(np.float32) / 16.0这段代码里最值得解释的是 SGBM 输出为什么除以 16。SGBM 内部用定点数表示亚像素视差,每个像素的视差精度是 1/16 像素,所以原始输出要先转 float32 再除以 16,得到以像素为单位的浮点视差图。忘了这一步,后面所有深度都会偏 16 倍。下面是三个必调参数:
| 参数 | 作用 | 调试方向 |
|---|---|---|
| numDisparities | 允许的最大视差范围,决定能测多近的物体 | 从 64 开始,近处视差被截断时加大;越大越慢 |
| blockSize | 匹配窗口边长,奇数,常用 3~11 | 小窗口保边缘、大窗口抗噪;弱纹理场景可试 7~9 |
| P1 / P2 | 视差平滑惩罚,P2 应明显大于 P1 | P2 越大视差图越平滑,但会吞掉细小结构,先按公式定再微调 |
调参时我的顺序是固定的:先用默认参数跑一遍,看整体轮廓对不对;再调 numDisparities 让最近物体不截断;最后调 uniquenessRatio 和 speckleWindowSize 清理噪点。uniquenessRatio 从 5 调到 15 能明显减少误匹配,但调太大边缘会变稀疏;speckleWindowSize 设 100 到 200 之间可以抹掉小块的孤立噪点,不过它会连真实的小物体一起抹掉,如果场景里本来就有小目标,这项要谨慎。
3.4 视差滤波、深度图生成与可视化
SGBM 输出可以直接转深度,但直接转的结果往往带着不少噪点,我一般会先做一次斑点滤波,再转深度图,最后用伪彩色存图便于肉眼检查。完整代码如下:
# 斑点滤波,把小块的孤立误匹配置为无效 disparity = cv2.filterSpeckles( disparity, newVal=0, maxSpeckleSize=200, maxDiff=8)[0] # Q 矩阵把 (u, v, d) 重投影为三维点 points = cv2.reprojectImageTo3D(disparity, Q) # 返回 H x W x 3 # 有效视差范围掩膜,剔除无效点和截断点 mask = (disparity > 0) & (disparity < 64) depth = np.zeros_like(disparity) depth[mask] = points[:, :, 2][mask] # 第三通道就是 Z # 伪彩色可视化,近处暖色、远处冷色 vis = cv2.normalize(disparity, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) vis_color = cv2.applyColorMap(vis, cv2.COLORMAP_JET) cv2.imwrite("results/disparity_jet.png", vis_color) cv2.imwrite("results/depth.npy", depth)逻辑说明:filterSpeckles 把面积小于 maxSpeckleSize 且与周围视差差超过 maxDiff 的连通块置为 newVal,这里设 0,后面会被 mask 剔除;reprojectImageTo3D 输出三通道数组,第三通道即深度 Z,但无效像素的三维坐标是无意义的极大值,所以必须先用 mask 把无效区域挡掉再取 Z。depth 图存成 npy 是为了后面验证时直接读,不必每次重算。如果你手里有已知距离的参照物,比如一把椅子离相机 1.2 米,就取深度图对应区域的中位数和 1.2 对比,误差在 5% 以内说明链路是通的。
4. 双目立体匹配的五个经典翻车现场与避坑参数
这一章是我做实验五时踩过的坑的汇总,每一条都按"现象 → 原因 → 解决"来写,可以直接对照自己的输出排查。说句实话,立体匹配这行参数全是玄学,很多效果差异靠调参不如靠排错来得快,先确定链路没错再谈参数。
4.1 现象:白墙、天空等无纹理区域全是雪花噪点
视差图在纯色墙面上表现为密密麻麻的随机灰点,深度图对应区域跳动剧烈。原因是 SGBM 的匹配代价基于局部窗口的灰度差异,无纹理区域里每个候选视差位置的代价都差不多,argmin 选出来的就是噪声。解决思路分两层:第一层是用 uniquenessRatio 提高匹配的"唯一性"要求,让代价不够突出的位置直接判无效;第二层是接受这些区域没有有效深度的事实,用中值滤波或者直接把低纹理区域掩膜掉。实验报告里能解释清楚"为什么无纹理区域无深度"比硬塞一个错误深度更得分。
4.2 现象:视差图左右边界出现黑色竖条
左右边缘各有一列黑色区域,宽度和 blockSize 有关。原因是匹配窗口在图像边界处越界,OpenCV 无法为边界外填充,于是输出无效值。解决方法是统计有效视差时把边界裁掉,常见做法是两个方向各裁 blockSize//2 个像素;如果黑色竖条特别宽,还要检查是不是 numDisparities 太大导致右图左侧一大片无法匹配,这种通常出现在近处大视差的物体上,把 numDisparities 调小或者裁掉左边界即可。
4.3 现象:物体边缘出现"拖尾"和前景膨胀
前景物体边缘向外扩了一圈,背景的深度被错误地算成了前景的深度。原因是遮挡区域只有单眼可见,左右一致性检查能部分识别出这类像素,但 blockSize 越大边缘越糊。解决:把 disp12MaxDiff 设为 1,开启左右一致性检查,让左右图互相验证通过的像素才保留;如果拖尾还明显,降低 blockSize。注意这个坑和 4.1 是矛盾的,边缘清晰和弱纹理抗噪不可兼得,我一般先保证边缘,再接受弱纹理区域的噪点。
4.4 现象:立体校正后极线仍然倾斜,深度图整体歪斜
校正检查图上同名点不在同一水平线,深度图整体看起来像被扭过。最常见原因是 R、T 的坐标系方向搞反,stereoRectify 期望的是右相机相对左相机的位姿,如果从别处抄来的代码里传的是左相对右,极线就会斜。解决:打印 R 和 T,确认 T 的 x 分量符号与相机实际摆放一致;或者干脆重新跑一遍棋盘格标定,把 cv2.stereoCalibrate 的输出直接喂给立体校正流程,不要手工改矩阵。还有一个隐蔽情况是标定图像太少或棋盘格角点检测不准,导致 R、T 本身误差大,这种情况只能重新采集标定数据。
4.5 现象:程序运行极慢,一张图要十几秒
SGBM 慢通常不是 OpenCV 的问题,是参数拖的。numDisparities 每翻一倍,计算量接近翻倍;blockSize 从 5 升到 9,代价计算量也会明显上升;再加上 mode 用默认值而不是 3WAY,速度和效果都吃亏。解决:先确认 mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY,这个模式通常是速度和质量的最佳折中;再压缩搜索范围,先算一下场景里最近物体的视差上限,把 numDisparities 设成略大于这个上限的 16 倍数;如果还不够,就把图像缩小一半再跑,视差图随之缩小,但深度关系不变。
5. 深度图质量验收:三种不靠肉眼的主观验证方法
视差图看着五颜六色不算完,实验五要交的是能用的深度图,所以最后这章给三个验证手段,让你在被问到"你的深度图准不准"的时候拿得出数据。
5.1 已知距离参照物验证
找场景里一个距离已知的物体,在深度图上框出对应区域,取有效深度的中位数和真实距离对比。深度图对离群值很敏感,所以用中位数不用均值。
roi = depth[y0:y1, x0:x1] valid = roi[roi > 0] med = np.median(valid) print(f"实测深度 {med:.2f} m,真实距离 {true_dist:.2f} m,误差 {(med / true_dist - 1) * 100:.1f}%")中位数和真实距离误差在 5% 以内说明标定、校正、匹配整条链路是通的;如果偏了 20% 以上,回查基线 B 的单位换算。
5.2 平面拟合残差验证
对一面平整的墙取深度点,用最小二乘拟合平面 z = ax + by + c,看 RMS 残差。这个指标专门暴露系统性畸变,比如深度整体弯曲、近处正常远处翘起。
ys, xs = np.meshgrid(np.arange(h), np.arange(w)) pts = np.stack([xs.ravel(), ys.ravel(), depth.ravel()], axis=1) pts = pts[mask.ravel()] A = np.hstack([pts[:, :2], np.ones((len(pts), 1))]) coef, _, _, _ = np.linalg.lstsq(A, pts[:, 2], rcond=None) residual = A @ coef - pts[:, 2] rms = float(np.sqrt(np.mean(residual ** 2))) print(f"平面拟合 RMS 残差: {rms:.3f} m")如果 RMS 只有几厘米,深度在局部范围内是自洽的;如果到几十厘米,基本可以断定校正或者匹配参数出了问题。这个验证手段在实验报告里写出来,比贴十张伪彩图都有说服力。
5.3 视差图与真值的坏点比例
实验五如果提供真值视差图(有些版本会附带),用坏点比例做定量评估:|d_pred - d_gt| 大于 1 像素的像素占比。这是 Middlebury 评测的标准口径,写在报告里能直接和公开算法的水平对标。
gt = cv2.imread("data/disp_gt.png", cv2.IMREAD_GRAYSCALE).astype(np.float32) / 16.0 bad = float(np.mean(np.abs(disparity - gt) > 1.0)) print(f"坏点比例: {bad * 100:.1f}%")我现在的习惯是做完立体校正先画极线检查图,确认极线水平才往下走,这一步至少帮我省掉一半的返工时间;验证深度图时优先用平面拟合残差,因为它在三种方法里最能暴露系统性问题。如果你的实验五卡在某个环节,按第四章的五个现象对号入座,多半能定位到原因。希望帮到你。
本文还有配套的精品资源,点击获取