Python+OpenCV双目测距实战:标定、校正、SGBM与重投影全链路避坑指南 简介本资源面向计算机视觉初学者与课程设计开发者围绕双目立体视觉的图像匹配与测距展开基于维视MV-VS220测量平台用PythonOpenCV实现相机标定、图像采集、SIFT与SURF特征点提取匹配、视差深度计算及误差分析并给出可运行的原型系统便于演示各关键环节与比较不同算法性能。压缩包共213个文件约99.27MB包含38个py源码、10个ui界面文件、5个xml配置、5个pdf与13个caj文献、2个docx报告以及大量bmp、png、jpg、jpeg图像样本和dll依赖库覆盖从数据采集到结果展示的完整流程。已有1156人学习下载。读者可据此复现双目测距实验理解特征匹配与深度计算原理并借助现成脚本与界面快速搭建自己的课程设计或原型系统。1. 双目测距翻车三次后我把PythonOpenCV这条链路重新捋直了用 Python 加 OpenCV 做双目立体视觉测距听起来像是把两个摄像头拧上、跑一遍StereoBM就能出深度图的事。我第一次也是这么想的结果测出来的距离在 0.5 米到 3 米之间来回跳近处物体算出来比远处还远标定板一挪位置整张视差图直接花掉。后来把标定、校正、匹配、重投影这四步拆开逐段验证才发现问题从来不在cv2.StereoSGBM_create那几个参数上而在标定内参的畸变系数和极线校正的映射矩阵有没有真正对齐。这篇笔记讲的就是这条链路用 Python 和 OpenCV 把双目相机标定好、把左右图校正到同一极线、用立体匹配算出视差、再通过Q矩阵把视差反投影成三维坐标拿到 Z 值。适合手上已经有双目模组、想自己写测距代码而不是直接调深度相机 SDK 的开发者也适合做机器人避障、AGV 测距、体积测量这类需要自己掌控整条链路的场景。下面按我实际调试的顺序展开每一步都给出能直接跑的代码和参数含义坑也一并写清楚。2. 从两个摄像头到视差图标定、校正、匹配的完整链路2.1 为什么必须先标定再谈测距双目测距的数学基础是三角测量。左右相机光心之间有一条基线B同一个物点在左右图像上的横坐标差就是视差d深度Z f * B / d其中f是焦距像素单位。这个公式看着简单但它成立的前提是左右图像已经校正到极线水平对齐也就是同一个物点在左右图里只差水平方向的位移没有垂直方向的偏移。如果相机没标定好左右图存在旋转和垂直视差d就不准Z直接崩。标定要拿到的东西有两组每个相机的内参矩阵M和畸变系数dist以及右相机相对左相机的旋转R和平移T。OpenCV 里用cv2.calibrateCamera分别标左右再用cv2.stereoCalibrate联合优化。棋盘格是常用靶标常见做法是打印一张 9x6 或 11x8 的棋盘采集 15 到 25 组不同角度和距离的图像。少于 10 组基本别指望稳定我一般会拍到 20 组以上覆盖画面四个角和中心。import cv2 import numpy as np import glob # 棋盘格内角点数量注意是内角点不是格子数 CHESSBOARD (9, 6) # 棋盘格单个方格的实际物理尺寸单位毫米按你打印的尺寸改 SQUARE_SIZE 25.0 criteria (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 30, 0.001) # 生成棋盘格三维坐标Z0 平面 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 def calibrate_single(image_paths): objpoints [] imgpoints [] gray_shape None for fname in image_paths: img cv2.imread(fname) gray cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) gray_shape gray.shape[::-1] ret, corners cv2.findChessboardCorners(gray, CHESSBOARD, None) if ret: corners_refined cv2.cornerSubPix( gray, corners, (11, 11), (-1, -1), criteria) objpoints.append(objp) imgpoints.append(corners_refined) ret, M, dist, rvecs, tvecs cv2.calibrateCamera( objpoints, imgpoints, gray_shape, None, None) return ret, M, dist, rvecs, tvecs, objpoints, imgpoints这段代码里findChessboardCorners负责粗定位角点cornerSubPix做亚像素精化把角点精度从整像素提到 0.1 像素级别这一步对后面视差精度影响很大。calibrateCamera返回的重投影误差ret是关键指标正常应该小于 0.5 像素超过 1 像素说明采集的图像有问题通常是棋盘没铺平、运动模糊或者角度太偏。SQUARE_SIZE必须和你实际打印的方格尺寸一致单位随意但要和后面基线单位统一我一般统一用毫米。2.2 双目标定与极线校正把左右图拉到同一水平线单目标定完把两组objpoints和imgpoints喂给stereoCalibrate它会同时优化两个相机的内参和它们之间的外参。这里有个容易忽略的点stereoCalibrate的flags参数。如果你对自己的单目标定结果有信心可以传cv2.CALIB_FIX_INTRINSIC固定内参只算外参如果想让联合优化重新调内参就不传这个标志。我一般先用固定内参跑一遍看外参是否合理再放开重跑对比。def stereo_calibrate(objpoints, imgpoints_l, imgpoints_r, gray_shape, M1, d1, M2, d2): flags cv2.CALIB_FIX_INTRINSIC criteria_stereo (cv2.TERM_CRITERIA_EPS cv2.TERM_CRITERIA_MAX_ITER, 100, 1e-5) ret, M1, d1, M2, d2, R, T, E, F cv2.stereoCalibrate( objpoints, imgpoints_l, imgpoints_r, M1, d1, M2, d2, gray_shape, criteriacriteria_stereo, flagsflags) return ret, M1, d1, M2, d2, R, T, E, F def stereo_rectify(M1, d1, M2, d2, gray_shape, R, T): R1, R2, P1, P2, Q, roi1, roi2 cv2.stereoRectify( M1, d1, M2, d2, gray_shape, R, T, alpha0) map1x, map1y cv2.initUndistortRectifyMap( M1, d1, R1, P1, gray_shape, cv2.CV_32FC1) map2x, map2y cv2.initUndistortRectifyMap( M2, d2, R2, P2, gray_shape, cv2.CV_32FC1) return map1x, map1y, map2x, map2y, QstereoRectify里的alpha参数值得单独说。alpha0表示校正后只保留有效像素图像边缘会被裁掉alpha1保留所有原始像素但会引入大量黑色填充区域。做测距我一般用alpha0因为有效区域内的像素都是真实可用的不会把填充的黑边误当成物体。Q矩阵是后面reprojectImageTo3D的核心它是一个 4x4 的视差到三维的映射矩阵里面编码了基线、焦距和主点偏移。校正完一定要肉眼验证。把左右图用remap映射后在左图上找一个特征点看它在右图里是不是只在水平方向移动。如果还有明显的垂直偏移说明标定外参有问题常见原因是采集标定图时左右相机没有严格同步或者棋盘在两组图里的姿态差异太大。2.3 立体匹配SGBM 参数怎么调才不花校正之后就可以算视差了。OpenCV 提供StereoBM和StereoSGBM两种BM 快但噪声大SGBM 慢但边缘和弱纹理区域更稳。做测距我基本只用 SGBM因为深度图的质量直接决定测距精度。SGBM 的参数里numDisparities和blockSize是最关键的两个。numDisparities必须是 16 的整数倍它决定了视差搜索范围。这个值要按你相机基线和最近测距距离来估基线越大、最近距离越近需要的视差范围越大。比如基线 60mm、焦距 600 像素、最近想测 0.3 米视差大约是600 * 60 / 300 120那numDisparities至少设 128。设小了近处物体会因为视差超出范围而匹配失败设大了计算量上去且容易引入误匹配。blockSize是匹配代价聚合的窗口大小必须是奇数。窗口越大弱纹理区域越稳但物体边缘的深度会糊掉窗口越小边缘锐利但噪声多。我一般从 5 开始试纹理丰富用 5纹理弱用 7 或 9。def compute_disparity(imgL, imgR, num_disp128, block_size5): # 灰度化SGBM 只处理单通道 grayL cv2.cvtColor(imgL, cv2.COLOR_BGR2GRAY) grayR cv2.cvtColor(imgR, cv2.COLOR_BGR2GRAY) # 预处理CLAHE 增强对比度对弱纹理场景有帮助 clahe cv2.createCLAHE(clipLimit2.0, tileGridSize(8, 8)) grayL clahe.apply(grayL) grayR clahe.apply(grayR) channels 1 min_disp 0 # P1、P2 是视差平滑惩罚项P2 一般是 P1 的 4 倍左右 P1 8 * channels * block_size * block_size P2 32 * channels * block_size * block_size stereo cv2.StereoSGBM_create( minDisparitymin_disp, numDisparitiesnum_disp, blockSizeblock_size, P1P1, P2P2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange2, preFilterCap63, modecv2.STEREO_SGBM_MODE_SGBM_3WAY ) disp stereo.compute(grayL, grayR).astype(np.float32) / 16.0 return dispdisp12MaxDiff是左右一致性检查的阈值设 1 表示左右视差差异超过 1 像素就标记为无效能过滤掉大部分误匹配。uniquenessRatio是唯一性比率值越大匹配越严格弱纹理区域会留下更多空洞我一般设 10 到 15。speckleWindowSize和speckleRange用来剔除小块的噪点视差窗口设 100 表示小于这个面积的连通区域会被清掉。mode选SGBM_3WAY比默认的SGBM精度略高代价是慢一点。算出来的disp是 float32除以 16 是因为 SGBM 内部用定点数放大 16 倍存储。无效视差是负值后面重投影前要处理掉。3. 视差转三维坐标Q 矩阵重投影与测距验证3.1 reprojectImageTo3D 的输入输出到底怎么对应拿到视差图后用cv2.reprojectImageTo3D(disp, Q)就能得到每个像素对应的三维坐标。返回的是一个和视差图同尺寸的三通道浮点矩阵每个像素存(X, Y, Z)单位和你标定时用的物理单位一致。这里有个细节reprojectImageTo3D内部会做disp/16的处理所以如果你传进去的disp已经除过 16结果会差 16 倍。我习惯在compute_disparity里就除好然后重投影时传原始disp的 16 倍版本或者干脆自己用Q矩阵做矩阵乘法避免这个歧义。def disparity_to_3d(disp, Q): # disp 是已经除以 16 的浮点视差无效值为负 # 把无效视差置为最小值reprojectImageTo3D 会输出很大的 Z disp_valid disp.copy() disp_valid[disp_valid 0] 0 # 注意reprojectImageTo3D 期望的 disp 是未除 16 的定点放大值 points_3d cv2.reprojectImageTo3D(disp_valid * 16, Q) return points_3d def get_depth_at(points_3d, x, y): # 取指定像素的 Z 值单位与标定一致 X, Y, Z points_3d[y, x] return X, Y, ZQ矩阵的结构决定了Z f * B / d这个关系。Q[2,3]通常是-1/Tx相关项Q[3,2]是焦距项。如果你自己手算Z Q[2,3] / (disp_normalized Q[3,2])这种形式但直接用 OpenCV 的接口更省事。重投影出来的Z就是相机坐标系下的深度X和Y是横向和纵向坐标可以用来算物体的实际尺寸。验证测距精度最直接的办法是拿一个已知距离的平面靶标比如把棋盘格放在卷尺量好的 1 米、2 米、3 米处取棋盘中心区域的视差中位数算 Z和真实距离对比。我实测在 0.5 到 3 米范围内标定做好的情况下误差能控制在 1% 到 3%。超过 3 米误差会明显变大因为视差随距离衰减远距离的视差只有几个像素量化误差占比就上去了。3.2 测距结果怎么可视化才看得出问题光看数字不够要把深度图伪彩色叠加上去才能一眼看出哪里匹配错了。我一般用cv2.applyColorMap把归一化后的视差图转成伪彩色再和原图按比例混合。这样近处物体是暖色、远处是冷色如果某个物体边缘出现颜色突变或者大片空洞就说明那块视差有问题。def visualize_depth(imgL, disp): # 归一化到 0-255无效区域置 0 disp_norm cv2.normalize(disp, None, 0, 255, cv2.NORM_MINMAX) disp_norm np.uint8(disp_norm) disp_color cv2.applyColorMap(disp_norm, cv2.COLORMAP_JET) # 无效视差区域涂黑 mask disp 0 disp_color[mask] 0 # 和原图叠加alpha 控制透明度 overlay cv2.addWeighted(imgL, 0.6, disp_color, 0.4, 0) return overlayCOLORMAP_JET是常用的伪彩色映射蓝色表示视差小远红色表示视差大近。addWeighted的 alpha 我一般设 0.6 原图加 0.4 深度图既能看清物体轮廓又能看到深度分布。如果发现整张图都是蓝色或者红色说明numDisparities设得不对视差范围没覆盖实际场景。3.3 从视差图到实际测距的完整调用顺序把前面的函数串起来一个完整的测距流程是这样的读入左右图用标定得到的映射表做remap校正算视差重投影成三维点取目标区域的中位深度作为测距结果。取中位数而不是单点是为了避免个别误匹配像素把结果带偏。def measure_distance(imgL, imgR, map1x, map1y, map2x, map2y, Q, roiNone): # 极线校正 rectL cv2.remap(imgL, map1x, map1y, cv2.INTER_LINEAR) rectR cv2.remap(imgR, map2x, map2y, cv2.INTER_LINEAR) # 计算视差 disp compute_disparity(rectL, rectR) # 重投影 points_3d disparity_to_3d(disp, Q) # 取 ROI 区域的有效深度中位数 if roi is not None: x, y, w, h roi region points_3d[y:yh, x:xw, 2] else: region points_3d[:, :, 2] valid region[(region 0) (region 10000)] if len(valid) 0: return None, disp, points_3d distance np.median(valid) return distance, disp, points_3droi参数用来指定你关心的目标区域比如画面中央的物体。valid过滤掉 Z 小于等于 0 和大于 10000 毫米的异常值这两个阈值按你的实际测距范围调整。返回的distance就是目标到左相机光心的距离单位是毫米。4. 双目测距避坑实录五个让我重标了三次的坑4.1 标定重投影误差看着小测距却偏得离谱现象单目标定ret只有 0.3 像素双目标定也正常但测出来的距离系统性偏大或偏小而且偏的比例固定。原因棋盘格的实际方格尺寸SQUARE_SIZE填错了或者打印时被打印机缩放。标定内部所有尺度都基于这个值填错会导致T里的基线长度不对Z就按比例偏。解决用卡尺量一下打印出来的棋盘格实际方格边长精确到 0.1 毫米把真实值填进SQUARE_SIZE。别信打印设置里写的尺寸打印机有缩放。4.2 视差图整片空洞SGBM 像没干活现象compute出来的视差图大部分是负值有效像素很少伪彩色图几乎全黑。原因numDisparities设得太小实际视差超出了搜索范围或者左右图没有真正校正对齐垂直方向有偏移导致匹配失败。解决先确认remap用的映射表是从同一组标定参数生成的。然后把numDisparities逐步调大从 64 试到 256看有效像素比例是否上升。如果调大也没用检查stereoRectify的alpha是不是设成了 1 导致有效区域被挤到边缘。4.3 近处物体测出来比远处还远现象1 米处的物体算出来 1.5 米2 米处的物体算出来 1.8 米顺序都乱了。原因视差和深度的关系是反比近处视差大、远处视差小。如果视差图在近处物体上出现了误匹配把视差算小了深度就会算大。常见于物体表面反光或者纹理重复。解决在compute_disparity前加 CLAHE 增强对比度把uniquenessRatio调高到 15 以上speckleWindowSize调到 200 剔除小噪点。如果物体本身反光考虑在镜头上加偏振片或者调整光源角度。4.4 换一个场景就要重新标定现象在实验室标定好搬到室外或者换个光照条件测距就不准了。原因标定得到的内参和外参是相机物理状态的描述只要相机没有受到撞击、温度没有剧烈变化理论上不用重标。但如果你动了镜头的对焦环或者变焦环内参就变了。解决双目相机一旦标定完用胶带或者螺丝胶把对焦环固定死。如果必须调焦调完重新标定。温度变化大的场景比如从空调房搬到太阳下建议等相机热平衡后再标定。4.5 测距结果跳动大同一位置每次读数不一样现象静止物体连续采 10 帧测距结果在 ±5 厘米范围内跳。原因SGBM 是逐帧独立计算的没有时域滤波弱纹理区域的匹配本身就有随机性。另外如果左右相机不是硬件同步触发运动物体会产生视差错误。解决对连续多帧的测距结果做中值滤波或者滑动平均我一般取最近 5 帧的中位数。如果是动态场景必须用硬件同步的相机软件触发的时间差在运动物体上会直接变成视差误差。5. 把测距精度再压一档亚像素视差与 ROI 策略前面跑通的是基础链路精度大概在 1% 到 3%。如果你要把误差压到 1% 以内有两个方向可以抠一是把视差从整像素做到亚像素二是把测距区域从全图缩小到目标 ROI。亚像素视差的做法是在 SGBM 得到整像素视差后在匹配代价的极值点附近做抛物线拟合。OpenCV 的 SGBM 本身不直接输出亚像素但你可以用cv2.filterSpeckles清理后对有效视差区域做一次cv2.GaussianBlur再用cv2.pyrUp上采样等效于在更细的尺度上插值。更严谨的做法是自己实现代价体积的抛物线拟合但工程上高斯平滑加中值滤波已经能把跳动压掉一半。ROI 策略更直接不要对全图算深度先用颜色或者运动检测框出目标区域只对这个区域内的视差取中位数。区域越小误匹配混进来的概率越低。我一般用cv2.inRange做颜色阈值或者用背景减除cv2.createBackgroundSubtractorMOG2拿到运动目标掩膜再把掩膜内的有效深度取中位数。def robust_distance(points_3d, mask): # mask 是目标区域掩膜255 表示目标 z points_3d[:, :, 2] valid (mask 0) (z 0) (z 10000) if np.count_nonzero(valid) 50: return None # 取中位数比均值抗异常值 return np.median(z[valid])np.count_nonzero(valid) 50是个保护有效像素太少时结果不可信直接返回 None 让上层逻辑处理。中位数比均值稳因为即使有 20% 的误匹配像素中位数也不会被带偏。还有一个容易被忽略的点Q矩阵里的基线T是右相机相对左相机的平移向量如果你在stereoCalibrate时传图的顺序是左在前右在后T的符号和大小就是对的。如果反了Z会变成负值或者量级完全不对。验证方法很简单把标定板放在相机前 1 米看算出来的 Z 是不是接近 1000 毫米符号为正。最后说一个我自己的习惯每次换场景或者重新架设相机后先拿一个已知尺寸的物体放在已知距离上跑一遍确认测距误差在可接受范围再开始正式采集。这个验证步骤花不了两分钟但能省掉后面发现数据全废重来的后悔药。双目测距这条链路标定是地基校正对齐是骨架SGBM 参数是血肉任何一环偷懒都会在最终数字上放大出来。希望帮到你。本文还有配套的精品资源点击获取