简介:本资源面向计算机、自动化等专业的本科毕业生及课程设计学习者,提供一套基于双目立体视觉的图像匹配与测距完整项目源码与配套资料,可直接用于毕业设计、期末大作业或课程设计场景。压缩包共164个文件,约96.22MB,包含38个Python源码文件、10个UI界面文件、49张bmp与23张jpg图像样本、13份caj文献、5份pdf与2份docx文档,以及qrc、qss、xml等配置资源,覆盖算法实现、界面搭建与测试数据。项目代码注释详尽,新手也能理解双目校正、立体匹配与深度测距的核心流程,部署后即可运行。已有69人学习关注,适合希望快速获得可运行高分项目、节省选题与调试时间的同学参考借鉴。
1. 双目立体视觉测距到底难在哪:从一张毕业设计标题说起
很多人第一次看到「Python 基于双目立体视觉的图像匹配与测距」这个题目,第一反应是去搜现成源码,下载下来跑一遍,发现能出深度图,就以为搞定了。真正动手做毕业设计或者工程落地时才会发现,能跑通 demo 和能交付一套可复现、参数可调、误差可解释的测距系统,中间隔着好几道坎。双目测距的核心链路其实就四步:双目标定、立体校正、图像匹配、视差转深度。每一步都有大量参数和边界条件,任何一步出问题,最后的距离值就会离谱到没法用。
这个方向适合两类人:一类是正在做毕业设计、课程设计的学生,需要一套完整可复现的 Python 方案;另一类是想把双目测距用到机器人避障、体积测量、工业检测里的工程师,需要知道参数怎么调、误差怎么控。本文不讲空泛概念,直接从标定板怎么拍、匹配算法怎么选、视差图怎么过滤、测距公式怎么推导,一路写到踩坑记录和精度验证方法。读完你至少能自己搭出一套能测 0.3 米到 5 米、误差可控的双目测距流程。
2. 双目标定与立体校正:把两个摄像头变成一把尺子
2.1 为什么标定是测距精度的天花板
双目测距的底层原理是三角测量:同一个物点在左右两个相机里成像位置不同,这个位置差叫视差,视差越大物体越近。公式很简单,Z = f * B / d,其中 f 是焦距(像素单位),B 是两个相机光心的基线距离,d 是视差。问题在于,这个公式成立的前提是两个相机光轴严格平行、成像平面严格共面。真实摄像头装上去一定有偏差,镜头还有畸变,所以必须通过标定把内参、外参、畸变系数全部算出来,再用立体校正把左右图像重投影到同一个理想平面上。
标定精度直接决定测距精度。我见过太多人用手机随便拍十几张棋盘格就标定,结果重投影误差 1.5 像素以上,测 1 米外的物体误差能到十几厘米。常见做法是:棋盘格标定板打印在 A3 纸上贴在硬板上,用双目相机同时采集 20 到 30 组不同角度、不同距离的图像,覆盖画面四个角和中心区域。OpenCV 的 stereoCalibrate 会输出重投影误差,这个值最好控制在 0.3 像素以内,超过 0.5 像素就要重新采集。
2.2 用 OpenCV 跑通双目标定的完整代码
下面这段代码是标定流程的核心,输入是左右相机各自拍的棋盘格图像文件夹,输出是内参矩阵、畸变系数、旋转和平移矩阵。
import cv2 import numpy as np import glob # 棋盘格内角点数量,比如 9x6 的棋盘格就写 (9, 6) CHESSBOARD = (9, 6) # 棋盘格方格实际物理尺寸,单位毫米 SQUARE_SIZE = 25.0 # 生成棋盘格三维坐标点 objp = np.zeros((CHESSBOARD[0] * CHESSBOARD[1], 3), np.float32) objp[:, :2] = np.mgrid[0:CHESSBOARD[0], 0:CHESSBOARD[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 lpath, rpath in zip(left_images, right_images): img_l = cv2.imread(lpath) img_r = cv2.imread(rpath) 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, None) ret_r, corners_r = cv2.findChessboardCorners(gray_r, CHESSBOARD, 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], flags=cv2.CALIB_FIX_INTRINSIC, criteria=(cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5) ) print('重投影误差:', ret) print('基线距离(mm):', np.linalg.norm(T)) np.savez('stereo_params.npz', mtx_l=mtx_l, dist_l=dist_l, mtx_r=mtx_r, dist_r=dist_r, R=R, T=T)这段代码的逻辑是先用单目标定拿到每个相机的初始内参,再把这些内参作为初值传给 stereoCalibrate,并用 CALIB_FIX_INTRINSIC 固定内参只优化外参。参数上,CHESSBOARD 必须和实际棋盘格内角点数一致,注意是内角点不是方格数;SQUARE_SIZE 单位要和后续测距单位统一,用毫米就全程毫米。cornerSubPix 的窗口 (11,11) 是常用值,图像分辨率高可以适当加大。重投影误差 ret 如果超过 0.5,优先检查棋盘格是否平整、图像是否有运动模糊、左右图是否严格同步采集。
2.3 立体校正:让左右图像行对齐
标定完之后要做 stereoRectify 和 initUndistortRectifyMap,把左右图像校正成行对齐。校正后同一个物点在左右图中应该在同一行,这样后续匹配只需要在同一行搜索,速度和准确率都会大幅提升。
import cv2 import numpy as np data = np.load('stereo_params.npz') mtx_l, dist_l = data['mtx_l'], data['dist_l'] mtx_r, dist_r = data['mtx_r'], data['dist_r'] R, T = data['R'], data['T'] img_l = cv2.imread('left/001.png') img_r = cv2.imread('right/001.png') h, w = img_l.shape[:2] # 立体校正,alpha=0 表示裁剪掉无效区域,alpha=1 保留全部像素 R1, R2, P1, P2, Q, roi1, roi2 = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, (w, h), R, T, alpha=0 ) # 生成映射表 map1_l, map2_l = cv2.initUndistortRectifyMap(mtx_l, dist_l, R1, P1, (w, h), cv2.CV_16SC2) map1_r, map2_r = cv2.initUndistortRectifyMap(mtx_r, dist_r, R2, P2, (w, h), cv2.CV_16SC2) rect_l = cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR) rect_r = cv2.remap(img_r, map1_r, map2_r, cv2.INTER_LINEAR) cv2.imwrite('rect_left.png', rect_l) cv2.imwrite('rect_right.png', rect_r) np.save('Q.npy', Q)Q 矩阵是后面视差转三维坐标的关键,必须保存。alpha 参数控制校正后图像是否裁剪,做测距建议用 alpha=0,虽然会损失边缘像素,但能保证所有像素都有效。校正完一定要肉眼检查:在左右图上画几条水平线,看同一个物体是否在同一行上,如果偏差超过 1 到 2 像素,说明标定有问题,回去重新标。
3. 图像匹配算法选型:SGBM、BM 和深度学习怎么选
3.1 传统匹配算法的参数逻辑
OpenCV 里最常用的两种立体匹配算法是 StereoBM 和 StereoSGBM。BM 速度快但视差图噪声大,SGBM 半全局匹配精度高很多,是毕业设计和工程落地的默认选择。SGBM 的核心参数有这几个:minDisparity 最小视差、numDisparities 视差搜索范围、blockSize 匹配块大小、P1 和 P2 平滑惩罚系数、uniquenessRatio 唯一性比率、speckleWindowSize 和 speckleRange 斑点过滤。
numDisparities 必须能被 16 整除,它决定了能测多近的物体。视差范围越大,能测的最近距离越小,但计算量也越大。blockSize 一般取 5 到 11 之间的奇数,太小噪声多,太大边缘模糊。P1 和 P2 控制视差平滑程度,常见经验值是 P1 = 8 * channels * blockSize^2,P2 = 32 * channels * blockSize^2。
3.2 SGBM 完整实现与参数调优
import cv2 import numpy as np rect_l = cv2.imread('rect_left.png', cv2.IMREAD_GRAYSCALE) rect_r = cv2.imread('rect_right.png', cv2.IMREAD_GRAYSCALE) # SGBM 参数配置 num_disparities = 16 * 6 # 视差范围,必须是 16 的倍数 block_size = 5 # 匹配块大小,奇数 stereo = cv2.StereoSGBM_create( minDisparity=0, numDisparities=num_disparities, blockSize=block_size, P1=8 * 1 * block_size * block_size, P2=32 * 1 * block_size * block_size, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=2, preFilterCap=63, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY ) disparity = stereo.compute(rect_l, rect_r).astype(np.float32) / 16.0 # 归一化显示 disp_vis = cv2.normalize(disparity, None, 0, 255, cv2.NORM_MINMAX) disp_vis = np.uint8(disp_vis) cv2.imwrite('disparity.png', disp_vis) np.save('disparity.npy', disparity)compute 返回的是定点数,除以 16 才是真实视差值。uniquenessRatio 设 10 表示最优匹配代价必须比次优好 10% 才接受,能有效过滤误匹配。speckleWindowSize 设 100 表示小于 100 像素的连通区域会被当作噪声滤掉,如果你的物体本身很小,这个值要调小。disp12MaxDiff 设 1 表示左右一致性检查容差,设 0 最严格但会产生更多空洞。
3.3 深度学习匹配的适用边界
近几年基于深度学习的立体匹配网络(如 PSMNet、RAFT-Stereo)在精度上确实超过传统方法,尤其是弱纹理区域和反光表面。但毕业设计场景下,我不建议一上来就上深度学习,原因有三:一是需要 GPU 和大量训练数据,二是推理速度慢,三是调参和部署复杂度高。如果你的场景是室内、纹理丰富、实时性要求不高,SGBM 完全够用。只有在传统方法明显失效、且你有 GPU 资源的情况下,才考虑用预训练模型做迁移。
4. 视差转深度与测距实现:从像素差到真实距离
4.1 用 Q 矩阵把视差图变成三维点云
立体校正时保存的 Q 矩阵可以直接把视差图重投影成三维点云。OpenCV 的 reprojectImageTo3D 就是干这个的,输出每个像素对应的 (X, Y, Z) 坐标,Z 就是深度值。
import cv2 import numpy as np disparity = np.load('disparity.npy') Q = np.load('Q.npy') # 视差图中无效值处理,小于等于 0 的视差设为 -1 disparity[disparity <= 0] = -1 points_3d = cv2.reprojectImageTo3D(disparity, Q) # 取图像中心区域的平均深度作为测距结果 h, w = disparity.shape cx, cy = w // 2, h // 2 roi = points_3d[cy-20:cy+20, cx-20:cx+20, 2] valid = roi[np.isfinite(roi) & (roi > 0)] if len(valid) > 0: distance = np.median(valid) print('中心区域距离(mm):', distance)这里用中位数而不是平均值,是因为视差图边缘容易有异常值,中位数更鲁棒。取中心 40x40 区域是为了避免边缘噪声影响。如果你的目标是测某个特定物体,应该先用颜色或轮廓分割出目标区域,再对该区域的深度值取中位数。
4.2 测距公式的手动推导与验证
不想用 Q 矩阵的话,也可以手动算:Z = f * B / d。f 可以从 P1 矩阵里取 P1[0,0],B 是基线距离(从 T 向量取模),d 是该点的视差值。这两种方法结果应该一致,如果不一致,说明 Q 矩阵或 P1 有问题。
# 手动计算验证 f = P1[0, 0] # 校正后的焦距 B = np.linalg.norm(T) # 基线距离 d = disparity[cy, cx] # 中心点视差 if d > 0: Z_manual = f * B / d print('手动计算距离(mm):', Z_manual)验证方法:把标定板放在已知距离(比如 1000mm)处拍一组图,跑完整流程,看输出距离和真实距离差多少。如果误差在 1% 到 3% 以内,说明整套流程没问题。如果误差超过 5%,优先检查标定重投影误差和基线距离是否准确。
4.3 视差图后处理:空洞填充与噪声过滤
原始 SGBM 视差图一定会有空洞和噪声,直接拿去测距会跳变。常见后处理有三步:左右一致性检查、空洞填充、中值滤波。左右一致性检查是把右图当参考再算一次视差,两次视差差异超过阈值的像素标记为无效。空洞填充可以用最近邻有效视差填充,或者用形态学闭运算。中值滤波用 cv2.medianBlur,窗口 5 或 7,能去掉大部分椒盐噪声。
# 中值滤波去噪 disp_filtered = cv2.medianBlur(disparity.astype(np.float32), 5) # 空洞填充:用形态学闭运算 kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) disp_filled = cv2.morphologyEx(disp_filtered, cv2.MORPH_CLOSE, kernel)注意中值滤波窗口不能太大,否则会把真实的深度边缘也模糊掉。如果你的场景里物体边缘很重要,建议先做左右一致性检查,再做小窗口中值滤波。
5. 避坑与排查:双目测距翻车实录
5.1 重投影误差正常但测距就是不准
现象:标定重投影误差 0.2 像素,看起来很好,但测距误差超过 10%。原因通常是基线距离 T 的测量或计算有问题。T 是 stereoCalibrate 输出的平移向量,它的模就是基线。如果标定时棋盘格没有同时出现在左右相机视野里,或者左右图像没有严格同步,T 就会偏。解决:重新采集数据,确保每组图像左右相机都能完整看到棋盘格,且采集时相机和标定板都静止。
5.2 视差图大面积黑色空洞
现象:SGBM 输出的视差图大部分是黑色,只有零星几个点有值。原因一般是 numDisparities 设置太小,或者图像纹理太弱。解决:先增大 numDisparities 到 168 甚至 1610,再看效果。如果还是不行,检查校正后的图像是否真的行对齐,可以在左右图上画水平线对比。弱纹理场景可以加投影纹理或者用深度学习匹配。
5.3 测距值随距离跳变严重
现象:同一个物体静止不动,连续测十次,距离值在几十厘米范围内跳。原因是视差图噪声大,中心区域取了太多无效值。解决:先做左右一致性检查和空洞填充,再取目标区域的中位数。如果还跳,增大 blockSize 到 7 或 9,提高 uniquenessRatio 到 15,牺牲一些细节换取稳定性。
5.4 近距离测不了,远距离也不准
现象:0.5 米以内测不出来,3 米以外误差巨大。原因是视差范围和基线距离的物理限制。numDisparities 决定了最小可测距离,基线 B 决定了远距离精度。解决:如果要测近,增大 numDisparities;如果要测远,增大基线 B。但基线增大后近处盲区也会变大,需要根据实际场景权衡。常见做法是基线取 60mm 到 120mm,覆盖 0.3 米到 5 米范围。
5.5 左右图像校正后行不对齐
现象:校正后左右图同一物体不在同一行,偏差好几像素。原因是标定参数不准,或者 stereoRectify 的 alpha 参数和 initUndistortRectifyMap 不匹配。解决:重新标定,确保重投影误差小于 0.3。检查 stereoRectify 和 initUndistortRectifyMap 是否用了同一组 R1、R2、P1、P2。校正后一定要画水平线肉眼验证,不要只看代码跑通。
6. 精度验证与进阶技巧:把误差压到 1% 以内
6.1 用已知距离标定板做端到端验证
整套流程跑通后,必须做端到端精度验证。我的习惯是:把棋盘格放在 500mm、1000mm、1500mm、2000mm、3000mm 五个距离,每个距离拍 10 组图,跑完整流程,记录测量值和真实值的偏差。下面是一个批量验证的脚本框架。
import cv2 import numpy as np import glob def measure_distance(left_path, right_path, params_path='stereo_params.npz', Q_path='Q.npy'): data = np.load(params_path) Q = np.load(Q_path) mtx_l, dist_l = data['mtx_l'], data['dist_l'] mtx_r, dist_r = data['mtx_r'], data['dist_r'] R, T = data['R'], data['T'] img_l = cv2.imread(left_path) img_r = cv2.imread(right_path) h, w = img_l.shape[:2] R1, R2, P1, P2, _, _, _ = cv2.stereoRectify( mtx_l, dist_l, mtx_r, dist_r, (w, h), R, T, alpha=0) map1_l, map2_l = cv2.initUndistortRectifyMap(mtx_l, dist_l, R1, P1, (w, h), cv2.CV_16SC2) map1_r, map2_r = cv2.initUndistortRectifyMap(mtx_r, dist_r, R2, P2, (w, h), cv2.CV_16SC2) rect_l = cv2.remap(img_l, map1_l, map2_l, cv2.INTER_LINEAR) rect_r = cv2.remap(img_r, map1_r, map2_r, cv2.INTER_LINEAR) gray_l = cv2.cvtColor(rect_l, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(rect_r, cv2.COLOR_BGR2GRAY) stereo = cv2.StereoSGBM_create( minDisparity=0, numDisparities=16*6, blockSize=5, P1=8*1*25, P2=32*1*25, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=2, preFilterCap=63, mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY) disp = stereo.compute(gray_l, gray_r).astype(np.float32) / 16.0 disp[disp <= 0] = -1 points_3d = cv2.reprojectImageTo3D(disp, Q) cx, cy = w // 2, h // 2 roi = points_3d[cy-20:cy+20, cx-20:cx+20, 2] valid = roi[np.isfinite(roi) & (roi > 0)] return np.median(valid) if len(valid) > 0 else -1 # 批量验证 ground_truth = {'500': 500, '1000': 1000, '1500': 1500, '2000': 2000, '3000': 3000} for tag, gt in ground_truth.items(): errors = [] for lpath in sorted(glob.glob(f'val_{tag}/left/*.png')): rpath = lpath.replace('left', 'right') d = measure_distance(lpath, rpath) if d > 0: errors.append(abs(d - gt) / gt * 100) if errors: print(f'真实距离 {gt}mm,平均误差 {np.mean(errors):.2f}%,最大误差 {np.max(errors):.2f}%')这个脚本把测量函数封装好,对每个距离批量跑,输出平均误差和最大误差。如果某个距离误差特别大,单独看那个距离的视差图,大概率是视差范围不够或者纹理太弱。
6.2 三个把误差压到 1% 以内的技巧
第一个技巧是亚像素级视差优化。SGBM 输出的是整数视差,精度有限。可以用 cv2.filterSpeckles 配合二次曲线拟合做亚像素插值,把视差精度提到 0.1 像素级别,测距精度能提升一个档次。第二个技巧是温度补偿。相机长时间工作会发热,镜头和传感器热胀冷缩会导致基线微变,远距离测量时这个误差不可忽略。我的习惯是开机预热 10 分钟再标定和测量。第三个技巧是分区域标定。如果测量范围跨度大,可以在近处和远处分别标定一组参数,测量时根据粗测距离切换参数组,能显著降低全量程误差。
6.3 从毕业设计到工程落地的最后一步
毕业设计做到能出视差图、能测几个距离,基本就够了。但如果要往工程落地走,还要考虑实时性、鲁棒性和标定自动化。实时性方面,SGBM 在 640x480 分辨率下能跑到 20 到 30 帧,再大就要降采样或换 BM。鲁棒性方面,强光、反光、弱纹理场景要有降级策略,比如切换到主动双目或者加结构光。标定自动化方面,可以做一个一键标定脚本,自动采集、自动筛选、自动输出报告,减少人工干预。
我自己踩过最深的坑是:标定的时候图省事,用手机拍了几张棋盘格就标,结果测距误差一直下不来,查了两天才发现是标定板不平整导致角点检测有系统偏差。后来老老实实打印 A3 贴硬板,重投影误差从 0.8 降到 0.2,测距误差直接从 8% 降到 1.5%。所以如果你现在测距不准,先回去看标定,别急着调匹配参数。希望帮到你。
本文还有配套的精品资源,点击获取