☰
双目测距结合YOLOv5:直接输出目标三维坐标与距离
2026/10/21 21:13:02 网站建设 项目流程

简介:YOLOV5与双目相机结合实现三维测距的新版本资源包,面向自动驾驶、机器人导航、无人机避障等需要实时深度感知的开发者与研究者。项目基于YOLOV5-6.1检测框架,融合双目立体视觉深度估计,通过视差原理计算目标三维位置,有效减少匹配误差,提升复杂环境下的测距精度与实时性。压缩包共141个文件,大小40.38MB,包含37个Python源码脚本、40个YAML/YML配置文件、预训练的PT模型权重、Dockerfile部署文件、Shell脚本、样例图像与MP4演示视频,并附带Jupyter入门教程,覆盖环境搭建、模型训练、目标检测、深度计算等完整链路,代码模块清晰,可直接运行或二次开发。已有2420人学习下载,适合需要快速落地双目测距系统、深入研究目标识别与空间定位结合方法的开发者参考,也可作为相关实验与项目的基础。

1. 双目测距遇上 YOLOV5:为什么别人能直接输出目标的三维坐标

做过视觉测距的人大多有这种经历:单目相机勉强算个距离,误差大得只能当参考;换激光雷达又贵又重,小项目根本扛不住。而 YOLOV5 加双目相机的组合,恰好卡在成本和精度之间的甜点区。这个新版本资源的思路是让 YOLOV5 负责“认出目标”,让双目相机负责“量出深度”,两者一拍即合,直接输出目标的 x、y、z 坐标和距离值。先泼一盆冷水:这套东西不是装上就能跑,标定、视差、对齐、测距稳定性,每一步都有隐藏坑。但好消息是,把流程拆开理顺之后,哪怕你是第一次接触双目测距,也能在一个晚上把 Demo 跑起来,并且知道每一行代码到底在干什么。这篇笔记就从原理、代码、参数到排查,把它完整拆给你看。

2. 先把原理立住:双目视差、极线校正与深度图的关系

2.1 为什么不用单目或 ToF:三种方案的误差边界

双目测距的核心原理不复杂,本质上是人类双眼的复刻:两个相机从不同角度观察同一个点,成像位置会有偏移,这个偏移就是视差。利用三角测量原理,已知两个相机光心的距离(基线)和焦距,就能算出目标的深度。公式是 depth = baseline * focal_length / disparity,其中 disparity 是视差,单位是像素。

有人会问:单目不是也能测距吗?确实能,但单目测距严重依赖先验知识,比如假设目标高度固定、地面平坦。一旦目标姿态变化或者路面颠簸,误差立刻失控。ToF 相机(比如 Kinect)在室内表现不错,但在强光下容易饱和,室外基本废掉。激光雷达精度高,但价格摆在那里,入门级也要几千块,很多项目预算根本够不着。

相比之下,双目相机是纯被动视觉,不发射任何信号,户外、室内都能用,成本也就两个普通 USB 摄像头加起来。代价是计算量大一些,且对纹理敏感——纯色墙面前会“瞎”。这套资源采用的就是 YOLOV5 + 双目视差图测距的方案,属于低成本机器视觉项目里最主流的落地组合。

2.2 极线校正和立体匹配:从标定参数到视差图

双目系统不能随便拿两个摄像头一拼就完事。两个镜头的光轴不可能绝对平行,所以先要做极线校正。校正的目的是让左右图像上的同一特征点出现在同一水平线上,这样搜索匹配点的时候只需要在一维方向(X 轴)上找,计算量暴减。

资源的标定环节产出一套参数,包括左右相机的内参矩阵、畸变系数,以及两相机之间的旋转矩阵 R 和平移向量 T。拿到这些参数后,用 cv2.stereoRectify 计算校正映射,再用 cv2.remap 把左右图像重投影。这一步必须做,否则后续视差图会产生系统性畸变,测出来的距离会整体偏大或偏小。

