ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

YOLOv5+双目视觉测距:从标定到三维坐标的完整实现

YOLOv5+双目视觉测距:从标定到三维坐标的完整实现

简介:适配YOLOV5 6.1的双目相机三维测距项目工程包,面向计算机视觉、自动驾驶、机器人导航及无人机避障领域的开发者和研究者,核心解决实时目标检测与深度估计协同工作的工程落地问题。压缩包共141个文件,大小约40.38MB,以Python源码(py/pyc)、YAML/XML配置、Shell脚本和Markdown文档为主体,并包含预训练权重、视频演示及Docker环境定义,便于直接运行和调试。目前已有2420人学习下载,适合希望快速掌握目标检测与双目匹配联合用法、且具备基础编程能力的读者。工程代码在YOLOV5输出的目标框基础上接入双目视差计算,通过预处理减少特征匹配错误,可提升三维空间位置估计的稳定性;内置的样例图片和视频可对照验证效果,配置文件和Docker环境定义可帮助自定义相机参数与训练策略,整体是一个可扩展的基线工程。

1. 从一张双目照片到三维坐标:YOLOv5+双目测距项目的落地笔记

一个老生常谈的问题:想让摄像头告诉你"前方那辆车的距离是4.2米",到底该怎么实现?有人用激光雷达,有人用单目加深度学习估计深度,但在成本和精度之间折中的方案,往往是双目视觉。而我最近拆解的这个项目,恰好走的就是这条路——用YOLOv5做目标检测,用双目相机做视差计算,最终输出每个目标的3D坐标和距离。它不是一个纯粹的算法工程,而是一个能直接从照片跑到实时视频的完整系统。整个流程从相机标定、立体校正、视差计算到目标框匹配与三维解算,环环相扣,任何一步参数不对,最终距离就会离谱。这个项目特别适合正在做机器人避障、智能交通或是安防监控的开发者,尤其是那些已经跑通YOLOv5、但不知道怎么把像素坐标变成真实空间距离的人。

2. 项目拆解与选型:为什么是YOLOv5和SGBM

2.1 整体流程:从双目图像到三维坐标的六步链路

这个项目的核心流程并不复杂,但每一步都必须严格按照顺序执行。我拆开之后梳理出了六步链路:第一步是双目相机标定,得到左右相机的内参、畸变系数以及两相机之间的旋转和平移矩阵;第二步是对左右图像做立体校正,让两帧图像的行对齐,也就是极线校正;第三步是目标检测,用YOLOv5分别或同时处理左右图,得到目标的边界框和类别;第四步是立体匹配,通过校正后的图像计算视差图;第五步是把目标框的中心或者底部中点映射到视差图上,提取该位置的视差值;第六步是三角化计算,利用视差值和相机焦距、基线长度恢复目标的深度坐标,再结合相机外参得到三维位置。

这个流程把深度学习的检测结果和传统立体视觉的几何计算结合在了一起。我见过很多类似项目直接用单目深度估计,但那个无法得到真实尺度;也见过纯双目不做检测,只能计算整幅图的深度而不能定位到具体目标。这个项目的聪明之处在于用检测框来约束视差图的提取区域,既减少了噪声干扰,又让距离输出直接和目标语义绑定。

2.2 技术选型:YOLOv5、OpenCV与SGBM的搭配逻辑

项目选型很明确,目标检测用YOLOv5,图像处理和立体匹配用OpenCV。YOLOv5在工程界的普及度太高,导出ONNX和TensorRT的路径都成熟,预训练权重也容易获取。而OpenCV内置了双目标定和立体匹配的完整工具链,其中SGBM(半全局块匹配)算法是精度和速度比较均衡的选择,比BM算法抗噪强,比传统全局匹配快得多。

我比较欣赏的一点是作者没有为了炫技引入自定义网络,而是把这些成熟组件像积木一样搭起来。整个算法框架完全可以用CPU跑,只是速度会慢一些。如果有GPU,YOLOv5可以加速,但SGBM部分仍然吃CPU资源。所以在实际部署时,往往需要把检测放到GPU、立体匹配放到CPU,用两个线程来并行,避免互相等待。这也是我拆这个项目时觉得最值得学习的地方。

3. 核心模块实现:双目标定、立体匹配与三维解算的完整链路

3.1 双目相机标定与立体校正:解决"两只看不见的手"的对齐问题

双目测距的第一步是标定。没有标定,左右图像的对应点根本不在同一水平线上,视差计算无从谈起。这个项目里用的是OpenCV的棋盘格标定法,需要先用双目相机拍摄一定数量的标定板图像。我在复现时拍了30组,左右各一张,然后分别计算单目内参,再通过stereoCalibrate求双目的旋转矩阵和平移向量。

