ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

YOLOv5单目测距:从检测框到距离计算的关键技术与避坑指南

YOLOv5单目测距:从检测框到距离计算的关键技术与避坑指南

简介:YOLOv5结合单目测距的Python项目,面向计算机视觉学习者和PyTorch开发者,解决单个摄像头估计物体距离的实际问题。模型以实时性能与高精度见长,适用于自动驾驶、智能监控、图像分析等场景,整体原理相对简单,适合具备一定深度学习基础、希望上手项目实践的读者。压缩包共132个文件,主体为34个py源码与40个yaml配置,另有pt权重文件、ipynb示例笔记、sh运行脚本及md说明文档,覆盖模型结构、训练参数、推理逻辑与运行环境配置,资源包约13.91MB,已有2293人学习。项目系统梳理了YOLOv5的网络结构、锚框机制、数据增强、训练流程与NMS后处理等核心要点,同时给出单目测距的完整思路,包括物体高度估计、相机参数标定与三角测量距离计算。读者可对照代码理解目标检测与单目几何的融合方法,将预训练模型加载、推理与结果可视化串成完整流程,整体是一份兼顾算法原理与工程落地的实用项目。

1. 单目测距:为什么“近大远小”不能直接换算成米

单目测距看起来有直觉支撑:近大远小,人眼能估距离,摄像头为什么不行?但真正把一个像素高度换算成米的,不是“感觉”,而是相似三角形——检测框在图像里的高度 h、目标真实高度 H、相机焦距 f(像素单位)三者凑出一个三角形,距离 d = H·f / h。这个公式简单到一行就能写完,可它恰恰是整个单目测距里最容易翻车的地方:h 来自目标检测的输出,f 来自相机标定,H 来自对目标的先验设定,三个来源各带一套误差,任何一个出错,距离都会被反比例方式放大。搞懂这条链路的每一环,比背下公式重要得多,这也是为什么市面上的测距 demo 看起来“原理简单”,落到真实场景却永远差一截。

2. YOLOv5 的输出张量:测距真正拿到的输入是什么

很多人写测距代码时,直接拿results.xyxy[0]里的(x1, y1, x2, y2)去算高度,YOLOv5 内部是什么结构完全是个黑匣子。但距离误差的源头,恰恰藏在这个黑匣子里。检测框是从特征图上的预测张量一步步解码来的,框的坐标、高度、置信度最初都是一些分布在网格上的数值,理解了张量结构,才理解“框高 h”这个关键输入的脾气。

2.1 从 640×640 到 (1, 25200, 85):三个检测头的输出结构

以 YOLOv5 v6.1 为例,输入 640×640 的图像,Backbone 提取特征后,Neck(PANet)会输出三个不同尺度的特征图,分别对应 stride 8、16、32。stride 8 的特征图是 80×80,负责小目标;stride 16 是 40×40,负责中目标;stride 32 是 20×20,负责大目标。

每个网格点上预置 3 个锚框,所以总的预测数这样算:

# 三个特征图的网格数之和,再乘每格锚框数 num_anchors = 3 grid_cells = 80 * 80 + 40 * 40 + 20 * 20 # 6400 + 1600 + 400 = 8400 total_predictions = grid_cells * num_anchors # 25200

每个预测向量包含 85 个数值:(x, y, w, h)四个坐标参数,1 个目标置信度,以及 COCO 数据集的 80 个类别分数。推理时 v6.1 会把三个检测头的结果拼成一个[1, 25200, 85]的张量,这就是 YOLOv5 前向推理的直接产出。

值得说明的是,这个 25200 不是随便定的:84 个格子,每格预测 3 个框,意味着同一目标会被很多邻近网格重复预测,后续必须有 NMS 来去重。测距代码拿到的是经过 NMS 后的最终框,但你要清楚,这个框最初是从 25200 个候选里筛出来的,它的“坐标精度”受限于特征图分辨率,不是像素级的精确分割。

2.2 解码、置信度与 NMS:哪些框才有资格参与测距

模型输出的 85 维向量是编码状态,必须解码成真实坐标。v6.1 的 Detect 头解码逻辑大致是这样:

# 以 YOLOv5 v6.1 Detect 头为原型,简化后的解码流程 import torch def decode_prediction(pred, anchors, stride, nc=80): # pred 形状: [B, 3, ny, nx, 85],其中 3 是锚框数 # 把 85 维拆成 坐标(4) + 目标置信度(1) + 类别分数(80) xy, wh, conf = torch.split(pred, (2, 2, nc + 1), dim=-1) # 中心点坐标:sigmoid 后乘 2 再减 0.5,让中心点能偏移到相邻网格 xy = xy.sigmoid() * 2 - 0.5 # 宽高:sigmoid 后平方再乘 2,再乘以锚框尺寸做基准缩放 wh = (wh.sigmoid() * 2) ** 2 * anchors # 加上网格偏移,乘以 stride 换算回输入图像的像素坐标 xy = (xy + grid) * stride return xy, wh, conf

这个解码有两个关键设计:中心点用sigmoid() * 2 - 0.5是为了让预测中心不局限于当前网格内部,可以覆盖网格边界附近的物体;宽高用(sigmoid() * 2) ** 2 * anchors则是以锚框为基准,让预测的宽高始终处于一个可控范围内,避免训练初期梯度爆炸。这些细节决定了框的稳定性——而框的稳定性,直接决定后面测距的稳定性。

解码之后,程序按置信度阈值过滤,再做 NMS 去掉重叠框。测距场景一般只关心特定类别,比如行人、车辆,类别过滤这一步很重要,因为 80 个类别共享一套锚框,不同类别的高宽比差异极大,如果不按类别筛,容易把误检的框也拿去做距离计算。

2.3 检测框质量如何传导到距离误差

这里要建立一个认知:YOLOv5 的边框回归目标是最优 IoU 框,不是物理边缘的精确拟合。目标在图像里只有十几个像素高时,框的高度天然有 ±1 像素的抖动。这 1 个像素在检测任务里无伤大雅,但在距离计算里会被放大——因为距离公式的分母是 h,h 越小,同样的像素抖动造成的距离变化越大。距离 80 米处的行人,检测框高度可能只有 20 像素左右,框高跳 1 个像素,对应距离就是好几米的变化。这就是为什么同一帧图像、同一个模型,有人能算出稳定距离,有人算出来像随机数——问题往往不在模型,而在你对“框高 h 不可控”这一点有没有准备。

3. 相似三角形与相机内参:一个像素高度怎么变成物理距离

上一章说了检测框的来源,现在进入测距的核心几何。很多人以为“标定”就是填几个数,其实焦距 f、主点 cx/cy、像素尺寸这些参数,决定了同一张图里不同的目标位置会得到完全不同的距离结果。这一章把针孔相机模型、高度先验和标定流程串起来讲清楚。

3.1 针孔相机模型与焦距的像素单位

先看基础模型。一个高为 H 的物体,距离相机光心 d,经过针孔成像,在像平面上占据的高度 h 满足:

h / f = H / d

整理得到测距公式:

d = H * f / h

这个公式里的 f 必须是以像素为单位的等效焦距,不是镜头包装上的毫米焦距。实际相机中,物理焦距 f_mm 除以每个像素的物理尺寸 sx,才得到像素焦距:fx = f_mm / sx。竖直方向同理有 fy = f_mm / sy。由于像素往往不是严格正方形,fx 和 fy 会有细微差别。

用检测框的像素高度测距时,应该用 fy,因为 h 是图像竖直方向的高度。用 fx 会引入像素宽高比的偏差,这在畸形标定参数下尤其明显。

# 测距核心公式,height_px 来自检测框 def estimate_distance(height_px: float, real_height: float, fy: float) -> float: # 相似三角形:距离 = 真实高度 * 焦距 / 图像高度 return real_height * fy / height_px

这个公式只有一行,但使用条件极其苛刻:假设目标垂直于相机光轴方向,假设目标底部没有遮挡,假设相机没有俯仰角。任何一个假设被破坏,计算结果就偏离真实值。

3.2 高度先验与距离求解:公式只有一行,前提有一箩筐

H 从哪来?典型做法是给每个类别一个固定先验:行人按 1.7 米、小轿车按 1.5 米、公交车按 3 米。这个“按类别取平均身高”的做法是工程上最常见的,但它天然带误差。行人实际身高从 1.5 到 2.0 米,先验取 1.7 米时,最大相对误差约 15%,这个误差会线性传导到距离上——测 50 米处的人,可能差 7 米。

