基于OpenCV的双目立体视觉测距:从标定到深度估计
2026/9/12 2:33:52 网站建设 项目流程

简介:一份基于Python与OpenCV实现的双目立体视觉图像匹配与测距资源包,面向毕业设计、课程设计及项目开发场景,适合计算机视觉初学者和需要快速搭建原型系统的开发者。压缩包共165个文件,整体约96.52MB,核心为38个Python源码,并包含49个bmp图像样本、23个jpg图片、10个ui界面文件及xml、qss等配置资源;论文部分提供13个caj、5个pdf和2个docx格式的毕业论文与参考文献,便于对照阅读和写作参考。目前已有224人浏览学习。内容涵盖SIFT与SURF特征提取、BF与FLANN特征匹配、基于视差深度计算的三维定位与测距,并结合MV-VS220双目平台完成图像采集、匹配与测距的交互演示,可直观比较不同算法的性能。源码经过严格测试,系统界面、功能与性能均达到设计要求,模块划分清晰,方便在此基础上进行算法替换或功能扩展,是系统学习双目视觉原理与工程实现的完整参考。

1. 从两个摄像头到深度估计:这个项目到底在做什么

双目立体视觉最反直觉的一点是:它不需要任何主动光源,仅凭两个普通 USB 摄像头,就能在 1~3 米范围内获得厘米级的深度估计。这个标题里的“图像匹配”和“测距”其实是同一条链路的两个环节——先通过极线校正让左右图像的行对齐,再在水平方向搜索同名点,最后用视差和三角测量反推出物体到相机的距离。对做毕业设计或课程设计的人来说,这套方案最大的价值在于它完全跑在 OpenCV 生态内,不需要昂贵的双目相机硬件,不用 GPU,一台带摄像头的笔记本或台式机就能把原理到实现整条链路走通。本文会顺着“标定 → 校正 → 匹配 → 测距”这条主线,把每一步的数学依据、参数含义和常见坑讲清楚,最后给出一个可以直接抄作业的工程骨架。

2. 视差计算与三角测量:双目测距的原理边界

2.1 为什么两个摄像头能算出深度

双目测距的核心不是图像本身,而是“几何约束”。假设左右两个摄像头的光轴平行、焦距相同、成像平面共面,那么空间中的任意一点 P,在左图中的投影为 p_l,在右图中的投影为 p_r。由于两个摄像头的位置不同,p_l 和 p_r 在图像坐标系中的 x 坐标存在差异,这个差异就是视差 d = x_l - x_r。

深度与视差的关系由相似三角形推出:

Z = (f * B) / d
  • Z:物体到相机基线的垂直距离(深度)
  • f:归一化焦距(像素单位)
  • B:左右摄像头光心之间的直线距离,即基线长度
  • d:视差,单位是像素

这个公式的物理含义很直白:物体越近,它在左右图中位置的偏移越大,视差越大;物体越远,视差趋近于零。所以双目测距的量程本质上由“最小可分辨视差”决定——当视差小于 1 个像素时,深度就趋近无穷大,系统失去分辨能力。

在 OpenCV 中,f 通常从相机内参矩阵的 fx 元素获取,B 需要通过标定得到,d 则来自立体匹配算法输出的视差图。三者齐备,深度图就是逐像素的除法运算。

2.2 理想模型的三个假设与现实的差距

上面的公式建立在三个理想假设上:左右相机内参完全相同、光轴严格平行、成像平面共面。真实摄像头几乎不可能满足这些条件。

两个镜头就算型号相同,装配误差也会导致焦距相差零点几个百分点;安装支架的加工误差会让光轴形成几度夹角;即便调整得再好,成像平面也存在微小的旋转偏差。这些误差如果不做校正,直接套用 z = fB/d,测距误差会在近距离处被急剧放大。

因此工程上必须引入两个前置步骤:

  1. 单目标定:分别求出左右相机的内参矩阵 K 和畸变系数 D。
  2. 双目标定:求出两相机之间的旋转矩阵 R 和平移向量 T。