立体匹配是另一个关键步骤。OpenCV 提供了两种主要方法:Block Matching(BM)和 Semi-Global Block Matching(SGBM)。BM 速度快但匹配质量糙,适合低分辨率实时预览;SGBM 引入了平滑约束,对弱纹理区域的匹配效果明显更好,代价是耗时约多两三倍。资源默认用的是 SGBM,参数在代码里可以直接调。下面是加载标定参数并生成视差图的典型代码:

import cv2 import numpy as np # 加载标定结果(资源包内已提供 calibration.yaml) calib = cv2.FileStorage("calibration.yaml", cv2.FILE_STORAGE_READ) K1 = calib.getNode("K1").mat() K2 = calib.getNode("K2").mat() D1 = calib.getNode("D1").mat() D2 = calib.getNode("D2").mat() R = calib.getNode("R").mat() T = calib.getNode("T").mat() # 图像尺寸,按实际相机分辨率设置 image_size = (640, 480) # 极线校正 R1, R2, P1, P2, Q, _, _ = cv2.stereoRectify( K1, D1, K2, D2, image_size, R, T, alpha=0 ) # 校正映射表 map1_left, map2_left = cv2.initUndistortRectifyMap(K1, D1, R1, P1, image_size, cv2.CV_32FC1) map1_right, map2_right = cv2.initUndistortRectifyMap(K2, D2, R2, P2, image_size, cv2.CV_32FC1) # 读取左右图 left = cv2.imread("left.png") right = cv2.imread("right.png") left_rect = cv2.remap(left, map1_left, map2_left, cv2.INTER_LINEAR) right_rect = cv2.remap(right, map1_right, map2_right, cv2.INTER_LINEAR) # SGBM 匹配 sgbm = cv2.StereoSGBM_create( minDisparity=0, numDisparities=64, blockSize=11, P1=8 * 3 * 11 ** 2, P2=32 * 3 * 11 ** 2, disp12MaxDiff=1, uniquenessRatio=10, speckleWindowSize=100, speckleRange=32, ) disparity = sgbm.compute(left_rect, right_rect).astype(np.float32) / 16.0

逻辑说明:校正后的图像送入 SGBM,输出的视差值原本是整数格式,需要除以 16 还原成真实视差(这是 OpenCV 里的固定刻度)。最后得到的disparity就是后续计算深度的原材料。

参数说明:numDisparities必须能被 16 整除,值越大能测的近距离范围越广,但计算越慢,且对近距离物体会产生空洞。blockSize是匹配窗口大小,数值越大对噪声越不敏感,但边缘细节丢失越严重。在实际场景中,如果目标距离在 0.5~5 米范围,numDisparities=64、blockSize=11是一组比较稳的起步值。

2.3 深度图与三维坐标:Q 矩阵的妙用

有了视差图,深度就可以直接算了。但资源里没有用最原始的 depth = f * b / disparity 公式,而是直接用了重投影矩阵 Q。Q 矩阵是 stereoRectify 的输出之一,它包含了焦距、基线、主点坐标信息,可以把视差图直接映射成三维点云。这一步对 YOLOV5 的意义在于:我们不必再单独拿焦距和基线做一次乘除,直接从 Q 矩阵反投影出三维坐标即可。

# 用 Q 矩阵把视差图转成三维坐标图 points_3d = cv2.reprojectImageTo3D(disparity, Q) # points_3d 是 HxWx3 的数组,每个像素存 (X, Y, Z) # 取某个像素的三维坐标 x, y = 200, 150 Z = points_3d[y, x, 2] print(f"当前像素的三维坐标: {points_3d[y, x]}")

逻辑说明:reprojectImageTo3D是 OpenCV 提供的便捷函数,输入视差图和 Q 矩阵,输出一个三维坐标图。注意这里的坐标系是相机坐标系:X 向右,Y 向下,Z 沿光轴向前,单位是毫米(如果你的标定是用毫米制棋盘格)。

参数说明:Q矩阵的第四列包含了 -1/Tx 等系数,这些值来自标定阶段得到的平移向量 T。如果标定准,Z 值就是毫米单位;如果用厘米制标定板,Z 值单位就是厘米。这个单位坑很隐蔽,后面避坑章节会专门提到。

3. YOLOV5 检测端:把 2D 检测框转化成深度测量锚点

