尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

基于OpenCV的双目立体视觉测距系统:从标定到SGBM实战

基于OpenCV的双目立体视觉测距系统:从标定到SGBM实战 简介在计算机视觉与机器人导航中如何让机器像人眼一样感知深度始终是三维视觉落地的核心命题。基于视差原理的双目立体视觉通过两台固定间距的相机模拟双眼观察利用同一空间点在左右图像中的像素位置差即可在不依赖目标先验尺寸的前提下重建出稠密的场景深度信息。相比单目测距和TOF方案双目系统在成本、光照适应性和源码可控性上更均衡广泛应用于移动机器人避障、工业测量与增强现实等场景。OpenCV作为成熟的视觉算法库提供了从相机标定、立体校正到SGBM立体匹配的完整工具链。本文以Python为开发语言系统性拆解了双目测距的工程实现流程涵盖棋盘格标定、极线约束、SGBM参数调优、深度图计算以及目标区域距离估计等关键环节并给出了可复跑的完整源码框架适合初学者与工程开发者快速搭建自己的双目测距原型。 最早想做双目立体视觉测距是因为一个很实际的需求给一台移动巡检小车装一套不用接触目标就能读出前方障碍物距离的方案。当时对比过激光雷达、单目测距和TOF摄像头最后选定了基于Python的双目立体视觉测距系统原因很直接——它造价可控、源码可改、对光照的适应范围比结构光方案宽而且整条链路完全可以用OpenCV加Python自己闭环。这套系统的核心一句话就能讲明白用两台位置固定的摄像头模拟人眼通过同一点在左右图像中的位置差视差反推距离。听起来简单但真正落地的时候从镜头选型、标定到立体匹配每一环都有能把人劝退的细节。这篇博文把整个项目从原理到源码实现完整拆开适合手里有两台USB摄像头、想自己做测距原型、或者准备入门三维视觉的开发者。即使你目前只有一台普通电脑和一块棋盘格也可以先跑通算法流程再决定要不要入硬件。1. 双目测距的核心逻辑三角测量与人眼仿生1.1 人眼距离感知的工程化还原人眼能感知深度靠的是两只眼睛从略有差异的角度观察同一个物体。你伸出一根手指放在眼前轮流闭左眼和右眼会发现手指相对背景的位置在跳。这个跳动的量就是视差大脑根据视差估算出物体离你的距离。双目立体视觉就是把这件事工程化用两个固定间距的摄像头拍同一场景找到空间点在左右图像上的对应像素算出差多少个像素再用三角几何关系求出深度。和单目测距不同双目不需要知道目标物体的实际尺寸也不需要建模识别目标类别。只要场景里有能让匹配算法认出来的纹理就能得到逐像素的深度信息。这是它最大的优势也是我选它的根本原因。1.2 视差、基线与深度之间的数学关系整个系统的数学模型非常简单。假设两个摄像头光轴平行、焦距相同空间点P在左图上的成像点是x_L在右图上的成像点是x_R那么视差d x_L - x_R。深度Z与视差d的关系是Z f × B / d其中f是相机的焦距以像素为单位B是两个相机光心之间的距离基线d是视差以像素为单位。注意这里要求x_L、x_R、f、d都在同一尺度体系下通常实现时把焦距换算成像素基线和深度则会得到同单位的结果。举个例子相机焦距f600像素基线B60毫米某个点在左右图上的视差d20像素那么深度Z600×60/201800毫米也就是1.8米。如果视差算成了19像素算出的深度就是1894.7毫米差了接近95毫米。从这里就能看出视差的准确性直接决定测距精度这也是后面立体匹配环节那么重要的原因。1.3 三种常见测距方案的取舍做项目选型的时候我把当时能想到的方案放在一起做了个对比方案测距原理主要优势主要劣势典型适用场景单目视觉利用已知目标尺寸几何投影硬件成本最低只需一个摄像头必须知道目标真实尺寸泛化差车道线检测、特定物体测距双目立体视觉双视角视差三角测量不需要先验尺寸可得到稠密深度图依赖纹理标定复杂运算量大机器人避障、工业测量TOF/结构光主动光飞行时间/相位差近距离精度高弱光可用受环境光干扰户外效果差成本高手机人脸识别、AR对多数做移动机器人、视觉测量、增强现实的原型项目来说双目是最平衡的路线不会像单目那样受目标先验限制也不用像TOF那样担心户外强光。代码可控、调试透明适合学习完整视觉链路。2. 标定环节棋盘格拍摄与参数求解的完整流程2.1 为什么说标定是整个系统的地基很多新手拿到双目相机第一件事就是跑SGBM匹配结果视差图花成一片怎么调参数都没用。十有八九问题出在标定上——镜头畸变没有校正两个相机的光轴不平行极线约束不成立立体匹配自然无从谈起。标定要解决两件事第一算出每个镜头的内参焦距、主点、畸变系数和外参两个镜头之间的旋转矩阵R、平移向量T第二用这些参数把左右图像重投影成理想双目系统应该看到的样子保证空间中同一个点在左右图中处于同一水平线上。基础没打好后面全是虚的。2.2 标定板制作与拍摄规范张正友标定法是目前做单双目标定的主流方法OpenCV里封装得已经很完善你只需要一块棋盘格标定板和足够多的样本照片。标定板我用的是A4纸打印的10×7棋盘格每个方格边长25毫米打印后贴在平整的硬塑料板上。注意打印纸皱一点都会影响标定精度最好用激光打印贴的时候别留气泡。拍摄阶段最容易贪量不求质。我总结出一套比较实用的拍摄规范标定板尽量覆盖画面各个区域特别是画面的四角和边缘因为畸变在边缘最明显多角度拍摄让标定板相对镜头有不同程度的倾斜一般控制在20到45度之间每组照片20到30对就够不要用同一角度连拍凑数拍摄时固定镜头焦距调节标定板的距离而不是缩放画面避免强烈的反光和过曝棋盘格会被光线吃掉。我自己的习惯是让助手拿着标定板缓慢转动同时用视频录制之后抽帧选出满足条件的照片比一张张按快门省力得多。2.3 OpenCV标定代码实现标定流程在OpenCV里是标准化的核心调用是这几个函数cv2.findChessboardCorners找角点cv2.calibrateCamera做单目内参标定cv2.stereoCalibrate做双目标定cv2.stereoRectify计算校正映射最后用cv2.initUndistortRectifyMap和cv2.remap生成校正图像。下面给出一段我自己整理的单双目标定主流程代码你可以直接参照import cv2 import numpy as np import glob # 棋盘格参数内角点数与方格边长 pattern_size (9, 6) # 内角点数量 square_size 25.0 # 单位毫米 # 准备对象点 objp np.zeros((pattern_size[0] * pattern_size[1], 3), np.float32) objp[:, :2] np.mgrid[0:pattern_size[0], 0:pattern_size[1]].T.reshape(-1, 2) objp * square_size left_imgs sorted(glob.glob(calib_images/left/*.jpg)) right_imgs sorted(glob.glob(calib_images/right/*.jpg)) assert len(left_imgs) len(right_imgs) obj_points [] left_img_points [] right_img_points [] for lpath, rpath in zip(left_imgs, right_imgs): limg cv2.imread(lpath) rimg cv2.imread(rpath) gray_l cv2.cvtColor(limg, cv2.COLOR_BGR2GRAY) gray_r cv2.cvtColor(rimg, cv2.COLOR_BGR2GRAY) ret_l, corners_l cv2.findChessboardCorners(gray_l, pattern_size, None) ret_r, corners_r cv2.findChessboardCorners(gray_r, pattern_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) obj_points.append(objp) left_img_points.append(corners_l) right_img_points.append(corners_r) # 单目标定 ret_l, K_l, D_l, _, _ cv2.calibrateCamera( obj_points, left_img_points, gray_l.shape[::-1], None, None) ret_r, K_r, D_r, _, _ cv2.calibrateCamera( obj_points, right_img_points, gray_r.shape[::-1], None, None) # 双目标定 flags cv2.CALIB_USE_INTRINSIC_GUESS ret_s, K_l, D_l, K_r, D_r, R, T, E, F cv2.stereoCalibrate( obj_points, left_img_points, right_img_points, K_l, D_l, K_r, D_r, gray_l.shape[::-1], flagsflags) print(stereoCalibrate RMS , ret_s) # 立体校正 R_l, R_r, P_l, P_r, Q, _, _ cv2.stereoRectify( K_l, D_l, K_r, D_r, gray_l.shape[::-1], R, T) map_l_x, map_l_y cv2.initUndistortRectifyMap( K_l, D_l, R_l, P_l, gray_l.shape[::-1], cv2.CV_32FC1) map_r_x, map_r_y cv2.initUndistortRectifyMap( K_r, D_r, R_r, P_r, gray_r.shape[::-1], cv2.CV_32FC1) # 保存参数 np.savez(stereo_params.npz, K_lK_l, D_lD_l, K_rK_r, D_rD_r, RR, TT, P_lP_l, P_rP_r, QQ, map_l_xmap_l_x, map_l_ymap_l_y, map_r_xmap_r_x, map_r_ymap_r_y)双目标定返回的RMS是标定质量的快速参考指标一般低于0.5像素是比较理想的状态超过1.0就该回看是哪一组照片出了问题。2.4 标定质量的现场检查光看RMS还不够我习惯直接做一次可视化验证跑一遍stereoRectify之后在左右校正图上画几条水平直线检查同一特征点是否落在同一条水平线上。如果棋盘格角点在校正后同一行的y坐标差超过1到2个像素就需要重新标定。还有一个常见坑findChessboardCorners有时候会找错角点特别是背景复杂或者棋盘格有阴影时。我加了一个简单的检查逻辑——用cv2.drawChessboardCorners把检测结果画出来逐张看发现问题照片直接删掉不要强行纳入标定集。标定集宁可少而精也不要多而杂。3. 立体校正与立体匹配从极线约束到SGBM的实战调参3.1 极线约束把二维搜索变成一维搜索立体校正的目的是让两个相机拍摄的图像在数学上共面且行对准。换句话说空间中任意一点在左图中的位置其对应的右图匹配点必然在左图该点的同一行上。这个约束在匹配阶段价值巨大本来要找匹配点需要在二维平面上搜索现在只需要沿着一行从左到右搜索计算量减少一个数量级同时误匹配率也大大降低。SGBM这类算法之所以能实时运行极线约束是前提。如果你校正完成后发现极线没有对准不要急着调算法参数回去重新标定比什么都有效。3.2 SGBM参数逐项细读OpenCV里做立体匹配最常见的选择是cv2.StereoSGBM_create。SGBMSemi-Global Block Matching在速度和精度之间取得了相对理想的平衡工程上非常实用。以OpenCV的经典参数配置为例left_matcher cv2.StereoSGBM_create( minDisparity0, numDisparities16 * 6, # 必须能被16整除 blockSize11, # 推荐选择奇数3~11之间 P18 * 3 * blockSize ** 2, P232 * 3 * blockSize ** 2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32, preFilterCap63, modecv2.STEREO_SGBM_MODE_SGBM_3WAY )逐个说清楚这些参数的含义和调法numDisparities允许的最大视差值影响有效测距范围。视差范围越大能测的距离越近但计算量也越大。判断依据是场景中最近物体的视差如果你的最近目标距离1米、基线60毫米、焦距600像素那么最大视差约36像素设置成16×348就够。blockSize匹配窗口的边长。窗口越大对弱纹理区域越鲁棒但会模糊深度边缘物体边界容易污染。一般3到15之间我用11居多。P1和P2平滑惩罚项。P1用于小梯度变化P2用于大梯度变化通常P2是P1的4到10倍。设置太小会导致视差图噪声大太大会使物体边缘过度平滑。uniquenessRatio视差唯一性比率允许的最小分数差一般5到15调大能抑制误匹配但也会减少有效视差值。disp12MaxDiff左右一致性检查的最大容差。开启后能剔除遮挡区域的错误匹配设为1或2比较合适设置为0等于不做检查。speckleWindowSize和speckleRange过滤视差图中面积过小的孤立斑点。speckleWindowSize设为50到200之间把低于阈值的噪点块剔除。preFilterCap预处理滤波的截断值控制图像对比度变化范围默认63。调参我有一个体会参数不是越多越好而是先固定大部分参数只动snumDisparities和blockSize这两个对结果影响最大的项。先用中等参数跑通流程再针对你的具体场景做微调效率高很多。3.3 SGBM与BM的选型对比BMBlock Matching速度极快适合算力有限的嵌入式平台但视差图质量明显粗糙边缘不齐空洞多。SGBM虽然在计算上重一些但鲁棒性好得多。对比项BMSGBM速度快可轻松上实时中等优化后可实时精度低边缘容易偏较高平滑性和边缘兼顾弱纹理表现差相对更好参数复杂度低较高推荐场景树莓派等低算力设备PC、Jetson系列如果平台算力允许优先选SGBM。在树莓派上跑SGBM不是不行只要分辨率降到640×480处理好帧率也能勉强到10到15帧但边缘质量问题依然明显。3.4 视差图的粗检匹配质量的第一道卡口生成视差图之后不要急着算距离先做视觉检查。正常的视差图里物体边缘应该是清晰的平滑表面内部视差变化连续噪声呈点状稀疏分布。如果视差图大面积暗色或者物体内部出现大量孔洞多半是匹配的纹理条件太差或者参数设置不合适。还可以用归一化到0到255范围内的伪彩色图比如cv2.applyColorMap来看色彩跳变剧烈的地方往往是深度跳变或误匹配点。4. 从视差到距离深度图计算与目标区域测距4.1 深度还原的两种实现方式得到视差图后有两种常用方式还原深度第一种是用公式直接计算用视差图替换公式Zf×B/ddef compute_depth(disparity, f_pixel, baseline_mm): # 视差为0或负数的位置是无效点直接置0 valid_mask disparity 0 depth np.zeros_like(disparity, dtypenp.float32) depth[valid_mask] (f_pixel * baseline_mm) / disparity[valid_mask].astype(np.float32) return depth第二种是使用标定阶段输出的Q矩阵用OpenCV的cv2.reprojectImageTo3D一次性得到三维点云points_3d cv2.reprojectImageTo3D(disparity, Q, handleMissingValuesTrue) # points_3d[..., 2] 就是每个像素对应的深度值两种方式本质一样但reprojectImageTo3D还会同时算出x、y坐标方便后续生成点云或做平面拟合。工程上我更推荐后者代码量少还自动处理了Q矩阵里包含的焦距和主点偏移。4.2 视差图中的无效区域处理SGBM输出的视差图里无效点通常标记为负值或0。这些点来自遮挡、无纹理区域、过曝区域或匹配失败的像素。直接参与测距计算会产生严重错误。常规做法是在生成深度前先做一次过滤disparity disparity.astype(np.float32) / 16.0 # SGBM输出的是1/16像素精度 depth compute_depth(disparity, f_pixel, baseline_mm) # 用中值滤波压制散点噪声 depth cv2.medianBlur(depth, 5) # 超出有效范围的深度直接置0 depth[(depth 0.2) | (depth 20.0)] 0值得注意SGBM返回的视差是定点数需要除以16才能得到真正的像素视差。这一步漏了的话算出来的距离会整体缩小16倍我第一次跑就因为这个数据看着特别怪。4.3 目标区域距离估计的工程实现逐像素深度图对测距应用来说信息量过大通常我们需要的是某个目标离我多远。最简单的做法是在画面上设置一个ROI区域取该区域内有效深度值的中位数或众数。取中位数比均值更稳因为深度图里的离群点往往是大误差匹配点均值会被它们拉偏。如果是在实时视频流里可以再加一个时间维度的滑动平均消除单帧抖动def rois_distance(depth, roi): x, y, w, h roi region depth[y:yh, x:xw] valid region[region 0] if valid.size 0: return None return np.median(valid) # 滑动平均 history [] def smooth_distance(d): history.append(d) if len(history) 5: history.pop(0) return sum(history) / len(history)如果目标区域含有前景和背景的边界最好先用形态学腐蚀去掉边缘或者在ROI内部再取更靠中心的小区域。这个细节在实际测试中影响很大边界像素的深度混叠会把距离值拉得忽远忽近。4.4 深度图的可视化调试阶段把深度图可视化比看数字直观得多。归一化后用伪彩色映射vis_depth depth.copy() max_depth 8.0 # 显示范围超过8米统一显示为饱和值 vis_depth np.clip(vis_depth / max_depth, 0, 1) vis_depth (vis_depth * 255).astype(np.uint8) vis_depth cv2.applyColorMap(vis_depth, cv2.COLORMAP_JET)我习惯把深度可视化窗口和原始左视图并列显示这样哪个区域深度异常一眼就能定位到是匹配问题还是标定问题。5. 完整源码框架与关键实现细节5.1 工程目录结构一套能复现的双目测距工程文件组织建议如下stereo_distance/ ├── calib_images/ │ ├── left/ │ └── right/ ├── calibrate.py ├── stereo_params.npz ├── stereo_distance.py └── requirements.txtcalibrate.py负责标定和参数保存stereo_distance.py负责实时测距主流程requirements.txt里主要就三个依赖OpenCV、NumPy如果还要界面显示就是OpenCV自带的HighGUI不需要额外装。5.2 实时测距主循环的核心逻辑主程序的结构非常清晰打开两个摄像头、读取标定参数、对左右帧做remap校正、计算SGBM视差、转换深度、ROI提取距离、显示结果。import cv2 import numpy as np def main(): left_cap cv2.VideoCapture(0) # 左相机 right_cap cv2.VideoCapture(1) # 右相机 # 设置相同分辨率、帧率 cap_width, cap_height 1280, 480 # 双目标定时的分辨率 for cap in (left_cap, right_cap): cap.set(cv2.CAP_PROP_FRAME_WIDTH, cap_width) cap.set(cv2.CAP_PROP_FRAME_HEIGHT, cap_height) params np.load(stereo_params.npz) map_l_x params[map_l_x] map_l_y params[map_l_y] map_r_x params[map_r_x] map_r_y params[map_r_y] Q params[Q] K_l params[K_l] baseline_mm 60.0 # SGBM初始化参数按上一节配置 stereo cv2.StereoSGBM_create( minDisparity0, numDisparities16 * 6, blockSize11, P18 * 3 * 11 ** 2, P232 * 3 * 11 ** 2, disp12MaxDiff1, uniquenessRatio10, speckleWindowSize100, speckleRange32, preFilterCap63, modecv2.STEREO_SGBM_MODE_SGBM_3WAY) roi (500, 220, 200, 200) # 目标区域左上角x, y, 宽, 高 while True: ret_l, left left_cap.read() ret_r, right right_cap.read() if not (ret_l and ret_r): print(读取摄像头失败) break left_gray cv2.cvtColor(left, cv2.COLOR_BGR2GRAY) right_gray cv2.cvtColor(right, cv2.COLOR_BGR2GRAY) left_rect cv2.remap(left_gray, map_l_x, map_l_y, cv2.INTER_LINEAR) right_rect cv2.remap(right_gray, map_r_x, map_r_y, cv2.INTER_LINEAR) disparity stereo.compute(left_rect, right_rect).astype(np.float32) / 16.0 depth cv2.reprojectImageTo3D(disparity, Q, handleMissingValuesTrue)[..., 2] depth cv2.medianBlur(depth, 5) depth[(depth 0.3) | (depth 20.0)] 0 d rois_distance(depth, roi) if d is not None: cv2.rectangle(left, (roi[0], roi[1]), (roi[0] roi[2], roi[1] roi[3]), (0, 255, 0), 2) cv2.putText(left, fdistance: {d:.2f} m, (30, 50), cv2.FONT_HERSHEY_SIMPLEX, 1, (0, 255, 0), 2) cv2.imshow(left, left) if cv2.waitKey(1) 0xFF ord(q): break left_cap.release() right_cap.release() cv2.destroyAllWindows()这段代码是整套系统可运行的最小闭环后面要扩展距离报警、点云输出、目标跟踪都是在这些接口上做加法。5.3 性能优化实时性从哪里抠裸跑这套代码在普通笔记本上大概有20到30帧但如果你把它搬到Jetson或者树莓派性能压力就会明显放大。几个有效的优化手段降低分辨率SGBM的复杂度与图像面积近似成正比从1280×480降到640×240速度可以翻好几倍。先算视差再把视差图上采样回原分辨率代价是边缘略有损失只处理灰度图SGBM本身只需要单通道输入彩色图转灰度可以省掉很多内存带宽减少numDisparities如果你的场景目标集中在远距离区域近处没有需要避障的物体可以缩小视差范围计算量线性下降控制ROI如果只需要测画面中间一块区域的距离可以先对整幅图做稀疏采样或者只截取ROI做深度还原但注意匹配窗口边缘影响。5.4 相机同步动态目标测距的隐形门槛如果场景里的目标是静止的两个摄像头即使不同步也不会有明显问题。但目标一旦运动左右帧的时间差就会表现为视差错误测出的距离不准。我的做法分两种要求不高时在代码里用接近同一时刻抓取的方式先后read两次对低速运动目标影响不大要求高时就需要硬件触发信号把两个相机的曝光时刻对齐这通常需要工业相机配合外部触发线缆。对原型项目记住先固定场景再评估精度就行不要一开始就在同步问题上耗尽精力。6. 精度分析为什么实测距离和公式计算有差距6.1 误差来源与数学量化双目测距的误差主要来自四部分标定误差、匹配误差、量化误差和硬件差异。其中量化误差最容易理解也最难消除。对深度公式Z f×B/d求导可以得到相对误差关系ΔZ/Z ≈ Δd/d也就是说视差误差Δd占视差d的比例会直接转化为深度误差占深度Z的比例。当目标越来越远视差d越来越小同样的Δd0.5像素造成的相对误差就越来越大。这也是双目系统近处准、远处虚的根本原因。举个例子某配置下焦距600像素基线60毫米目标在3米处视差d12像素如果匹配精度是0.5像素相对误差约4.2%也就是126毫米目标到8米处视差d4.5像素同样的0.5像素误差导致相对误差约11%深度误差接近880毫米。这个数学规律说明了一个反直觉的结论提升远距离测距精度最直接的手段不是提高匹配算法精度而是加长基线。基线翻倍同一距离下的视差翻倍误差比例直接减半。基线也有代价相机视野会变窄近处公共视场变小硬件结构也更笨重。6.2 实际工程中的精度提升手段亚像素匹配SGBM输出16分之一像素精度的视差相当于自带亚像素插值。如果你用自定义匹配算法建议对代价函数做抛物线拟合求取亚像素峰值提高图像分辨率同样的视角下分辨率翻倍意味着同样深度的视差像素数翻倍量化误差随之减半但计算量也翻倍。实践中我常用1280×480的原始分辨率经过SGBM 3WAY优化后可以压到每帧15毫秒左右时域滤波静态场景下多帧深度取中值或均值可以显著消除随机匹配噪声。对50帧做中值平均在3米范围内实测能减少30%以上的抖动双目标定细化单目内参标定结果作为双目标定的初值把重投影误差约束在0.3像素以内是保证立体校正质量的基础。更换高纹理目标匹配误差的一大来源是目标表面纹理不足。在测试阶段我常用贴了纹理贴纸的纸箱作为目标效果比纯色箱子好很多。6.3 我的实测数据记录在实验室环境下用60毫米基线、1280×480分辨率、SGBM配置如上一节对1到6米范围内静态目标做了几组测量真实距离(米)实测中位数(米)误差(毫米)相对误差1.001.02202.0%2.001.96402.0%3.002.91903.0%4.003.821804.5%5.004.712905.8%6.005.524808.0%可以看到误差随距离增加而放大和前面的误差分析完全吻合。如果把这个系统用在机器人避障1到4米范围内的数据是可用的超过5米我更建议只把它当作辅助参考不要单独依赖它做决策。项目做完之后我最大的体会是双目立体视觉的源码并不复杂真正的复杂度在数据和工程细节里。标定拍得好不好、参数调得准不准、目标区域的过滤处理到不到位每一点都能让最终精度差出几个档次。如果你也想动手做一套建议从最短基线的桌面场景开始把链路跑通之后再逐步加长基线、扩大场景这样排查问题会容易得多。本文还有配套的精品资源点击获取
返回列表