得到这些参数后,通过立体校正(stereo rectify)把左右图像变换成理想状态——行对齐、极线水平。这也是 OpenCV 里 stereoRectify 和 initUndistortRectifyMap 在做的事。

2.3 基线长度与测距精度的权衡

基线 B 是一个需要人为选择的参数,它对测距精度的影响非常直接。从公式 Z = fB/d 做误差传播,可以得到:

ΔZ = (Z² / (f * B)) * Δd

这里的 Δd 是视差匹配的误差,通常为 0.5~1 个像素。以焦距 fx≈600 像素、基线 B=60mm 为例,在 Z=1000mm 处,单个像素视差误差对应的深度误差约 28mm。如果把基线拉长到 120mm,同样条件下深度误差能降到 14mm。

但基线不是越长越好。基线增大意味着左右视角差异增大,物体在左右图中的外观变化更明显,匹配的困难程度随之上升,遮挡区域也更多。做桌面级测距,基线建议选在 40~120mm 之间,兼顾精度和匹配鲁棒性。选基线时还要考虑摄像头视野重叠区——被测物体必须同时落在两个相机的视野内。

3. 用 OpenCV 完成双目标定与极线校正

3.1 标定板准备与图像采集规范

标定是双目测距里最影响下限的环节。OpenCV 的 calibrateCamera 和 stereoCalibrate 都依赖棋盘格角点做优化,标定板的质量和采集数量决定内参的可靠性。

我一般用 A4 纸打印 9×6 的棋盘格,方格边长 25mm,贴在硬纸板上避免弯曲。要特别注意:

  • 棋盘格必须是平面,弯曲或皱褶的标定板会直接污染角点坐标。
  • 每个角点在图像中至少覆盖 15×15 像素,太远或太小的棋盘格会让角点检测亚像素精度下降。
  • 采集 20~30 对左右图像,覆盖视野的九个区域(左中右 × 上中下),每张图中棋盘格的角度要有明显变化。

采集代码可以用 OpenCV 的 VideoCapture 逐帧读取并保存:

# capture_stereo.py import cv2 import os left_cap = cv2.VideoCapture(0) # 左摄像头 ID right_cap = cv2.VideoCapture(1) # 右摄像头 ID os.makedirs('stereo_images/left', exist_ok=True) os.makedirs('stereo_images/right', exist_ok=True) count = 0 while count < 30: ret_l, frame_l = left_cap.read() ret_r, frame_r = right_cap.read() if not (ret_l and ret_r): print("读取失败,检查摄像头 ID") break # 并排显示两路画面方便调整位置 combined = cv2.hconcat([frame_l, frame_r]) cv2.imshow('combined', combined) key = cv2.waitKey(1) if key == ord('s'): cv2.imwrite(f'stereo_images/left/{count:02d}.jpg', frame_l) cv2.imwrite(f'stereo_images/right/{count:02d}.jpg', frame_r) print(f"保存第 {count} 对图像") count += 1 elif key == 27: break cv2.destroyAllWindows()

这段代码的逻辑是:同时打开两个摄像头 ID 0 和 ID 1,把左右画面横向拼接后显示。按 S 键同时保存当前左右帧,按 ESC 结束采集。建议每换一个位置先移动棋盘格,再按 S 保存,避免相同位姿重复采集。摄像头驱动有延迟的话,采集时保持棋盘格静止,防止左右帧时间戳不一致导致匹配点偏移。

3.2 单目标定与双目标定的参数取舍

==有人说 optuna 里 tuner=‘choose_from’ 会自动选最优的经过训练的模型,甚至不需要再拟合?== 那批人==

单目标定直接用 OpenCV 的样例代码就能跑,但有两个参数值得细调:

  • criteria 的终止条件设为 (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001),30 次迭代足够让角点亚像素定位收敛。
  • calibrateCamera 的 flags 参数我一般不用 CV_CALIB_ZERO_TANGENT_DIST,因为切向畸变虽然小,但强制归零会掩盖镜头装配误差。

双目标定使用 stereoCalibrate,注意传给它的旋转矩阵和平移向量必须是单目标定得到的值:

# stereo_calibrate.py import cv2 import numpy as np import glob CHESSBOARD_SIZE = (9, 6) SQUARE_SIZE = 25.0 # 毫米 # 准备 3D 对象点 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 = [] images_l = sorted(glob.glob('stereo_images/left/*.jpg')) images_r = sorted(glob.glob('stereo_images/right/*.jpg')) # 分别检测左右图像的角点 for img_l, img_r in zip(images_l, images_r): gray_l = cv2.imread(img_l, 0) gray_r = cv2.imread(img_r, 0) 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, rvecs_l, tvecs_l = cv2.calibrateCamera( objpoints, imgpoints_l, gray_l.shape[::-1], None, None) ret_r, mtx_r, dist_r, rvecs_r, tvecs_r = cv2.calibrateCamera( objpoints, imgpoints_r, gray_r.shape[::-1], None, None) # 双目标定 flags = cv2.CALIB_FIX_INTRINSIC criteria_stereo = (cv2.TERM_CRITERIA_MAX_ITER + cv2.TERM_CRITERIA_EPS, 100, 1e-5) 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], criteria=criteria_stereo, flags=flags)

代码里检测到的角点会做亚像素精细化,这是提高标定精度的关键一步。findChessboardCorners 返回的角点精度在像素级,cornerSubPix 会基于梯度做亚像素优化,配合 (11, 11) 的窗口能让角点定位精度提升一个量级。

stereoCalibrate 的 flags 这里用了 CALIB_FIX_INTRINSIC,意思是只优化左右相机之间的外参,内参保持单目标定的结果不变。这样做的理由是:单目标定已经用了全部图像拟合出较优的内参,双目标定阶段如果同时优化内外参,自由度太多,容易收敛到局部极小值。如果标定板图像数量偏少(少于 15 对),可以考虑去掉这个 flag 做全参数优化,但要注意重投影误差是否发散。

3.3 极线校正确认行对齐

stereoCalibrate 之后,用 stereoRectify 计算校正映射,再生成 undistort 所需的查找表:

# rectify.py import cv2 import numpy as np image_size = gray_l.shape[::-1] # (width, height) alpha = 0 R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, image_size, R, T, alpha=alpha) map1_l, map2_l = cv2.initUndistortRectifyMap( mtx_l, dist_l, R1, P1, image_size, cv2.CV_16SC2) map1_r, map2_r = cv2.initUndistortRectifyMap( mtx_r, dist_r, R2, P2, image_size, cv2.CV_16SC2)

stereoRectify 的 alpha 参数控制校正后图像的裁剪范围。alpha=0 表示只保留有效像素区域,会大幅裁剪边缘;alpha=1 保留所有原始像素,但会出现黑色边框。实际使用中 alpha 取 0 即可,因为测距通常关注中心区域。校准完成后,用 cv2.remap 处理左右图像,然后在显示窗口中画几条水平线,观察棋盘格角点是否精确落在同一水平线上。若行对齐偏差超过 2 个像素,一般是标定图像质量不够或左右图像时间不同步,需要重新采集。

4. SGBM 图像匹配与像素级测距实现

4.1 StereoSGBM 的核心参数与调参思路

双目图像匹配的经典算法里,BM 速度最快但精度粗糙,SGBM(Semi-Global Block Matching)在精度和速度之间最均衡,也是目前 OpenCV 里做实时测距的首选。

SGBM 的核心思想是逐像素匹配代价计算 + 多个方向路径代价聚合 + 胜者为王(WTA)视差选择 + 左右一致性检查。它比局部 BM 强在引入了平滑约束,能在弱纹理区域减少误匹配。

关键参数有 5 个:

  • minDisparity:最小视差,通常设为 0,表示无穷远。
  • numDisparities:视差搜索范围,必须能被 16 整除。取值决定了可测的最近距离。
  • blockSize:匹配块大小,必须是奇数。值越小,边缘细节保留越好,但噪声响应越强。
  • P1、P2:平滑惩罚系数,P2 通常取 P1 的 4~8 倍。P2 越大,对视差突变的惩罚越重,深度边缘会被抹平。
  • uniquenessRatio:视差唯一性阈值,用于排除低置信度区域,通常取 5~15。