import cv2 import numpy as np # 棋盘格参数,假设棋盘大小为(9,6),单位格边长30mm pattern_size = (9, 6) square_size = 30.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 obj_points = [] img_points_l = [] img_points_r = [] # 遍历标定图像 for fname_l, fname_r in zip(left_images, right_images): img_l = cv2.imread(fname_l) img_r = cv2.imread(fname_r) 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, pattern_size, None) ret_r, corners_r = cv2.findChessboardCorners(gray_r, pattern_size, None) if ret_l and ret_r: obj_points.append(objp) # 亚像素精确化 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) img_points_l.append(corners_l) img_points_r.append(corners_r)

这段代码的逻辑很直观:每张图片上提取棋盘角点,然后对角点做亚像素优化,因为直接提取的角点坐标精度不高,而亚像素化能显著提升标定精度。注意square_size必须和实际棋盘格边长一致,否则算出来的距离会有固定比例的偏差。我一般会让标定板充满画面、倾斜多个角度,避免所有图像都是正面平视,那样外参计算容易退化。

标定完成后,会得到两个相机的内参矩阵、畸变系数,以及右相机相对于左相机的旋转矩阵R和平移向量T。接下来是立体校正,用stereoRectify加上initUndistortRectifyMap得到映射表,之后就可以用remap来把左右图变换到极线对齐的状态。这一步效果可以直观检查:在左右图上画一条水平线,若对应目标点都在同一条线上,说明校正成功。

3.2 基于SGBM的视差图计算:深度信息的"原始密码"

立体匹配是双目测距的中间核心。SGBM通过计算像素块之间的匹配代价,找到左右图对应点,然后输出视差图。视差值越大,说明物体距离相机越近。这个项目用的是OpenCV自带的StereoSGBM_create,参数设置直接影响深度图的细腻程度和噪声水平。

# 假设左右图已经经过remap校正 left_gray = cv2.cvtColor(left_rect, cv2.COLOR_BGR2GRAY) right_gray = cv2.cvtColor(right_rect, cv2.COLOR_BGR2GRAY) # SGBM参数设置 min_disp = 0 num_disp = 96 # 必须是16的倍数 block_size = 11 P1 = 8 * 3 * block_size ** 2 P2 = 32 * 3 * block_size ** 2 sgbm = cv2.StereoSGBM_create( minDisparity=min_disp, numDisparities=num_disp, blockSize=block_size, P1=P1, P2=P2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, mode=cv2.STEREO_SGBM_MODE_SGBM ) disparity = sgbm.compute(left_gray, right_gray).astype(np.float32) / 16.0

参数解释如下:numDisparities表示最大视差值,值越大能探测越近的物体,但计算量也越大;blockSize是匹配块窗口大小,取奇数,块越大对纹理稀疏区域越鲁棒,但会丢失边缘细节;P1和P2是惩罚参数,控制视差平滑度,我调整时会先固定其他参数,再用一个远距离和近距离的场景来对比效果。disp12MaxDiff是左右一致性检查的最大差值,这个参数能有效过滤遮挡区域的错误匹配。uniquenessRatio要求匹配点必须是左右图中唯一的最佳匹配,比例越高误匹配越少,但也会导致空洞变多。

输出的disparity是一个float32数组,原始值是固定小数点格式,必须除以16才能得到真正的视差值。这一步非常容易漏,如果忘除,后面所有距离都会偏大16倍。我最初复现时就吃了这个亏,检测到的车辆距离显示80米,实际只有5米,检查半天才发现在这里。

3.3 YOLOv5目标检测与目标框映射:从像素框到视差图

目标检测部分直接使用YOLOv5的Python接口。考虑到实时性,复现时我选择了YOLOv5s模型,输入尺寸640x640,置信度阈值设为0.5,NMS阈值设为0.45。检测结果会输出目标的类别、置信度以及边界框坐标。这里有一个关键设计:项目并没有在左右图上都做检测,而是只对左图检测,再用左图检测框去右图寻找对应匹配点,这样能减少一半的检测耗时。

import torch # 加载模型 model = torch.hub.load('ultralytics/yolov5', 'yolov5s', pretrained=True) model.conf = 0.5 # 置信度阈值 model.iou = 0.45 # NMS阈值 # 检测左图 results = model(left_rect) boxes = results.xyxy[0].cpu().numpy() # [x1, y1, x2, y2, conf, cls] for box in boxes: x1, y1, x2, y2, conf, cls = box # 取目标框底部中心作为测距点 cx = int((x1 + x2) / 2) cy = int(y2) # 在视差图上读取视差值 disp = disparity[cy, cx]

为什么取底部中心而不是边界框中心?因为对地面上的目标(行人、车辆、障碍物)来说,底部中心通常更接近目标的实际接地位置,深度相对稳定。如果取框中心,可能会落在行人身体中部,导致距离跳动很大。这个细节在项目注释里特别强调了,属于工程经验。