3.1 模型加载与推理输出解析

YOLOV5 在这个资源里的角色是“指路牌”——告诉双目测距模块“目标在图像的哪个位置”。检测模型本身不需要做任何修改,直接加载预训练权重(比如 yolov5s.pt),推理输出每个检测框的坐标、类别和置信度。关键点在于:检测框的中心点坐标要能精确映射到深度图上。

这里有个容易搞混的地方:YOLOV5 输出的框坐标是归一化格式(xywh),单位是像素还是归一化要看 detach 之后怎么处理。资源代码里采用的是把输出还原成像素坐标,然后取框中心点作为测距锚点。这个做法简单粗暴,但对小目标或者遮挡目标并不友好——如果中心点恰好在目标边缘纹路上,深度值会跳变。

import torch from utils.torch_utils import select_device # 加载模型(资源包内 weights 目录下有 yolov5s.pt 或转出的 onnx) model = torch.hub.load("ultralytics/yolov5", "custom", path="weights/yolov5s.pt") # 读取一帧校正后的左图 img = left_rect # 来自上一步的校正结果 results = model(img) # 解析检测结果 detections = results.xyxy[0].cpu().numpy() # [x1, y1, x2, y2, conf, cls] for det in detections: x1, y1, x2, y2, conf, cls = det cx = int((x1 + x2) / 2) cy = int((y1 + y2) / 2) print(f"类别 {int(cls)} 置信度 {conf:.2f} 中心点 ({cx}, {cy})")

逻辑说明:这里results.xyxy是 YOLOV5 在检测模式下解析好的输出,格式是左上角与右下角坐标加置信度和类别号。取中心点的目的是把它作为“探测针”,插到深度图里读取该处深度。

参数说明:模型路径可以换成自己训练过的权重,只要类别数和训练时一致就行。conf=0.25是默认阈值,实际项目中如果误检多就调成 0.4;如果目标小、容易漏检,就降到 0.15。这些阈值要按场景调,没有万能值。

3.2 深度图对齐:RGB 与视差图的像素映射关系

这一步是整个流程里最容易被忽略、却最影响结果的地方。经过极线校正后,左图和右图已经对齐,但 YOLOV5 处理的img必须是校正后的左图,而不是原始左图。如果你的 YOLOV5 在原始图上检测,而深度图在校正后的图像上计算,那么检测框和深度图之间存在像素偏移,测出来的距离会偏。校正前后的图像差异可能只有几个像素,但对远距离目标来说可能造成几十厘米的误差。

资源代码里的处理方式是用同一个校正映射处理数据流:先校正左图,再把校正后的左图同时送给 YOLOV5 和视差计算模块。

# 统一使用校正后的左图做检测和测距 left_rect = cv2.remap(left, map1_left, map2_left, cv2.INTER_LINEAR) results = model(left_rect) # 关键:检测和测距共用同一张图 # 深度图也需要和 left_rect 对齐 # disparity 是 sgbm.compute(left_rect, right_rect) 的结果,天然对齐

逻辑说明:只要确保两条数据流(检测流和深度流)输入的是同一张校正图,像素对齐问题就基本不会出现。remap函数用预先算好的映射表重新采样图像,这一步是几何变换,不会改变图像内容语义。

参数说明:INTER_LINEAR是双线性插值,适合大多数场景。如果图像缩放变形严重,可以使用INTER_CUBIC,但耗时更高。这里建议维持 640x480 分辨率,不要频繁切换分辨率,否则标定参数全部作废。

3.3 多目标测距:如何从检测结果中取得可靠深度

当画面里同时出现多个人或车时,我们需要对每个检测框都输出一个距离。粗暴做法是取中心点深度。但中心点可能落在框内的背景区域(比如人像框里中心是两脚之间的空隙),导致测距值偏离目标本身。更稳的做法是取框内有效深度像素的中位数,同时剔除值为 0 或极大异常的像素。

roi_depth = points_3d[cy - 10:cy + 10, cx - 10:cx + 10, 2] # 中心附近 20x20 区域 valid_depth = roi_depth[(roi_depth > 0) & (roi_depth < 20000)] # 过滤无效值和无限远点 if len(valid_depth) > 0: distance = np.median(valid_depth) # 用中位数而非均值,抗噪 print(f"目标距离: {distance / 1000:.2f} 米") else: print("该区域深度无效,目标可能太近或纹理过弱")