一个经验起始配置如下:

# stereo_sgbm.py import cv2 import numpy as np def create_sgbm(min_disparity=0, num_disparities=128, block_size=11): stereo = cv2.StereoSGBM_create( minDisparity=min_disparity, numDisparities=num_disparities, blockSize=block_size, P1=8 * 3 * block_size ** 2, P2=32 * 3 * block_size ** 2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, preFilterCap=63, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY ) return stereo # 读取校正后的左右图 img_l = cv2.imread('rectified_left.jpg', 0) img_r = cv2.imread('rectified_right.jpg', 0) stereo = create_sgbm() disparity = stereo.compute(img_l, img_r).astype(np.float32) / 16.0

前面提到热词里有“opencv 4.5.2 原生支持 code128”“opencv rect 函数”相关内容,实际编码中这两者与 SGBM 本身没有耦合,但注意 OpenCV 4.x 的 StereoSGBM 接口和 3.x 的 SGBM_create 在参数上完全兼容,升级版本不会破坏已有代码的调参习惯。

disparity 输出是固定点数,需要除以 16 得到真实视差。如果直接拿未除的值去算深度,结果会整体偏近 16 倍,这是最常见的错误之一。

4.2 视差图后处理:WLS 滤波与遮罩

SGBM 输出的原始视差图在弱纹理区域会有明显的空洞和噪声。直接用于测距会导致深度图出现大量离群点。常规做法是叠加 WLS(Weighted Least Squares)滤波,用左图的灰度梯度引导视差平滑:

# wls_filter.py def apply_wls(disparity_left, disparity_right, img_left): wls = cv2.ximgproc.createDisparityWLSFilter( stereo, use_confience=True) wls.setLambda(8000.0) wls.setSigmaColor(1.5) filtered = wls.filter(disparity_left, img_left, None, disparity_right) return filtered

这里需要右视差图作为约束。WLS 的 lambda 控制平滑强度,过大容易让物体边缘模糊,测距时近景物体边界会被“侵蚀”;sigmaColor 控制颜色相似度的衰减速度,对纹理丰富的场景影响不大,对纯色墙面影响显著。

WLS 滤波的输出仍然保留无效像素(值为 0 或负数的区域),处理时要用 mask 剔除:

valid_mask = disparity > min_disparity

常见的错误是直接把 0 视差当作无效值,但近处物体视差可能恰好在 0 附近,正确的做法是结合左右一致性检查的结果做掩膜。

4.3 从视差图计算深度图

视差图转深度图有两种路径。第一种是用 stereoRectify 输出的 Q 矩阵直接做 reprojectImageTo3D;第二种是手写公式逐像素计算。两种路径本质上等价,但手写公式更直观,方便理解每个变量的来源:

# depth_map.py def disparity_to_depth(disparity, baseline, focal_length, min_depth=0.1, max_depth=10.0): # 避免除零 disparity[disparity <= 0] = 0.1 depth = (focal_length * baseline) / disparity depth[(depth < min_depth) | (depth > max_depth)] = 0 return depth # baseline 单位需与 focal_length 的分母一致 baseline = 0.06 # 米 focal_length = mtx_l[0, 0] # 像素 depth_map = disparity_to_depth(disparity.copy(), baseline, focal_length)

这里最隐蔽的坑是单位。fx 的单位是像素,基线的单位是米,两者相除得到的深度单位是米。如果标定板的方格用了毫米做单位,那么从 Q 矩阵得到的深度也以同样的单位为准,必须先做一次单位换算再交给后面的逻辑使用。KITTI 数据集里视差图是 uint16 编码的,除以 256 才是真实视差,切不可照搬这里的除以 16 的规则。

4.4 测距验证:用标定板反推距离

拿到深度图之后,先别急着测任意物体,用标定板做个定量验证是最快的纠错手段。流程分三步:

  1. 把标定板放在已知距离处(例如用卷尺量 1.0 米)。
  2. 在左图中检测棋盘格角点,取角点区域的视差中值。
  3. 换算成深度,与真实距离比较。