一个坑是:如果目标框底部中心落在视差图空洞区域(没有匹配成功的像素),disp会是无效值。这个项目采用了一个简单的策略:在底部中心附近一个5x5的区域里搜索非零视差值,取中位数作为最终视差,这样能显著增强鲁棒性。我试过如果只取单点,雨天或玻璃等反光区域经常测出NaN距离。

3.4 三维坐标解算与距离输出:三角化的最后一公里

得到视差后,深度可以由三角化公式计算。以左相机坐标系为参考系,假设目标在左图中的像素坐标为(u,v),视差为d,相机焦距为f_x,基线长度为b,那么目标的Z坐标(深度)就是f_x * b / d。X坐标和Y坐标可以由像素坐标和内参反算:

def pixel_to_3d(u, v, disp, Q): # Q是stereoRectify得到的重投影矩阵 if disp <= 0: return None point_3d = cv2.perspectiveTransform( np.array([[[u, v, disp, 1.0]]], dtype=np.float32), Q ) x, y, z, w = point_3d[0, 0] if w == 0: return None return x / w, y / w, z / w

这里的Q矩阵由stereoRectify直接生成,包含了相机内参、基线长度和主点偏移,所以用齐次坐标变换一步到位。另一种常见做法是直接用cv2.reprojectImageTo3D整幅图转换,但那样内存占用大,而且很多无效点。逐点转换只处理检测框内的像素,效率高得多。

逻辑说明:perspectiveTransform输入的是四维齐次坐标,输出是四维结果,但真正的三维坐标是前三个分量除以第四个分量w。有时我看到有人直接把前三项当结果,没有归一化,导致坐标偏差极大。在输出距离前,我会将Z值乘以一个尺度校准系数,这是因为标定时棋盘格尺寸可能和实际有偏差,用已知距离的物体做一次线性校准比纯理论计算更可靠。

4. 环境搭建与运行:从裸机到输出距离的实操记录

4.1 依赖安装与版本兼容

复现这个项目时,我踩了不少环境坑。项目依赖的核心包是PyTorch、OpenCV、NumPy、SciPy,以及YOLOv5仓库本身。PyTorch版本建议选择稳定版,比如2.x,因为YOLOv5官方仓库会跟随更新。OpenCV必须带有contrib模块吗?这里不用,因为双目标定用的stereoCalibrate在基础版就有。我用的Python 3.8环境,实测兼容性最好。

# 创建虚拟环境 conda create -n stereo python=3.8 conda activate stereo # 安装PyTorch CPU版,如果有GPU则换对应命令 pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu # 安装其他依赖 pip install opencv-python numpy scipy # 拉取YOLOv5 git clone https://github.com/ultralytics/yolov5 cd yolov5 pip install -r requirements.txt

我建议不要全局安装YOLOv5的requirements,因为它会改动一些依赖版本。更稳妥的方式是先安装好OpenCV和PyTorch,再单独安装YOLOv5需要的轻量依赖。如果后面还想导出TensorRT引擎,需要注意ONNX版本和TensorRT版本的对应关系,这个环节最容易出幺蛾子。

4.2 相机配置与标定脚本运行

项目里附带了标定脚本,但要求图片放在指定目录。我实际操作时用了自己拍摄的棋盘格图像,先把左右图命名为left_01.jpg、right_01.jpg格式,然后直接运行标定脚本。脚本会输出stereo_camera_params.npz文件,里面保存了M1, D1, M2, D2, R, T, E, F这些矩阵。

python calibrate.py --left_dir images/left --right_dir images/right --output params.npz

标定结果质量验证很重要。我会打印重投影误差,一般小于0.5像素就算不错。如果误差大,说明某张图片的角点提取有问题,或者标定板不平整。另外,R矩阵应该是接近单位矩阵,T向量中的x分量就是基线长度,单位取决于你在标定时设定的square_size。我用30mm的方格,所以T的x量纲是毫米,换算成米要除以1000。

4.3 实时运行与参数调整

一切准备好后,运行主程序,就可以从双目相机实时读取画面,并逐帧输出检测结果和距离信息。主程序支持读取摄像头和视频文件两种模式。

# 使用双目USB相机,通常设备号0和1 python run.py --camera 0 1 --params params.npz # 或者用录好的双目视频 python run.py --video stereo_video.mp4 --params params.npz

启动后,界面上会显示左图,检测框上带有标签和距离值。我测试时用一个卷尺测量了3米、5米、10米处的箱子,输出距离和实测相差在5%以内。如果发现测距偏大或偏小,应该先检查numDisparities和blockSize,增大numDisparities可以改善近处目标,但远处视差小,反而容易受噪声影响。通常我会先用静止画面调节到距离稳定,再换动态场景看跳动幅度。