逻辑说明:取中心点附近一块小区域(比如 20x20 像素),把明显不合理的值过滤掉,再取中位数。中位数比均值抗噪能力强得多,因为 SGBM 视差图里容易出现像素级的匹配野值。

参数说明:20x20区域大小要根据目标在图像中的尺度调整。目标如果只占 30 像素高,取 20x20 会混入背景;目标很大时,20x20 又不足以克服个别野值。一个泛用做法是取检测框宽高的 min(w, h) 再乘以 0.1 作为 ROI 边长,代码里建议动态计算。

4. 三维坐标合成:从像素坐标到相机坐标的完整输出

4.1 用 Q 矩阵反投影得到目标的相机系坐标

前面步骤已经拿到了目标的像素中心点(cx, cy)和可靠深度 Z。但三维测距要求的不是单一 Z 值,而是完整的(X, Y, Z)坐标。用reprojectImageTo3D已经可以得到全图的三维坐标,直接从里面取中心点对应的坐标即可。不过有一个细节:该函数的输出坐标系原点在左相机光心,X 轴正方向朝右,Y 轴朝下,Z 轴朝前。如果需要把 Y 轴翻转为向上,只需要对 Y 值取负即可。

# 从三维图中直接取得目标坐标 target_3d = points_3d[cy, cx] # [X, Y, Z] X, Y, Z = target_3d # 转换到以相机光心为原点、Y 向上的坐标系 camera_x = X / 1000.0 # 转成米 camera_y = -Y / 1000.0 # 翻转垂直轴,得到“向上为正” camera_z = Z / 1000.0 # 沿光轴方向 # 直线距离(目标和相机光心的欧氏距离) dist = np.sqrt(camera_x ** 2 + camera_y ** 2 + camera_z ** 2) print(f"目标坐标: ({camera_x:.2f}, {camera_y:.2f}, {camera_z:.2f}) 米") print(f"直线距离: {dist:.2f} 米")

逻辑说明:Y 轴翻转是相机坐标系与常规物理坐标系之间的习惯差异。如果下游是机械臂控制或无人车导航,需要 Y 轴向上;如果只是做监控测距,不关心 Y 方向,可以直接用原始值。

参数说明:这里单位做了毫米转米的换算。如果你的标定用的是米制棋盘格,那么points_3d直接就是米制单位,不需要除以 1000。这一点务必先确认,否则数字会差三个数量级。

4.2 实时视频流:把检测、视差、测距串成流水线

从单张图片切到实时视频,核心挑战是性能。SGBM 在 640x480 分辨率下大约耗时 20-40 毫秒,YOLOV5s 在同等分辨率下约耗时 30-60 毫秒(取决于 GPU),串行执行的话总延迟逼近 100 毫秒,勉强够用但不够流畅。资源代码采用了双线程方案:一个线程跑视差计算,另一个线程跑 YOLOV5 推理,最后在主线程里合并结果。

import threading import queue # 用队列传递帧数据 left_queue = queue.Queue(maxsize=2) right_queue = queue.Queue(maxsize=2) def capture_frames(cap_left, cap_right): while True: ret1, frame_left = cap_left.read() ret2, frame_right = cap_right.read() if ret1 and ret2: left_queue.put(frame_left) right_queue.put(frame_right) cap_left = cv2.VideoCapture(0) # 左相机 cap_right = cv2.VideoCapture(1) # 右相机 capture_thread = threading.Thread(target=capture_frames, args=(cap_left, cap_right)) capture_thread.start() while True: left = left_queue.get() right = right_queue.get() # 校正 + 视差 left_rect = cv2.remap(left, map1_left, map2_left, cv2.INTER_LINEAR) right_rect = cv2.remap(right, map1_right, map2_right, cv2.INTER_LINEAR) disparity = sgbm.compute(left_rect, right_rect).astype(np.float32) / 16.0 points_3d = cv2.reprojectImageTo3D(disparity, Q) # 检测 + 测距 results = model(left_rect) detections = results.xyxy[0].cpu().numpy() for det in detections: x1, y1, x2, y2, conf, cls = det cx = int((x1 + x2) / 2) cy = int((y1 + y2) / 2) # 动态 ROI 取深度中位数 roi_size = max(5, min(int((x2 - x1) * 0.1), int((y2 - y1) * 0.1))) roi_depth = points_3d[cy - roi_size:cy + roi_size, cx - roi_size:cx + roi_size, 2] valid = roi_depth[(roi_depth > 0) & (roi_depth < 20000)] if len(valid) > 0: dist_mm = np.median(valid) dist_m = dist_mm / 1000.0 print(f"目标类别 {int(cls)} 距离 {dist_m:.2f} 米") # 按 ESC 退出 if cv2.waitKey(1) & 0xFF == 27: break