# verify_distance.py def measure_distance_at_points(depth_map, points): distances = [] for (x, y) in points: region = depth_map[y-5:y+6, x-5:x+6] non_zero = region[region > 0] if len(non_zero) > 20: distances.append(np.median(non_zero)) return np.mean(distances)

之所以用中值而不是均值,是因为视差图边缘像素容易被 SGBM 误匹配,均值会被离群点带偏。如果中值结果与真实距离偏差超过 5%,优先检查基线标定是否准确,其次是 numDisparities 是否覆盖了最近的物体。

再提醒一个容易忽略的问题:双目标定的平移向量 T 有三个分量,stereoCalibrate 输出的 T[0] 才是基线水平距离,T[1] 是竖直方向偏移,T[2] 是前后深度差。理想的双目系统中 T[1] 和 T[2] 应接近零,如果不为零,说明两个摄像头安装得存在明显的高度差或前后错位,这种情况下的极线校正会有残余误差。

5. 把测距精度从“能跑”调到“能交差”

5.1 精度验证矩阵与误差标定

做毕业设计或课程设计,答辩时最怕被问“你的测距精度究竟是多少”。一个能撑住场面的做法是建立一组距离-误差对照表:在 0.5m、1.0m、1.5m、2.0m、2.5m 五个位置分别放置标定板,每个位置测 20 次取均值,记录误差。如果误差随距离呈线性增长,说明视差匹配精度稳定;如果误差在某个距离突然跳变,通常是对应位置的视差搜索范围不足或光照条件变化导致误匹配。

误差公式建议用相对误差,即abs(实测 - 真值) / 真值 × 100%。实测中 1 米以内相对误差做到 2%~3% 是合理水平,超过 5% 需要回头查标定数据而不是盲目调 SGBM 参数——外参不准导致的误差在远距离处会被平方放大。

5.2 三个容易翻车的边界场景

第一个场景是反光表面。白墙、瓷砖、玻璃这类低纹理区域,SGBM 靠邻域灰度梯度计算匹配代价,反光会让左右图亮度不一致,代价函数失效。处理办法是采集时用漫射光源避免强反光,或者在预处理阶段做直方图匹配均衡左右图的亮度分布。

第二个场景是近距离死区。当物体离相机太近时,视差超过 numDisparities 设定范围,匹配结果直接失效。设定 numDisparities 前,先用min_depth = f * B / numDisparities估算最近可测距离,再留 20% 余量。

第三个场景是温度漂移。摄像头模组在长时间运行后会轻微热胀冷缩,内外参缓慢变化。1 小时以上的连续测距实验,建议在开始和结束时各标定一次,观察重投影误差漂移。如果漂移显著,说明摄像头固定结构不够刚性,需要用金属支架加胶固定,而不是塑料支架。

5.3 性能优化与导出

实时测距场景下,SGBM 的耗时主要在代价聚合阶段,分辨率越高越明显。常见的优化手段有两个:一是用 stereoRectify 后裁剪图像有效区域,丢弃黑色边框,减少计算量;二是预处理阶段用cv2.pyrDown降采样,视差图恢复尺寸时乘以缩放因子。对 640×480 的分辨率,SGBM 在 CPU 上约能跑 15~25 fps,足够做静态或慢速物体的测距演示。

代码骨架搭好后,建议把所有标定参数保存为 JSON 或 YAML,并附带标定日期和重投影误差。这样论文实验部分可以直接引用参数表,不必重新跑标定流程:

# 运行顺序 python capture_stereo.py # 采集标定图像 python stereo_calibrate.py # 双目标定 python rectify.py # 生成校正映射 python stereo_sgbm.py # 计算视差图 python depth_map.py # 输出深度图

最后说一个写论文时常被忽略的细节:双目测距的精度评估必须与被测物体表面特性一起描述。同样的算法在标定板上的误差 2%,在毛绒玩具上可能劣化到 6% 以上,这不是算法变了,而是物体表面反射特性影响了匹配代价的可靠性。把这一节写进论文的误差分析中,比堆砌公式更能体现对问题的理解深度。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询