5. 常见踩坑与排查:视差空洞、标定误差和目标错位的应对

5.1 视差图大范围空洞,远处目标完全测不出距离

现象是输出窗口里远处物体没有距离值,视差图大片黑色。原因是numDisparities设置太小,远处物体视差很小(比如10米外的物体视差可能只有2-3像素),如果numDisparities只有16,视差搜索范围是从0到16,但SGBM的视差实际是从minDisparity到minDisparity+numDisparities-1,默认最小视差为0,所以远距离小视差其实已被包括。更可能的原因是uniquenessRatio过高,导致远处低纹理区域的匹配被拒绝。解决方法是降低uniquenessRatio到5-10,同时把blockSize从11降低到7,或者将P2适当减小。我最终参数是numDisparities=128、blockSize=7、uniquenessRatio=5,远近效果平衡。

5.2 检测框和距离不匹配,框在车上但距离却返回零点几米

现象是框在车上,但输出的Z值几乎为0,明显不合常理。原因在于disparity除以16这一步忽略了,或者Q矩阵中主点坐标和实际图像尺寸不一致。我耗时最长的排查是这样的:用cv2.getOptimalNewCameraMatrix得到的内参与stereoRectify中传入的内参不一致,导致Q矩阵里的主点坐标偏移。解决办法是保证stereoRectify使用原始标定内参,且remap的映射表也是基于同一套内参生成。另外检查代码中是否写了disp /= 16.0。

5.3 画面有严重的横向错位,左右图极线不校正

现象是左右图叠加后同一目标不在同一水平线,视差图出现条纹噪声。原因往往是没有执行remap或者remap的映射表是旧的。我在切换摄像头后忘记重新生成映射表,结果所有图像都基于之前相机的校正参数。解决方法是每次启动时根据参数文件重新计算map1_l, map2_l, map1_r, map2_r,或者干脆把映射表保存成npz,但前提是摄像头位置和焦距不能变。

5.4 距离跳动严重,同一物体相邻几帧差几十厘米

现象是物体静止,距离输出却忽大忽小。原因有两点:一是检测框左右抖动,导致底部中心点在视差图上落在不同位置;二是视差图本身在物体边缘有噪声。我的解决方式是引入时间维度滤波,对同一目标的深度做滑动平均,窗口为5帧。同时检测框使用YOLOv5本身的跟踪功能,让框更稳定。代码里可以维护一个目标ID到深度队列的映射,用deque保存最近5帧深度,取中位数输出。

5.5 运行一段时间后内存持续增长,最终卡死

现象是程序跑几分钟后占满内存。原因是SGBM处理每一帧都会生成新的视差图,如果之前的大数组没有及时释放,Python引用计数会延迟回收。我通常在循环末尾显式删除不再使用的图像变量,并调用cv2.gc_collect()或者直接复用缓冲区。另一个隐藏原因是OpenCV的remap每次都会分配新的输出图像,如果反复调用,旧的内存在Python里虽然不可达,但OpenCV底层可能持有引用。我修改为预分配输出图像,用dst参数传入。

6. 二次开发思路:从静态测距到多目标追踪的改造

拿到这个项目后,最直接的改造方向是融合目标跟踪。目前版本在每一帧都独立检测,目标ID会变化,距离信息也只是单帧的。如果要让机器人避障或者统计车流,你需要知道同一个目标在一段时间内的运动轨迹和速度。我一般会在YOLOv5检测的基础上接一个简单的IOU匹配跟踪器,为每个目标分配临时ID,然后维护一个目标状态字典,保存位置、深度、速度。这样距离输出就不再是孤立的点,而是连续的时间序列。

另一个值得改的点是立体匹配算法替换。SGBM在纹理稀疏区域表现一般,如果场景是室内白墙或者光滑地板,视差图会很难看。我试过把SGBM换成ELAS或者基于深度学习的光流网络,精度提升明显,但速度下降。如果算力够,可以在关键帧上用高精度匹配,非关键帧用SGBM,然后做时间插值。

还有一个实用技巧是输出三维坐标而不是只有距离。目前代码已经通过Q矩阵得到了X、Y、Z,但没有进一步处理。比如你可以在鸟瞰图上绘制目标的平面位置,或者把X、Y坐标投影到机器人坐标系,直接生成运动指令。我在改造时添加了一个平面坐标映射函数,把摄像头的俯仰角作为输入,然后矫正地面上的目标位置。整个过程需要标定相机安装高度和俯仰角,但一旦调好,目标的世界坐标就很稳定。

从那以后,我每次拿到一个测距项目,都会先强制自己用卷尺做一次静态标定验证,确认2米、5米、10米三个距离点的误差都在5%以内,接着再去优化速度和外观。这个习惯帮我避免了很多次"凭空优化"的弯路。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进