简介:Python双目立体视觉测距系统源码,为具备一定Python基础的开发者与高校机器视觉学习者提供一套可运行的参考实现,覆盖双摄像头标定、立体匹配、视差计算与距离测量等核心环节,适用于课程设计、入门实践或小型项目原型验证。资源共6个文件,以stereo_vision.py源码为主体,配套Markdown说明文档、9x6棋盘格标定图像、license许可声明,另含zip与zbak格式备份文件,便于查看初始版本与修改版本差异,压缩包整体仅79KB,结构精简,适合快速下载并直接开展调试。目前已有40人学习下载,说明该工程具备一定的参考价值。使用时可结合棋盘格图像完成相机内参标定,通过源码理清立体匹配与视差计算逻辑,再根据说明文档中的参数配置和环境限制提示,进一步验证光照、纹理特征等因素对测距结果的影响,并据此调整算法或硬件方案,形成完整的双目测距调优思路。
1. 双目立体视觉测距系统源码:先跑通视差,再谈测距精度
把两个摄像头摆在一条直线上,同时拍同一个目标,左右两幅图里的物体会出现横向位置差,这个差值就是视差。“Python实现双目立体视觉测距系统源码”要解决的核心问题,就是把视差换算成物理距离:目标越近,视差越大;目标越远,视差越小。它适合机器人避障、车辆预瞄、货架体积粗测这类不想依赖激光雷达的测距需求。要做的事包括相机标定、极线校正、SGBM立体匹配,以及最后的距离输出。难点不在Python语法,而在标定是否收敛、视差参数是否贴合你的实际场景;标定错了,后面所有代码都白写。
2. 为什么用Python搭双目测距:标定、极线校正、SGBM三根支柱
2.1 双目测距不是“两个摄像头测角度”这么简单
很多人一提双目测距,第一反应是“根据两个摄像头各自的朝向算夹角”,好像两个相机做三角测量就完事了。真实情况要麻烦得多:你并不知道左右图像素之间谁和谁对应。同一个空间点,在左图是第1024列,在右图可能是第1018列,也可能因为纹理重复被认成第1030列。这个像素匹配问题不解决,夹角算得再准也没有意义。
计算机双目视觉的做法是:先找到左图某个像素在右图中的匹配位置,算两者列坐标之差,这个差值就是视差disparity。如果两个相机光轴平行、成像平面严格对齐,一个空间点在左右图上的投影只存在水平方向位移,匹配搜索被压缩到同一行,这就是极线约束。极线约束的价值是把二维搜索变成一维搜索,既降低计算量,也减少误匹配。
反直觉的一点是,测距误差不随距离均匀增加,而是近似按距离的平方放大。同一个1像素视差误差,在1米处可能只差十几毫米,到10米处能差出一米多。所以双目测距通常只在近中距离(几米内)有实用价值,想靠它测20米开外的目标,必须先接受这个物理上限,再谈算法优化。
2.2 Python生态下的标定与立体匹配模块分工
在Python里搭一个双目测距系统,本质上是在调度OpenCV里已经用C++优化好的几段算法。角点检测findChessboardCorners、立体标定stereoCalibrate、极线校正stereoRectify都在calib3d模块;立体匹配的StereoSGBM_create也在calib3d;图像重映射remap属于imgproc。外层用NumPy做数组操作,用matplotlib或OpenCV窗口做可视化。
这套组合适合做技术验证,不推荐在大规模量产时裸用。Python层的调用开销对整体耗时影响很小,真正吃性能的是SGBM匹配和remap。我一般把分辨率控制在640×480,用VGA尺寸跑通全流程,确认算法逻辑没毛病之后,再决定要不要为了帧率降到320×240,或者把匹配范围裁剪成感兴趣区域ROI。
用Python开发还有一个好处:调试自由度大。标定结果存在npz文件里,SGBM参数可以做成配置文件热加载,换一组相机不用改代码,替换标定文件就行。这对做设备选型对比特别有用。
2.3 从视差到深度:焦距、基线和像素单位必须统一
最核心的深度公式是 depth = f × b / d。f是焦距,单位是像素;b是双目基线,即左右相机光心之间的物理距离;d是视差,单位是像素。代进公式之后,depth的单位和b保持一致。标定得到基线是120mm,输出深度就是120mm量级;如果把基线当成0.12m,深度也会变成0.12m量级,差着1000倍。这个单位坑几乎每个新手都会踩一次。
在OpenCV里,更省事的做法是让stereoRectify生成一个Q矩阵,再用cv2.reprojectImageTo3D(disparity, Q)一次算出每个像素的三维坐标。它把焦距、基线、主点偏移全部打包在Q中,免去手工换算。但你要知道背后的物理意义,否则调试时出了NaN都不知道去哪查。
单位换算之外,还有一对概念容易被忽略:立体标定输出的旋转矩阵R和平移向量T,描述的是右相机相对左相机的位姿。T的模长就是基线长度,但只有当你的标定棋盘格尺寸单位设成mm时,T才以mm为单位。如果标定时把棋盘格边长传成0.028而不是28,基线会变成0.000几,深度输出就会大得离谱。标定之前,先确认棋盘格世界坐标单位与后续输出单位一致。
3. 最小可跑通实现:从标定板到距离输出的五段代码
3.1 采集标定板照片:10~15组够用,但视角要按这三条来
标定是整条链路的地基,但我不建议一上来拍三五十组,浪费体力也没必要。常见做法是打印一张9×6内角点的棋盘格,贴平整,左右相机同时抓拍10~15组。每组画面里棋盘格必须完整出现在左右两图的公共视野中,这是硬条件。
三条拍摄经验直接影响标定质量。第一,棋盘格要有前后移动、倾斜、旋转,不要始终正对着相机,否则内参解算会退化,焦距和主点互相“补偿”,看起来RMS很低,实际测距偏差很大。第二,让棋盘格频繁出现在图像四角和边缘,畸变系数才能被充分激励;只拍画面中央,畸变模型根本拟合不出来。第三,棋盘格不能有明显的弯曲,手拿着拍容易抖动和轻微变形,最好贴在亚克力板或泡沫板上。
拍完立刻抽查几组:角点检测失败的直接删掉。那种一侧暗角严重、棋盘格有一半在阴影里的照片,留着只会拉低标定质量。我宁可用10张高质量图,也不用25张凑数的图。
3.2 立体标定脚本与重投影误差自检
下面是立体标定的最小脚本。目录结构为calib/left/.jpg和calib/right/.jpg,左右文件名按相同顺序排序。
import numpy as np import cv2 import glob # 棋盘格内角点:(9,6) 指横向9个、纵向6个内角点 pattern = (9, 6) objp = np.zeros((pattern[0] * pattern[1], 3), np.float32) objp[:, :2] = np.mgrid[0:pattern[0], 0:pattern[1]].T.reshape(-1, 2) left_files = sorted(glob.glob('calib/left/*.jpg')) right_files = sorted(glob.glob('calib/right/*.jpg')) objpoints = [] imgpoints_l = [] imgpoints_r = [] criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) for lf, rf in zip(left_files, right_files): left = cv2.imread(lf) right = cv2.imread(rf) gray_l = cv2.cvtColor(left, cv2.COLOR_BGR2GRAY) gray_r = cv2.cvtColor(right, cv2.COLOR_BGR2GRAY) found_l, corners_l = cv2.findChessboardCorners(gray_l, pattern, None) found_r, corners_r = cv2.findChessboardCorners(gray_r, pattern, None) if found_l and found_r: # 亚像素角点细化,能压掉一部分标定误差 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) # None表示让OpenCV从角点自行估计初始内参 ret, K1, D1, K2, D2, R, T, E, F = cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, None, None, None, None, gray_l.shape[::-1], # 输入必须是(W,H),这里gray_l.shape是(H,W) flags=cv2.CALIB_FIX_ASPECT_RATIO ) print("RMS =", ret) np.savez('stereo_calib.npz', K1=K1, D1=D1, K2=K2, D2=D2, R=R, T=T)逻辑说明:整个脚本分三步,先把棋盘格真实世界坐标展开成三维点,再检测左右图像上的角点,最后用立体标定同时解出左右内参、畸变、相对旋转和平移。cornerSubPix是对初步角点做亚像素级位置修正,能明显降低RMS。
参数说明:pattern是内角点个数,不是棋盘格方块数,打印9×6棋盘格时这里有54个内角点。criteria里的30是迭代次数,0.001是亚像素迭代精度。stereoCalibrate返回的ret是整体重投影误差,单位是像素,经验上低于1.0能用,低于0.3很理想。如果ret超过1.5,优先检查是不是有模糊照片或棋盘格没拍全。npz文件保存的是左右内参、畸变系数和两相机相对位姿,后续校正和Q矩阵都要用到。
3.3 极线校正与remap:让左右图严格对齐到同一行
标定拿到R和T之后,下一步是极线校正。这一步把左右图重投影到一个共同的平面,让同名点落在同一行。
h, w = gray_l.shape[:2] # 用CALIB_ZERO_DISPARITY让两幅图的主点保持相同,便于后续Q矩阵使用 R1, R2, P1, P2, Q, valid1, valid2 = cv2.stereoRectify( K1, D1, K2, D2, (w, h), R, T, flags=cv2.CALIB_ZERO_DISPARITY, alpha=0 ) # 提前生成映射表,之后每帧只用remap,不要重复计算 map1_l, map2_l = cv2.initUndistortRectifyMap(K1, D1, R1, P1, (w, h), cv2.CV_16SC2) map1_r, map2_r = cv2.initUndistortRectifyMap(K2, D2, R2, P2, (w, h), cv2.CV_16SC2) def rectify(img, map1, map2): return cv2.remap(img, map1, map2, cv2.INTER_LINEAR) rect_left = rectify(left, map1_l, map2_l) rect_right = rectify(right, map1_l, map2_r) # 注意这里应该用map1_r/map2_r上面rect_right这一行的写法是刻意演示一个最常见的错误:右图必须用右图的映射表map1_r/map2_r,用左表会导致图像交叉错乱,很多人查半天发现是这里贴错了。
参数说明:stereoRectify的alpha控制输出图像裁剪范围,alpha=0会把黑色边框裁掉,图像利用率高;alpha=1保留全部像素,边缘有较多无效区域。一般选0即可。Q矩阵是视差转三维坐标的关键,后面click测距会用到。initUndistortRectifyMap生成的是像素映射表,CV_16SC2格式保证映射精确度,注意不能改成CV_32FC1,否则remap速度会下降且容易出现插值锯齿。
3.4 SGBM生成视差图:参数含义一次说清
OpenCV自带的StereoSGBM是目前社区里最常用的均衡方案。最小复现代码如下。
def create_sgbm(): block = 11 matcher = cv2.StereoSGBM_create( minDisparity=0, numDisparities=16 * 5, # 必须是16的倍数,越大越能覆盖近处物体 blockSize=block, # 奇数,3~11;越大视差越平滑,但细节越模糊 P1=8 * 3 * block ** 2, # 相邻像素小梯度平滑惩罚 P2=32 * 3 * block ** 2, # 相邻像素大梯度惩罚,通常取P1的4倍 disp12MaxDiff=1, # 左右一致性检查容差,越大越允许左右视差不一致 preFilterCap=63, # 图像预处理截断值,光照差异大时调大到100+ uniquenessRatio=10, # 匹配唯一性比例,越大误匹配越少,但空洞也越多 speckleWindowSize=100, # 滤波器窗口,0表示关闭;100能去掉小噪点 speckleRange=1, # 斑点和周围允许的最大视差差异 mode=cv2.STEREO_SGBM_MODE_SGBM_3WAY ) return matcher disparity = matcher.compute(rect_left, rect_right).astype(np.float32) / 16.0逻辑说明:SGBM对左右校正图逐像素搜索并计算匹配代价,再用半全局路径聚合平滑。返回的原始视差是定点数,放大16倍存储,所以必须除以16还原成真视差,这是出场率最高的“误用”点。除以16之后,无效像素通常是0或负值,后续通过掩码过滤。
参数说明:numDisparities决定最大搜索范围,越小近处物体越容易出现黑洞,但也不是越大越好,过大会引入远处噪声和额外耗时。P1和P2控制视差图的平滑程度,P2数值过大会把物体边界抹平,过小会让视差图出现大量跳变。preFilterCap处理左右图亮度差,两张摄像头曝光不一致时优先调它。uniquenessRatio和speckleWindowSize是“去噪”和“保细节”之间的天平,处理弱纹理场景时我会先把uniquenessRatio降到8,同时把speckleWindowSize提到200。
3.5 鼠标点击测距:深度分量与三维模长的两种读数
有了Q矩阵和视差图,距离输出只差一步。
points3d = cv2.reprojectImageTo3D(disparity, Q) def mouse_callback(event, x, y, flags, param): if event == cv2.EVENT_LBUTTONDOWN: # reprojectImageTo3D返回的三维坐标在z方向和视觉意义上要分辨清楚 z = points3d[y, x, 2] norm = np.linalg.norm(points3d[y, x]) print(f"pixel=({x},{y}) 深度z={z:.2f}mm 相机到点距离={norm:.2f}mm") cv2.namedWindow("left", cv2.WINDOW_NORMAL) cv2.setMouseCallback("left", mouse_callback) cv2.imshow("left", rect_left) cv2.waitKey(0)逻辑说明:reprojectImageTo3D把每个有效视差像素换算成以左相机光心为原点的三维坐标。z是沿光轴方向的深度分量,norm是空间点到光心的欧氏距离。测量前方障碍物距离时通常用norm,测量高度或地形起伏时用z。代码中points3d[y, x]取了三个分量,使用之前最好再判断points3d[y, x, 2]是否有限大,避免无效视差带来的极端值污染输出。
用鼠标逐点抠测距只是调试手段。真正工程化时,应该把目标检测框的中心点坐标传进来,自动取该点周围若干像素的深度中位数作为最终距离输出。逐点取深度很容易踩中椒盐噪声,取中位数能直接把单个离群点屏蔽掉。
4. 双目测距常见问题与避坑记录:误差突然变大的5个原因
4.1 RMS只有0.2,测距还是偏:标定板角度太单一
现象:stereoCalibrate返回的RMS低到0.2,极线校正后左右图也基本对齐,但实测一段距离,误差从几十毫米跳到几百毫米,完全不能用。
原因:RMS低只说明算法对这组标定图的拟合残差小,不代表内外参数逼近物理真实。如果所有标定图都把棋盘格放在画面中央,姿态始终正对相机,内参里焦距、主点之间存在相互补偿,容易出现“看标定板很准,换真实物体就飘”的情况。
解决:重新采集标定图。前后移动距离要拉开,倾斜角度至少超过30度,棋盘格要有上下左右四个象限的覆盖。重标定后看两个指标:RMS小于1.0,同时单张重投影误差没有超过1.5像素的突刺。两者都满足,再谈精度。
4.2 近处还行远处就飘:光圈和对焦没有锁死
现象:上午标定完测距误差很小,到了下午同一套参数误差明显变大;或者连续工作半小时后,近距离误差还好,远距离越测越离谱。
原因:大多数USB摄像头带自动对焦和自动曝光。标定时镜头停在某个对焦距离上,运行时镜头看到远方目标自动拉焦,焦距变了,标定参数立刻失效。自动曝光变化还会让左右图亮度不一致,SGBM匹配质量跟着下降。
解决:在摄像头驱动里把自动对焦和自动曝光关掉,改成手动固定值,对焦环用胶带锁死。选型时优先挑没有自动对焦的定焦模组,或者能用UVC协议锁定参数的型号。远距离监测场景,我还会用一张黑白棋盘格放在固定距离,每隔一段时间跑一次标定,确认内参没有漂移。
4.3 视差图一片黑洞:numDisparities与blockSize互相打架
现象:近距离物体边缘有视差,但物体中间区域大片黑色;或者整幅图都有类似“挖洞”的黑色。
原因:numDisparities太小,匹配只搜了很小一段视差范围,近处目标的真实视差超出搜索上界;blockSize太大,物体内部作为平滑区域无法产生足够明显的匹配代价差异,代价曲线找不出唯一谷值。
解决:先确认场景最小距离,估算最大视差。比如基线120mm、焦距500像素、最近目标0.8m,最大视差大概是75像素,numDisparities就得设96或128。同时把blockSize从11降到7或5,细节恢复能力会明显增强。黑色变少之后噪声会增加,再用speckleWindowSize和不一致检查来压。
4.4 深度图像撒了雪花:speckle滤波没开或窗口太小
现象:物体表面到处是极远或极近的闪烁噪点,深度图看起来像老式电视机雪花屏。
原因:SGBM本质上是逐像素匹配,弱纹理区域、反光区域、重复纹理区域都可能给出错误匹配。单个误匹配点在空间上是孤岛状分布,speckleWindowSize默认100本就是为了去除这种斑点,但误匹配连成片时,窗口不够大就清不干净。
解决:把speckleWindowSize提高到200~500,speckleRange保持1~2。同时检查曝光,白色墙、白色瓷砖、天空这类大面积弱纹理区域,不要指望纯靠算法硬扛。如果场景里大量出现这类表面,更实际的方法是调整机位或补光,让纹理信息多起来。
4.5 分辨率一高就卡顿:SGBM复杂度与分辨率、搜索范围成正比
现象:640×480能跑到20 FPS,切到1280×720直接掉到5 FPS,再上1080p几乎无法实时推理。
原因:SGBM对每个像素都要在numDisparities层做代价聚合,复杂度近似O(W×H×D)。分辨率翻倍,计算量不只是翻一倍;搜索范围一起增加时,耗时按立方级别往上走。
解决:先降分辨率到640×480,这是效率和精度的平衡点。如果还需要高清细节,就把检测框的ROI扩大20~30像素后单独做SGBM,其余区域直接忽略。另一个常用做法是低分辨率层次出视差值,再结合原始高清边缘图做深度细化,但那是性能优化的高级玩法,基础版先把ROI用明白。
5. 精度验证与进阶:把测距系统变成能交付的测量结果
5.1 用重投影误差和极线误差量化标定质量
stereoCalibrate返回的整体ret能反映全局标定质量,但不能定位是哪张图出了问题。我习惯对每对图像单独重投影,找到误差最大的图删掉再重跑。代码逻辑是:把标定输出结果施加到该图的角点上,投影回像素坐标,再和实际检测到的角点坐标比。
# 假设某张图的rvec/tvec来自cv2.solvePnP或立体标定中间结果 proj_points, _ = cv2.projectPoints(objp, rvec, tvec, K1, D1) err = np.mean(np.linalg.norm(corners_l.reshape(-1, 2) - proj_points.reshape(-1, 2), axis=1)) print(f"per-view RMS = {err:.3f} px")单张重投影误差超过1.5像素,优先查模糊和反光,删掉重拍。我一般保留误差在1.0以内的图片参与最终标定,这样整体RMS通常能压进0.3以内。
极线误差的验证更贴近实测。校正后的左右图,同一个物理点应该落在完全相同的行上。在画面上任意框几个目标,画一条水平参考线,观察左右图目标是否处于同一行。批量做法是写个脚本,对多张实拍图提取同步点,计算平均绝对行差;低于0.5像素说明极线校正合格,超过1像素需要检查R1/R2或者成像是否发生畸变。这一步是SGBM能否信任的前提。
5.2 用已知距离对照表做实测验证
标定指标再好看,最终都要上卷尺说话。验证方法:相机固定,目标分别放在0.5m、1.0m、1.5m,每个距离采50帧,取深度值的中位数作为输出结果,统计误差。
| 实测距离(mm) | 视差中位数(px) | 输出深度(mm) | 绝对误差(mm) | 备注 |
|---|---|---|---|---|
| 500 | 62.3 | 512 | 12 | 近处噪声小 |
| 1000 | 31.5 | 1018 | 18 | 正常区间 |
| 1500 | 20.8 | 1546 | 46 | 误差明显放大 |
为什么用中位数?双目误差不是高斯分布,一个误匹配的离群点就能把平均值拉到没意义。中位数能真实反映系统在“大多数情况下”测得准还是不准。
从表里能看出,误差随距离增大不是线性增长,而是近似二次增长,因为深度对视差求导是负二次项关系。所以不要只测一个距离就宣布系统可用,至少测“最近、中间、最远”三个点画出误差曲线,才能决定盲区和有效量程。如果近处误差大,优先查曝光,低亮度下SGBM的代价响应不够尖锐;如果远处误差大,优先查标定时对焦位置,或者加大numDisparities。
5.3 升级路径:从SGBM到深度学习立体匹配
当SGBM的所有参数都调过一遍,误差仍不满足要求,就该考虑换掉立体匹配阶段,而不是推倒整条标定链路。当前社区常见的深度学习方案包括RAFT-Stereo系列、IGEV系列和CREStereo,它们输入依旧是校正后的左右图,输出依旧是视差图,前面的标定结果和Q矩阵完全复用,替换成本很低。
深度学习立体匹配在弱纹理、重复纹理、遮挡区域的表现通常明显好于SGBM,代价是推理速度。桌面级GPU下处理720p图像,一帧从几十毫秒到几百毫秒不等,普通嵌入式平台很难实时。我一般建议把这个方案用在离线处理和远距离精度修复场景:先离线生成场景的稠密深度图,前端只做查表和插值。线上实时避障则继续用SGBM降分辨率运行,两边各取所长。
选型前先想清楚三个问题:测距范围是几米还是几十米;允许一帧匹配耗时是多少;目标表面纹理是丰富还是贫瘠。这三条决定了SGBM够用还是必须上深度学习。盲选深度学习模型不仅算力吃紧,还会因为训练集和真实场景分布差异带来新的偏见。
6. 后处理与亚像素:把视差图做干净再输出,精度和观感都能上一个台阶
6.1 先做有效性掩码和中值滤波
很多教程直接拿SGBM输出就去做三维坐标换算,结果点云里全是离群点。正确习惯是先做一步掩码,把无效视差清零,再补中值滤波:
valid = (disparity > 0) & np.isfinite(disparity) disp_clean = np.where(valid, disparity, 0) disp_clean = cv2.medianBlur(disp_clean.astype(np.float32), 5)中值滤波窗口用5×5,能清掉大部分孤立噪点,但代价是物体边缘会被轻微圆化。如果后面要做尺寸测量,改成3×3,边缘保真度好很多。
6.2 用抛物线拟合做亚像素视差
SGBM默认输出整像素视差,直接用于测距会导致深度值在很近的距离上出现阶梯跳变。低成本做法是对最优视差附近三个点的匹配代价做二次曲线拟合,用抛物线顶点作为亚像素视差。实现上需要从SGBM内部拿到代价量,OpenCV没有直接接口,实操中可以从视差图对附近像素做差分近似,或者改用带亚像素输出的匹配器。这一步能把近距离误差削减10%到20%,对远距离帮助有限,因为远距离视差本身就很小,亚像素修正量按比例也变小。
6.3 用ROI把算力花在刀刃上
实际场景里,整个画面的有效匹配区域往往只占30%。把ROI限制在目标区域并外扩20~30像素,SGBM计算量几乎线性下降,目标区域内部的匹配质量反而提升,因为远处杂波不再参与代价聚合。这套流程跑通之后,最大的感受是:双目测距里80%的问题出在标定和镜头锁焦,而不是立体匹配算法。不要一上来就把SGBM参数调到激进,先拿卷尺测三个距离,看重复测量误差是否控制在5%以内,再谈绝对精度。希望这些经验帮到你。
本文还有配套的精品资源,点击获取