这里有一个常见的误区:有人试图用目标的宽度替代高度,认为宽度先验更稳定。实际恰恰相反,同样的目标在不同视角下宽度变化剧烈,车辆侧面和正面的宽度差异可以超过一倍。而目标高度在竖直方向受视角影响较小,所以“高度优先”是更合理的选择。但高度先验的缺陷也很明显——它隐含了一个假设:目标在地面上是直立的,且相机光轴与地面平行。

3.3 标定与内参重映射:fx、fy、cx、cy 怎么进公式

获取准确 fx、fy 的标准做法是张正友棋盘格标定,OpenCV 里流程固定:采集不同角度的棋盘格照片,检测角点,再传入calibrateCamera得到内参矩阵。

import cv2 import numpy as np # 假设已经用 1920×1080 的图完成标定,得到如下内参(单位:像素) K = np.array([[fx, 0, cx], [0, fy, cy], [0, 0, 1]], dtype=np.float64) # 若推理时把图像 resize 到 640×640,内参必须按比例重映射 scale = 640 / 1920 fx_resized = fx * scale fy_resized = fy * scale

很多人在这里踩坑:标定用的是原分辨率,推理时为了速度把图缩到 640×640,却直接拿原始内参去算距离,导致整体距离系统性偏小或偏大。内参是“以像素为单位的”,图像尺寸变了,内参必须跟着变。

另一个容易忽略的是主点 cx/cy。测距公式只用到了 fy,没有显式用到 cx/cy,但这不代表主点无关——主点影响的是目标在图像中的位置,进而影响镜头畸变程度和目标是否被完整捕捉。如果目标是画面正中央的物体,直接用简单公式误差不大;目标在画面边缘时,畸变和透视误差都会显著上升。

3.4 地面假设与“框底边”约束:摆脱高度先验的另一条路

固定高度先验的最大问题,是 H 不准确。工程上有一条替代路线:假设目标站在地面上,利用目标检测框的底边在图像中的位置来推算距离,完全不依赖 H。这个思路基于地平线约束:如果知道了相机距离地面的高度 H_cam 和相机俯仰角,那么图像中任意一个“贴近地面”的点,都能通过地面平面与像平面的交线解出距离。

常见的落地方案是离线拟合:在几个已知距离处放置目标,记录检测框底边的 y 坐标与真实距离的对应表,再拟合出一条曲线或单应矩阵。这样“测距”就变成了“查表”,模型不用知道目标的真实高度,只要框底边贴近地面即可。这个方案在自动驾驶和安防场景里很常见,但它要求相机安装位置固定,一旦俯仰角被碰动,整条对应关系作废,需要重新拟合。

4. 数据标签与回归路线:距离要不要交给神经网络自己学

几何法虽然优雅,但它的精度上限受标定和先验限制。于是有人想到:能不能让神经网络直接从图像中回归出距离?这条路看起来更“智能”,实际做起来要面对标签怎么来、回归目标怎么定义、模型会不会像黑匣子一样不可控这些问题。这一章把数据、标签和回归路线梳理出边界。

4.1 标注距离:从激光雷达、GPS 到人工测量

监督学习需要“图像 + 距离”的配对数据。现实中距离标签的来源大致有三种:

第一种是激光雷达或毫米波雷达。点云投影到图像上,取检测框中心对应的那个点云的距离作为标签。要注意时间同步问题:图像帧和雷达帧的时间戳若不对齐,高速运动目标会有一两米的系统性误差。第二种是 RTK 定位。这在车端场景常见,车辆位置已知,目标位置已知,两者相减得到距离,但只对静态目标有效。第三种是人工测量。在场地里划好距离线,让目标站在已知位置拍摄,这是最准的,但标注成本随距离点数线性增长。

标签的质量直接决定回归模型的精度上限。常见的做法是先用几何法粗算一个距离,再人工修正,但这会让模型学到几何法的系统性误差,属于自己给自己喂噪声。

4.2 回归目标的三种形态:直接回归距离、归一化深度、3D中心点

确认了标签来源后,下一个问题是如何定义回归目标。三种常见形态各有优劣:

# 形态一:直接回归距离(最简单的全连接头) # 输入:检测框的像素高度、类别先验、目标在图像中的位置 # 输出:距离 d,损失用 L1 或 Smooth L1 features = torch.cat([log_height_px, class_prior_log, center_offset], dim=-1) d_pred = fc_head(features) # 标量距离 # 形态二:回归几何公式的残差(混合方案) # 先用几何公式算一个初始距离 d_geo,让网络只学修正量 # 优点:网络只需要学"残差",数据量需求大幅下降 residual = fc_head(features) d_pred = d_geo * torch.exp(residual) # 乘性残差,保证输出恒为正

直接回归距离的缺点很明显:模型要同时学到“近大远小”的几何关系,又要学到不同类别的先验,数据量不够时远处样本表现很差。归一化深度是把距离除以一个最大值(比如 100 米),让输出落在 0 到 1 区间,能稳定训练,但实际部署时误差是绝对的还是相对的,需要想清楚。

第三种形态是回归 3D 中心点,也就是把目标在相机坐标系下的 (x, y, z) 都预测出来,再算欧氏距离。这本质上走向了 3D 目标检测:需要额外预测目标的朝向角和物理尺寸,模型复杂度直线上升。这类方案在工业界的 3D 检测任务里比较多见,但受害者是标注成本——3D 标注框比 2D 框贵一个数量级。

4.3 几何法与回归法的分工:什么场景选哪条路

把两条路线放在一起看,边界很清楚:

维度几何法端到端回归法
距离标注需求不需要需要大量配对数据
换相机后的适配重新标定内参即可需要重新采集数据
可解释性高,公式透明低,黑匣子
对先验误差的鲁棒性差,H 不准就不准较好,能学统计规律
远距离表现受像素高度限制受训练数据分布限制
落地速度快慢

实际做的时候,我一般会这么理解“原理比较简单”这句话:它说的不是测距简单,而是几何部分简单。真正的复杂度全在工程环节——标定准不准、先验选得合不合理、输入预处理有没有破坏内参假设。上面表格里“换相机重新标定即可”这一条,是几何法最大的优势,因为很多测距需求场景本身就是固定机位,标定一次能跑很久;而回归法一旦换场景就要重新采数据,调试周期按周算。

5. 常见问题与避坑:为什么你测出来的距离总是飘

前面几章把原理讲完了,这一章专门记录我在复现这类“检测 + 测距”项目时反复踩过的坑。每一条都是先看到现象,再定位到原因,最后给出解决方案。这些坑不会都发生在同一台机器上,但只要你部署到真实场景,大概率会撞上一两个。

5.1 检测框抖一下,距离跳 20%

现象:目标静止不动,检测框在帧间只有一两个像素的抖动,但计算出的距离在 40 米和 48 米之间来回跳,看起来像随机噪声。

原因:距离公式 d = H·f/h 对 h 的导数是非线性的,h 越小,同样的像素误差放得越大。20 米处的行人框高约 50 像素,抖动 1 像素对应约 2% 的距离波动;80 米处框高约 17 像素,同样抖动 1 像素就是接近 6% 的波动。目标检测的边框回归本身就有亚像素级不稳定性,这是模型特性,不是 bug。

解决:不要在原始距离序列上做简单平均,先把 h 做时间维度的平滑,再代入公式计算。工程上常见的是对 h 做 EMA(指数滑动平均)或中值滤波,抖动能压掉大半。更稳妥的做法是平滑距离输出本身,但要注意平滑系数要随帧率调整,30 帧和 15 帧的系数不能一样。

5.2 目标没完整入画,测距翻倍

现象:行人从画面边缘走入时,检测框只有半个身体,此时输出的距离突然比真实值大得多,甚至超过一倍。

原因:框底边在画面之外时,框高 h 被截断,只有真实高度的一半,公式分母减半,距离直接翻倍。YOLOv5 的检测框在目标被截断时会尽量贴合可见部分,但它不知道目标剩下半截在哪。

解决:加一个约束条件,框的底边距离图像下边缘小于某个阈值时,认为目标是“不完整入画”,直接丢弃这一帧的测距结果,或者用前一帧的距离值保持输出。判断条件不要只看底边坐标,还要考虑目标类别——行人在画面下沿被截断很常见,车辆只有底部很小区域被遮住时影响不大,因为车的高度方向不一定从底边算。

5.3 换了分辨率,整条距离曲线平移

现象:同一段视频用 640×640 推理结果正常,换成 1280×1280 推理后,所有距离整体变大一截或缩小一截,且远近目标的偏差比例不一致。