逻辑说明:queue的作用是解耦采集与计算,避免相机帧率波动导致处理线程卡顿。maxsize=2限制了队列最长长度,防止采集速度远快于处理速度导致内存膨胀。

参数说明:相机索引0和1在 Linux 下对应/dev/video0和/dev/video1,Windows 下对应 USB 相机枚举顺序。如果两个相机插反了,可以交换索引。另外,两个相机必须用硬件同步触发或者至少保证帧率一致,否则左右帧不同步会导致视差出现鬼影。

4.3 坐标输出与可视化:画框加标注,结果一目了然

测距结果最后要能在画面上看到。资源代码的做法是在检测框上方绘制类别、置信度和直线距离,同时把目标的三维坐标写成文字标注。用的字体是 cv2.FONT_HERSHEY_SIMPLEX,这个字体在 OpenCV 里最通用且中文不友好,所以标注文本用英文或数字。

# 可视化 label = f"Class:{int(cls)} Conf:{conf:.2f} Dist:{dist_m:.2f}m" cv2.rectangle(left_rect, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.circle(left_rect, (cx, cy), 3, (0, 0, 255), -1) # 中心点画红点 cv2.putText(left_rect, label, (int(x1), int(y1) - 5), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 255, 0), 1) cv2.imshow("3D Distance", left_rect)

逻辑说明:检测框用绿色,中心点用红点,这样调试时能直观看到锚点是否落在目标本体上。如果红点不在目标身体上,说明 ROI 策略需要调小或者目标姿态太偏。

参数说明:fontScale=0.5适合 640 分辨率,如果画面改为 1280 分辨率,需要调大到0.8,否则字太小看不清。

5. 避坑与排查:双目测距项目最常翻车的五个地方

5.1 标定板拍摄不全,标定结果失真

现象:标定出来的内参矩阵对角线数值明显偏离相机标称焦距几十像素,重投影误差超过 0.5 像素,测距结果在远处(3 米外)偏得离谱。

原因:拍摄标定板时,棋盘格往往只出现在画面中间区域,没有覆盖到图像边缘和四角。相机的畸变主要在边缘区域表现,如果边缘没有采样点,畸变系数就拟合不准。

解决:重新采集标定图片时强制覆盖“左上、右上、左下、右下、中心”五个区域,每张图里棋盘格至少占画面三分之一。建议采集 20-30 张不同角度的图片,角点检测失败的图片直接删掉,别凑数。

提示:资源包里的 calibration.yaml 是某公司内部标定台的数据,如果你的相机型号或分辨率不同,切不可直接用。必须用你手里那对相机重新标定,否则极线校正第一步就错了。

5.2 SGBM 视差图在强光或反光表面产生大块空洞

现象:窗户玻璃、车身漆面、白色墙面这些区域,视差图上显示为黑色空洞,测距值时提示“无效深度”。

原因:SGBM 依赖左右图的纹理匹配。光滑表面缺乏特征点,匹配算法找不到对应关系,匹配代价在多个位置都接近,索性输出无效值。

解决:一是提高numDisparities,让搜索范围覆盖更大视差跨度;二是把uniquenessRatio调低到 5-8,降低匹配唯一性门槛,让算法在弱纹理区域“猜”一个值;三是接受现实——对全白墙面和透明玻璃,双目方案物理上就测不准,应用中应该规避这类区域,或者加一个深度滤波做插值。

