简介:面向计算机视觉初学者与自动驾驶、机器人导航方向开发者的一份实战型工程包,围绕YOLOv5目标检测与单目深度估计的结合展开,解决无深度相机条件下如何从单张彩色图像推断物体距离的问题。包内共132个文件,以34个Python脚本、40个YAML配置、16个编译缓存及若干Dockerfile、Shell脚本和说明文档为主,涵盖模型代码、预训练权重、训练与测试脚本、数据集配置等模块,压缩包约13.87MB,目录结构清晰便于按模块检索。已有3868人学习下载,说明该方案在同类教程中具备一定参考价值。读者可据此复现检测与测距联合流程,理解锚框预测、深度映射与结果可视化的衔接方式,并借助训练脚本调整参数、在新图像上验证效果,为自动驾驶、无人机与智能监控场景的距离感知提供可运行的基础实现。
1. 从一张二维截图里读出三维距离:YOLOv5 单目测距能解决什么
一张普通的监控截图,画面里有人、有车、有锥桶,人眼能大致判断谁远谁近,但代码拿到的是像素坐标,它不知道那个 320×240 的框到底站在 8 米外还是 30 米外。YOLOv5 单目测距要干的事,就是把检测框和相机成像模型接起来,让「框」变成「距离」。它适合两类人:一类是做过目标检测、想把结果落到实际场景(园区测距、车道前车距离提示、机器人避障预判)的开发者;另一类是课程设计或毕设需要一套能跑通、能改参数的完整链路的人。整套东西不依赖双目相机、不依赖激光雷达,一个标定过的普通摄像头加一份 YOLOv5 权重就能起步,这也是单目方案在成本和部署难度上最现实的地方。下面按「它怎么算 → 怎么跑起来 → 怎么改」的顺序拆。
2. 测距原理与工程结构:一个检测框如何变成米
2.1 单目测距的几何模型:相似三角形是全部基础
单目测距没有魔法,核心就是小孔成像的相似三角形。相机焦距为 f(像素单位),目标在图像里的像素高度为 h_pixel,目标的真实物理高度为 H_real,那么目标到相机的距离 D 满足:
D = (f × H_real) / h_pixel推导很直白:真实高度 H_real 在距离 D 处投影到成像平面上的高度是 h_pixel,两者比值等于焦距与距离的比值。工程上真正麻烦的不是这个公式,而是三个量的来源——f 要标定,H_real 要按类别给先验值,h_pixel 要从检测框里稳定地取。YOLOv5 给的是(x1, y1, x2, y2),框高就是y2 - y1,但框会抖,直接拿单帧框高算距离,输出会像心电图一样跳,这是后面要处理的重点。
焦距的获取常见做法是棋盘格标定,用 OpenCV 的calibrateCamera拿到内参矩阵,取fx或fy。如果手头没有标定板,也有一个糙但能用的办法:让一个已知高度 H_real 的物体站在已知距离 D0 处,量出它在图里的像素高 h0,反解f = D0 × h0 / H_real。这个办法在固定焦距、固定分辨率的场景里够用,但一旦相机换分辨率或开了数码变焦,f 就失效,血泪经验是:标定结果和分辨率绑定,改分辨率必须重标或按比例换算。
2.2 项目目录结构:检测、测距、可视化三层分开
这类项目通常是一个轻量 Python 工程,结构大致如下(不同版本命名会有出入,但分层逻辑一致):
yolov5_mono_distance/ ├── weights/ │ └── yolov5s.pt # 检测权重 ├── configs/ │ └── camera.yaml # 相机内参、类别真实高度 ├── models/ │ └── detector.py # YOLOv5 推理封装 ├── core/ │ ├── calibration.py # 标定与焦距读取 │ └── distance.py # 单目测距计算 ├── utils/ │ ├── draw.py # 画框、写距离 │ └── smooth.py # 距离平滑滤波 ├── main.py # 主入口:视频/图片/摄像头 └── requirements.txt分层的意义在于:detector.py只负责「图进、框出」,distance.py只负责「框进、米出」,draw.py只负责「米进、图出」。这样换检测模型(比如换 YOLOv8)时只动 detector,换测距策略(比如加地面平面假设)时只动 distance,互不牵连。我一般会把相机参数单独放camera.yaml,而不是硬编码在 py 里,因为同一套代码经常要在不同摄像头之间切。
2.3 相机参数配置:内参与类别先验高度
camera.yaml里通常有两块内容,一块是内参,一块是每个类别的真实高度先验:
# configs/camera.yaml camera: fx: 920.5 # 标定得到的焦距(像素) fy: 920.5 cx: 640.0 # 主点 cy: 360.0 image_width: 1280 image_height: 720 # 类别真实高度先验(米),按 COCO 常见类别给 class_real_height: person: 1.70 car: 1.50 truck: 3.00 bicycle: 1.10 default: 1.50 # 未登记类别兜底fx/fy来自标定,cx/cy是主点,测距公式里其实只用到 f,但主点在需要把像素坐标反投影到相机坐标系时会用到。class_real_height是这套方案误差的最大来源之一:同样是 person,成年人和小孩能差 0.5 米,坐着的人和站着的人差更多。所以工程上要么限定场景(比如只测站立行人),要么在类别之外再加一个「姿态/框宽高比」的过滤条件,把明显不符合先验的框剔掉再算距离。
2.4 检测到测距的数据流:一次推理的完整链路
主链路的伪代码逻辑是这样的:读帧 → 预处理 → YOLOv5 推理 → NMS 后拿到框和类别 → 对每个框取框高 → 查类别先验高度 → 套公式算距离 → 平滑 → 画图输出。关键在「取框高」和「平滑」两步,下面用代码说明。
# core/distance.py import numpy as np def pixel_height_to_distance(box, focal_px, real_height_m): """ box: (x1, y1, x2, y2) 像素坐标 focal_px: 相机焦距(像素) real_height_m: 该类别目标的真实高度(米) 返回:估计距离(米) """ x1, y1, x2, y2 = box h_pixel = max(y2 - y1, 1e-6) # 防止除零 distance = (focal_px * real_height_m) / h_pixel return distance def batch_distance(boxes, classes, focal_px, height_map): """对一帧内所有框批量算距离""" results = [] for box, cls in zip(boxes, classes): real_h = height_map.get(cls, height_map["default"]) d = pixel_height_to_distance(box, focal_px, real_h) results.append(d) return np.array(results)pixel_height_to_distance就是公式的直接实现,max(..., 1e-6)是防呆,避免极端小框导致除零或天文数字。batch_distance里height_map.get(cls, default)保证没登记的类别也有兜底值,不会因为 KeyError 让整帧崩掉。参数上要注意focal_px必须和当前输入分辨率匹配,如果模型输入是 640 而原图是 1280,框坐标要先映射回原图再算,否则 f 和 h_pixel 不在同一尺度,距离会整体偏大一倍——这是最常见的翻车点之一。
2.5 距离平滑:单帧结果不能直接信
检测框每帧都在抖,框高抖 5 个像素,距离可能跳 1~2 米。直接显示会让人以为目标在抽搐。常见做法是对同一目标的距离做滑动平均或指数滤波,配合简单的跟踪(按 IoU 或中心点距离关联前后帧的框)来维持目标 ID。
# utils/smooth.py from collections import deque class DistanceSmoother: def __init__(self, window=8): self.window = window self.buffers = {} # track_id -> deque def update(self, track_id, distance): buf = self.buffers.setdefault(track_id, deque(maxlen=self.window)) buf.append(distance) return sum(buf) / len(buf) # 滑动平均window=8是经验值,太小压不住抖动,太大响应变慢,前车急刹时距离显示会滞后。如果场景里目标移动快,可以换成指数滤波d_smooth = α·d_new + (1-α)·d_old,α 取 0.3~0.5,调参时盯着「静止目标距离是否稳定」和「移动目标是否跟得上」两个指标。跟踪 ID 的关联不在测距模块里做,通常复用检测侧或单独接一个轻量跟踪器,这里只按 ID 维护缓冲。
3. 跑起来:环境、权重与三种输入模式
3.1 环境依赖与版本对齐
这套东西的依赖不多,但版本对齐很关键,尤其是 PyTorch 和 torchvision 的匹配,以及 OpenCV 的读写后端。requirements.txt大致是:
torch>=1.8.0 torchvision>=0.9.0 opencv-python>=4.5.0 numpy>=1.19.0 pyyaml>=5.4安装时先确认显卡驱动和 CUDA 版本,再选对应的 torch 轮子,不要直接pip install torch撞运气。CPU 也能跑 yolov5s,720p 视频大概几帧到十几帧每秒,够验证逻辑;要实时就上 GPU。OpenCV 用opencv-python即可,如果要做视频编码输出再换opencv-python-headless或带 ffmpeg 的版本。踩过的坑是 numpy 2.x 和某些旧版 torch 不兼容,报module 'numpy' has no attribute之类的错,锁到 1.2x 更稳。
3.2 权重准备与模型加载
YOLOv5 的权重是.pt文件,加载方式取决于你用的是官方仓库还是 ultralytics 包。用官方仓库时通常torch.hub.load或直接DetectMultiBackend;用 ultralytics 包时是YOLO("yolov5s.pt")。封装层要做的就是把两种调用统一成一个detect(frame) -> boxes, classes, confs接口。
# models/detector.py import torch class YOLOv5Detector: def __init__(self, weights="weights/yolov5s.pt", conf=0.35, iou=0.45, device="cuda"): self.device = device if torch.cuda.is_available() else "cpu" # 官方仓库方式;若用 ultralytics 包则改为 YOLO(weights) self.model = torch.hub.load("ultralytics/yolov5", "custom", path=weights, source="local") self.model.conf = conf # 置信度阈值 self.model.iou = iou # NMS 的 IoU 阈值 self.model.to(self.device) def detect(self, frame_bgr): results = self.model(frame_bgr) df = results.pandas().xyxy[0] # xmin,ymin,xmax,ymax,confidence,class,name boxes = df[["xmin", "ymin", "xmax", "ymax"]].values classes = df["name"].tolist() confs = df["confidence"].values return boxes, classes, confsconf=0.35比默认 0.25 略高,是为了减少低置信框带来的距离噪声;iou=0.45控制重叠框合并。source="local"表示用本地权重,避免运行时去拉网络资源。results.pandas()依赖 pandas,如果不想引入这个依赖,可以改用results.xyxy[0]直接拿 tensor,再手动映射类别名。参数上conf调高会让远处小目标漏检,调低会多出一堆误检框,测距场景里误检框的距离往往是错的,所以宁可略高。
3.3 三种输入模式:图片、视频、摄像头
main.py一般用 argparse 暴露输入源,三种模式共用同一条处理链路,只是取帧方式不同。图片模式读一次、算一次、存结果;视频模式逐帧读、逐帧算、写输出;摄像头模式用cv2.VideoCapture(0)实时读。
# main.py(节选) import argparse, cv2, yaml from models.detector import YOLOv5Detector from core.distance import batch_distance from utils.smooth import DistanceSmoother from utils.draw import draw_results def load_cfg(path="configs/camera.yaml"): with open(path, "r", encoding="utf-8") as f: return yaml.safe_load(f) def run(source, cfg, detector, smoother): cap = cv2.VideoCapture(source) while True: ok, frame = cap.read() if not ok: break boxes, classes, confs = detector.detect(frame) dists = batch_distance(boxes, classes, cfg["camera"]["fx"], cfg["class_real_height"]) # 简化:用框索引当 track_id,实际应接跟踪器 smooth_d = [smoother.update(i, d) for i, d in enumerate(dists)] frame = draw_results(frame, boxes, classes, smooth_d) cv2.imshow("mono-distance", frame) if cv2.waitKey(1) & 0xFF == 27: # ESC 退出 break cap.release() cv2.destroyAllWindows()source传图片路径、视频路径或0都能走VideoCapture,这是 OpenCV 的统一入口。smoother.update(i, d)这里用框索引当 ID 只是演示,真实场景要换成跟踪器给的 ID,否则目标一多、顺序一变,平滑缓冲就串了。waitKey(1)控制刷新,ESC 退出。参数上fx从配置读,不要在这里写死,方便换相机。
3.4 结果可视化:把米数写在框上
可视化不只是画框,还要把距离、类别、置信度一起标出来,方便肉眼校验。字体大小和颜色要按分辨率自适应,否则 4K 图上字小得看不见。
# utils/draw.py import cv2 def draw_results(frame, boxes, classes, distances): for box, cls, d in zip(boxes, classes, distances): x1, y1, x2, y2 = map(int, box) cv2.rectangle(frame, (x1, y1), (x2, y2), (0, 255, 0), 2) label = f"{cls} {d:.1f}m" cv2.putText(frame, label, (x1, max(y1 - 8, 12)), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) return framed:.1f保留一位小数,米级精度下够用,写太多位反而显得假。max(y1 - 8, 12)防止框贴顶时文字被裁掉。颜色固定绿色是图省事,多类别时可以按类别映射颜色,但别用太接近的色系,远看分不清。
4. 避坑与排查:测距偏了先看这五处
4.1 距离整体偏大或偏小一倍
现象:所有目标测出来都比实际远一倍或近一倍。原因:焦距 f 和框高 h_pixel 不在同一分辨率尺度。模型输入 640、原图 1280 时,框坐标若没映射回原图,h_pixel 只有真实的一半,距离就翻倍。解决:在 detector 输出后统一把框坐标按原图宽/模型输入宽缩放回原图,再进测距模块;或者反过来把 f 按同比例缩放。我一般固定「测距模块只认原图坐标」这条约定,避免到处换算。
4.2 静止目标距离持续跳动
现象:人站着不动,距离在 7.2~9.8 米之间来回跳。原因:检测框高度逐帧抖动,单帧直接套公式放大成距离噪声。解决:加滑动平均或指数滤波,并配合跟踪维持 ID;同时把conf适当调高,过滤掉边缘低置信框。如果抖动仍大,检查是不是同一目标被两个框重复检测,NMS 的iou调低一点。
4.3 远处小目标距离离谱
现象:30 米外的人测出来 5 米。原因:远处目标框只有十几个像素高,框高误差一两个像素,距离误差就是百分之几十,公式在小 h_pixel 时极度敏感。解决:设一个最小框高阈值(比如 20 像素),低于阈值不输出距离或标注「超出量程」;同时明确这套方案的有效量程,别拿它测几百米外的目标。
4.4 换摄像头后全部失准
现象:同一套代码,换个摄像头距离全错。原因:fx是跟具体相机、具体分辨率绑定的,换设备没重标。解决:每换一个相机或改一次分辨率,重新标定或按比例换算 f;把camera.yaml按设备分文件管理,启动时按设备选择。后悔药是提前在配置里记录image_width/image_height,加载时校验当前帧尺寸,不匹配就告警。
4.5 类别先验高度用错导致系统性偏差
现象:车测出来总是偏近。原因:class_real_height里 car 给了 1.5 米,但场景里多是 SUV,实际 1.7 米以上。解决:按实际场景统计目标高度分布,取中位数而不是拍脑袋;对高度差异大的类别(人、车)增加子类或按框宽高比做二次判断。这条没有代码能救,只能靠场景数据校准。
5. 二次开发:把单目测距改成能落地的模块
5.1 从「框高测距」升级到「地面平面假设」
框高测距的硬伤是依赖目标真实高度先验,人一蹲下就错。更稳的思路是地面平面假设:假设目标接地点在相机下方某个平面上,用相机外参(俯仰角、安装高度)把接地点像素反投影到地面,得到纵向距离。这条路对行人、车辆都更鲁棒,代价是要标外参。改造时在core/distance.py里加一个ground_plane_distance(point_px, K, R, t, plane_height),用cv2.undistortPoints加射线与平面求交,检测侧只取框底边中点作为接地点。两条路可以并存:框高法给粗估,地面法给精估,按场景切换。
5.2 把测距封装成可复用服务
单文件脚本跑通后,下一步是抽成服务:detect_and_distance(frame) -> list[{box, cls, distance, track_id}],输入输出都是纯数据,不碰cv2.imshow。这样同一套逻辑能接 RTSP 流、能接 Web 接口、能接 ROS 节点。我一般会把可视化从核心链路里彻底剥离,核心只返回结构化结果,画图放到独立的展示层,方便做单元测试——给一张固定图,断言某个人框的距离在合理区间,比肉眼看靠谱。
5.3 参数调优的验证方法
改完别凭感觉,做一个小验证集:固定机位,让目标站在 5、10、15、20 米处各拍一段,跑一遍看输出误差。误差在 ±10% 以内算可用,超过就回头查 f、先验高度、框坐标尺度三件事。下面这个表是我常用的记录格式:
| 实际距离(m) | 测距输出(m) | 误差(%) | 备注 |
|---|---|---|---|
| 5.0 | 5.3 | +6.0 | 站立行人 |
| 10.0 | 9.4 | -6.0 | 站立行人 |
| 15.0 | 16.8 | +12.0 | 框高偏小 |
| 20.0 | 24.5 | +22.5 | 小目标,超量程边缘 |
表里 20 米那行就是典型的小目标失效,看到这种数据就该加最小框高阈值,而不是继续调 f。从那以后我每次改测距参数,都强制先跑一遍这个固定距离验证集,再谈效果,不然调参就是玄学。希望这套拆解能帮到你,把手里那份 YOLOv5 单目测距代码真正跑成能用的东西。
本文还有配套的精品资源,点击获取