原因:内参没有随输入分辨率重映射。标定得到的内参是“像素单位”,如果标定时的原图是 1920×1080,推理时缩到 640×640,fx 和 fy 需要乘以同一个缩放比例。更隐蔽的是,如果预处理里先做了 Letterbox 填充,图像是缩放到 640×640 再补灰边,那内参缩放比例还要考虑灰边的影响。

解决:把内参重映射和数据预处理放在同一个函数里,每次修改分辨率必须同步修改内参。我的调试习惯是写一个自检函数:输入一张已知距离的实拍图,打印出“当前分辨率、当前 fx/fy、计算距离、真实距离”四个值,任何预处理改动后都跑一遍这个自检,比查半天日志快得多。

5.4 广角镜头边缘,针孔模型失效

现象:目标位于画面中心附近时测距还算准,移到画面边缘后,距离偏差明显变大,有的偏近有的偏远,没有固定规律。

原因:针孔相机模型只对无畸变镜头成立。广角镜头或运动相机有显著的桶形畸变,画面边缘的像素位置被扭曲,检测框的高度 h 不再满足相似三角形关系。标定能修正这个问题,但前提是推理时先对图像做去畸变处理。

解决:推理前调用cv2.undistort或等效的去畸变处理,让送入 YOLOv5 的图像接近针孔模型。注意去畸变也会改变图像尺寸和内容,内参要使用cv2.getOptimalNewCameraMatrix返回的调整后内参,不能拿原始内参继续用。如果目标一直是画面中心区域,可以直接忽略边缘误差,但要清楚这个适用边界。

5.5 相机俯仰角一变,所有距离一起错

现象:设备安装时测距正常,过了几天或换了个安装位置后,同样的目标在同样的图像位置,距离却整体偏差。

原因:直接测距公式隐含了相机光轴与地面平行的假设。一旦相机有俯仰角,图像中的目标位置和实际距离的关系会偏离相似三角形的理想条件。俯仰角 3 度,对 50 米外的目标就能造成数米的误差,且这个误差随距离非线性变化。

解决:如果是固定安装场景,离线标定一次俯仰角,把地面平面约束加进公式;如果是可动摄像头,几何法基本不可用,只能切换到 3D 目标检测或用双目方案。判断方法很简单:在画面不同位置放两个距离已知的目标,如果近处准、远处偏,大概率是俯仰角问题而不是内参问题。

6. 设计取舍:几何先验、误差放大与工程上限

6.1 误差放大倍率:远处一个像素值多少米

把测距公式对 h 求导,能得到一个很直观的结论:∂d/∂h = -H·f/h² = -d/h。像素高度 h 越小,d 越大,分母越小,导数值越大——同样的 1 像素误差,在远处被放大成好几米。这不是某个环节没做好,而是单目测距的物理天花板:远处的目标本身就只占几个像素,信息量不够,任何算法都救不回来。所以评估一个测距方案时,先算一下目标在图像里占多少像素,如果只有十几个像素,盲目追求“更准”没有意义。

6.2 怎么权衡:我的选择与一条血泪教训

几何法和回归法之间的取舍,核心指标是数据成本和稳定性。我的选择标准是:没有高质量距离标注数据时,老老实实用几何法,把精力花在标定和安装上;有激光雷达或丰富标注数据时,优先做“几何公式 + 残差回归”的混合方案,让网络只学修正量,而不是从零学物理。纯端到端回归测距,看起来美好,但数据偏差会让模型在不同场景之间彻底失效,调起来极其折磨。

最后说一条我自己的血泪经验。某次调试一个固定机位的测距 demo,A同学在预处理里加了 Letterbox 逻辑,图像从 1920×1080 压到 640×640,但标定内参按老流程从原图直接取的,没有乘缩放比例。结果所有距离系统性偏大 3 倍,查了一个晚上,最后把预处理前后的图像对应像素点一对比,才发现是分辨率换算漏了。从那以后我每次做测距实验,都强制打印“标定分辨率 / 当前分辨率 / fx / fy”四个日志,并且固定用一张已知距离的实拍图做回归测试,任何预处理改动都必须先过这一关。这个习惯帮我挡掉过很多次翻车,也希望帮到你。

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

RELATED READING

延伸阅读

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