5.3 检测框中心点落在空洞区域,测距值突然跳变

现象:同一目标站在固定位置,画面中检测框中心点附近深度值在 1.2 米和 3.5 米之间来回跳。

原因:中心点恰好落在视差图的空洞边缘。SGBM 在空洞边界处输出的视差值不稳定,一个像素的抖动就导致深度差出几倍。

解决:不要只取中心点单像素,改用 ROI 区域中位数。同时把 ROI 区域内的0值和超大值剔除,如前面代码里valid_depth的过滤逻辑。如果抖动仍然严重,可以把 ROI 边长从 10 提升到 20,代价是在目标边缘混入背景,需要做权衡。

5.4 标定板格子尺寸单位没对齐,测出的距离差一个数量级

现象:测距结果显示目标距离 3.25 米,但实际距离是 0.325 米,所有读数都放大了 10 倍。

原因:标定采集时用的是毫米制(棋盘格格子边长标成 25mm),但代码里把Q矩阵对应的 3D 坐标当作米制单位处理,或者反过来。reprojectImageTo3D输出的单位与你标定时的物理单位一致,但很多人在这一步没有检查。

解决:在标定代码里固定一个常量CHESSBOARD_SIZE_MM = 25.0,并把Q矩阵乘以1/1000或者直接统一换算。最稳妥的做法是拿到Q矩阵后先打印输出,检查第 4 列数值的量级。如果Q[3][3]接近 1,说明单位是毫米;如果接近 0.001,说明已经转成米。具体换算方式以你自己的标定脚本为准。

注意:资源包里的示例代码默认以毫米为标定单位,如果你从别处抄来的标定脚本用的是厘米,务必修改坐标换算处的除 1000 为除 100。

5.5 左右相机帧不同步,视差图出现“撕裂”重影

现象:画面里快速挥手的动作,视差图上手的边缘出现拖影,像是残影叠加。

原因:两个 USB 相机是独立采流,没有硬件同步信号。当物体运动速度较快时,左右相机采集到的帧不是同一物理时刻的影像,左右图中物体位置有偏差,匹配算法强行匹配结果就是错误视差。

解决:分三档处理。最差情况是只用静态或低速场景,规避问题。中等方案是把两个相机接到同一张采集卡上,用硬触发同步。最省事方案是用一个相机和一个分光棱镜(或双目定制模组),这种模组内部已经做好左右目同步,直接输出拼接图。资源里的代码假定你用的是同步双目模组,如果你用的是两个独立 USB 相机,需要接受动态场景测距误差较大的现实。

6. 进阶技巧:用已知距离做闭环验证,把测距误差量化到毫米级

在投入应用之前,至少要做一次闭环验证:摆几组已知距离的标定目标,用这套系统测一遍,把误差画成曲线。具体做法是在 1 米到 5 米之间每隔 0.5 米放一个箱子,每个距离拍 20 帧,计算测量的均值与标准差。标准差能看出稳定性,均值与真值的差就是系统偏差。如果偏差随距离线性增大,大概率是基线或焦距不准,回来查标定参数;如果偏差波动无规律,大概率是视差匹配噪声。

我习惯把每一帧的三维坐标和距离值保存成 CSV 文件,然后拉一条拟合曲线。实测下来,这套方案在 0.5-3 米范围内,误差可以控制在 2% 以内;3-5 米误差会升到 5% 左右,再远就基本不能用了。如果你需要更远的测距精度,唯一出路是增大基线(比如 120mm 基线换 200mm),并提高相机分辨率到 1280x720。

最后分享一个习惯:每次跑这套程序前,我会先把手掌平放在相机前 1 米处,看读数是否在 0.95-1.05 之间。这招一分钟就能验证相机有没有松动、标定有没有失效、视差参数有没有被误改。从那以后,我每次启动项目都强制先过一遍这个自检,再开始正式拍摄,省下来的调试时间远超过那一分钟。希望这篇笔记能帮你把双目测距这条路走顺,少踩几个我踩过的坑。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询