ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

YOLO V1从原理到复现:目标检测入门第一关

YOLO V1从原理到复现:目标检测入门第一关 1. 第1关到底在学什么如果你打算系统学习目标检测YOLO V1几乎是绕不开的第一站。很多人第一次听到这个名词是在招聘JD里看到“熟悉YOLO系列检测算法”但真到自己上手看论文、跑代码时面对一堆概念——grid cell、anchor、IoU、NMS、mAP——容易一头雾水。这个系列我把YOLO V1当成一个“第一关”来写是因为它虽然已经是2016年的老模型但它把目标检测最核心的问题——怎么做定位、怎么做分类、怎么设计损失函数——用最直白的方式定义清楚了。搞清楚这一关后面再学YOLOv2、v3、v5甚至Faster R-CNN都会轻松不少。YOLO V1全称是You Only Look Once意思是“你只看一次”。在此之前主流检测方案是两阶段先用区域提议网络找出可能包含物体的候选框再对每个候选框做分类和回归。这种做法准确率高但速度慢一张图要几百毫秒甚至几秒。YOLO V1的思路是我不要先找候选区了直接把整张图丢进一个神经网络一次性输出所有物体的位置和类别。这个“端到端”的设计在当时是颠覆性的——它以实时速度做检测GPU上能做到45 FPS轻量版本甚至能到155 FPS。你用今天的眼光看这个数字可能觉得平平无奇但在2016年这基本是把检测从“离线分析”推到了“实时可用”的层面。这关适合谁两类人。一类是刚入门深度学习、想做视觉方向但不知道从哪下手的同学YOLO V1结构简单、代码量小是理解“检测任务怎么用神经网络实现”的最佳样本。另一类是已经跑通分类网络、但对检测没有全局认知的工程师你需要的不是再刷一个模型而是把检测的骨架逻辑吃透。这篇文章我就围绕四个层面展开为什么说YOLO V1把检测变成了一个回归问题、它的网络结构和输出张量是怎么设计的、损失函数里的每个项到底在惩罚什么、以及实际复现时会踩哪些坑。2. YOLO V1的核心设计思路2.1 把检测当作回归问题一个前向过程搞定一切YOLO V1最关键的思维转变是把目标检测从“先提议再分类”的流程中解放出来变成一个纯粹的回归问题。什么叫回归就是输入一张图输出一组连续的数值。这组数值直接编码了所有物体的位置和类别不需要额外的候选框生成步骤。具体来说YOLO V1把输入图片划分成 S×S 个网格每个网格负责检测中心点落在这个格子里的物体。每个格子输出 B 个边界框bounding box每个框包含 4 个位置参数中心点坐标 x、y 和宽高 w、h和 1 个置信度分数同时还要输出 C 个类别概率。整个网络的最终输出就是一个 S×S×(B×5C) 的张量。在VOC数据集上S7B2C20所以输出张量大小是 7×7×30。这个设计的巧妙之处在于它把图像分割和物体检测天然绑定在一起。比如一张 448×448 的图片被分成 7×7 个 64×64 的区域每个区域只看“自己地盘”里有没有物体中心。这种强约束大大简化了检测的难度——每个格子不需要管全图的物体只需要关心局部区域的内容训练起来也更容易收敛。代价是如果两个物体的中心落在同一个格子里这个格子最多只能检测出其中一个这也是YOLO V1对小物体和密集场景不友好的根本原因。后面版本引入多尺度预测和anchor机制就是为了缓解这个问题。2.2 置信度是什么不只是“有没有物体”YOLO V1里的置信度confidence是最容易被初学者误解的概念。它不是一个简单的“概率”而是“这个框里有没有物体”和“框的位置准不准”的综合度量。公式长这样confidence Pr(Object) × IoU(pred, truth)前半部分 Pr(Object) 表示该格子是否包含物体中心有就是1没有就是0。后半部分是预测框和真实框的交并比Intersection over Union用来度量位置预测得准不准。在训练阶段如果一个格子确实包含物体中心那么该格子负责预测的那个框的置信度目标值就是真实IoU如果格子不包含物体置信度目标值直接是0。这意味着模型不仅要学会判断“这里有没有东西”还要学会“我框得准不准”。你在推理阶段拿到一个框想要过滤掉低质量的预测用的就是这个置信度值——小于某个阈值比如0.5的直接丢掉剩下的再做NMS。我当年第一次看论文时一直没想明白为什么置信度要乘IoU后来调训练时才发现如果不这么做模型会倾向于“迷之自信”——哪怕框完全不在物体上也会给一个很高的“有物体”分数。乘上IoU之后模型被迫去精确学习边界框的位置因为它知道位置不准置信度分数就会被打折扣。这是一种非常优雅的“自我约束”设计。2.3 每个格子只能预测一个类别YOLO V1的取舍逻辑YOLO V1对每个格子的类别预测采用的策略是“一格子一类别”——不管是哪个边界框负责检测该格子的类别置信度是共用的。也就是说7×7 个格子最多只能检测出 49 个物体如果每个格子最多一个物体的话并且每个格子只能输出一个类别的预测。这是一个非常激进的简化。它的好处是网络结构简洁输出维度固定训练稳定速度极快。坏处也很明显如果同一个格子里有两个不同类别的物体比如一只猫趴在一只狗旁边中心点都落在同一个格子里那么这个格子只能选择其中一个来预测另一个就丢失了。训练时YOLO V1的处理方式是让这个格子只对“与真实框IoU最大的那个预测框”负责另一个预测框虽然参与计算但梯度会偏向让它的置信度降为0也就是让这个多余的框“闭嘴”。这个取舍在当时是合理的因为VOC数据集的场景相对简单物体密度不高。但放到今天如果你要检测密集场景比如俯瞰图中一排排汽车、超市货架上密密麻麻的商品YOLO V1这个限制会立刻变成硬伤。这也是后来YOLOv2引入anchor、YOLOv3引入多尺度预测的直接动因。3. 网络框架逐层拆解从输入到7×7×303.1 整体结构没有魔法就是卷积加全连接YOLO V1的网络结构设计灵感来自GoogLeNet但比GoogLeNet更直接。全网络共24个卷积层后面接2个全连接层。所有卷积层都用来提取特征全连接层则负责把特征图映射到最终的预测张量。卷积部分主要用 3×3 卷积提取特征用 1×1 卷积做通道压缩和跨通道信息融合。这里有一个细节YOLO V1的所有卷积层都没有采用padding来保持某个固定尺寸而是靠池化层逐步缩小特征图分辨率。输入是 448×448经过5次最大池化步长2特征图尺寸最终降到 7×7通道数到1024。最后接两个全连接层把 7×7×1024 的特征图展平成向量再映射到 1470 维的输出即 7×7×30。从今天的视角看这个结构有一个很明显的问题——最后一个全连接层的参数量巨大。7×7×1024展平后是50176维第一层全连接的权重矩阵就是50176×4096约2亿个参数。这导致模型体积大、容易过拟合也是它后来被全卷积结构替代的原因。不过放在当时这已经是最容易实现、最稳定的方案。3.2 为什么输出是7×7×30每个数值都有含义我建议你动手前先花10分钟把“7×7×30”这个张量的结构彻底搞懂。7×7对应的是输入图片划分的网格数S7。每个格子对应一个长度为30的向量这30个数分成三部分2个边界框的坐标参数共10个数。每个边界框有5个值x、y、w、h、confidence。x和y是边界框中心相对当前格子的偏移归一化到0-1之间w和h是边界框的宽高相对整张图片的比例同样归一化到0-1。20个类别概率。VOC数据集有20个类别每个格子预测这20个类别的概率分布用的是softmax。302×520逻辑一目了然。注意两个边界框共享同一组类别概率。这意味着在同一格子里两个框的分类结果是一样的模型仅仅是提供两个不同的“位置猜测方案”——训练时哪个框与真实框的IoU更大就由哪个框来负责预测该物体。这是一种很朴素的“让多个候选框竞争”的思路可以理解为一种简单的集成策略比只用一个框多一层容错能力。3.3 归一化细节不用你操心但你必须理解YOLO V1的坐标归一化经常被忽略但它其实很关键。做法是x和y用边界框中心相对于所在格子的偏移量除以格子尺寸w和h用边界框的宽度和高度除以整张图片的宽度和高度。这样所有值都在0到1之间避免了因为图片尺寸变化导致的数值尺度问题。为什么x、y要以格子为单位归一化而w、h要以整张图为单位归一化因为格子的位置是已知的中心点偏移只要在0-1之间就能精确定位到格子内部的任何位置。而宽高是描述物体自身大小的量如果也用格子尺寸归一化那不同格子之间就有不同的基准模型会很难学。用整张图做基准物体大小这个物理量就与它的位置无关了模型学习起来更稳定。这个设计细节在复现时不需要你手动处理数据加载时换算好就行但理解它能帮你诊断训练时输出异常的问题。4. 损失函数YOLO V1真正的灵魂4.1 四项损失的构成与权重设计YOLO V1的损失函数是整篇论文最值得反复咀嚼的地方。它由四项组成坐标损失、含物体置信度损失、不含物体置信度损失、分类损失。每一项都有对应的权重系数论文中设置如下。坐标损失的计算方式是对每个格子的每个边界框如果该框负责检测某个物体即与真实框的IoU最大则计算中心点坐标的平方误差和宽高的平方误差。中心点坐标误差直接计算宽高误差要先开平方再计算。这里有一个非常经典的细节w、h的误差项为什么加了一个根号直接原因是为了缓和大小物体对误差的贡献不平衡。假设预测偏差相同一个宽100像素的物体和一个宽10像素的物体前者的绝对误差是后者的10倍损失会被大物体主导。加上根号之后同样偏差在两个尺寸上产生的损失差距会被压缩。你可以自己算一下√100−√90≈10−9.490.51√10−√9≈3.16−30.16虽然差距仍然有但相比100−9010和10−91的10倍差距已经温和很多。这个“和平方误差过不去”的处理方式是YOLO系列一贯的实用主义——不追求理论漂亮只求好用。4.2 不均衡样本的处理为什么要分两个置信度损失目标检测面临一个天然的数据不均衡问题——一张图里大部分区域都是背景只有少数格子有物体。如果所有格子一视同仁地对待模型会迅速学会“偷懒”把所有置信度都预测成0因为这样损失最小、梯度也小。YOLO V1的应对方案是对有物体的格子和无物体的格子设置不同的损失权重。论文中 λ_coord5λ_noobj0.5。也就是说有物体的格子坐标误差会被放大5倍促使模型优先学习物体定位而没有物体的格子置信度误差只会带来0.5倍的惩罚不至于让背景噪声主导训练。此外论文还做了一个“准硬性”的处理在无物体格子的置信度损失中只惩罚那些“对真实框IoU最大的预测框”之外的框。换句话说每个格子有两个预测框如果格子没有物体两个框都要向0回归如果格子有物体只有那个“不负责任”的框需要向0回归而“负责任”的框专注逼近真实框的位置。这种把“唯一负责人”机制写进损失函数的做法比起后来很多复杂的分配策略简单但有效。4.3 类别损失只用平方误差不用交叉熵你可能注意到了YOLO V1的类别预测部分用的不是分类任务常用的交叉熵而是平方误差损失。这是一个反直觉的设计。按理说类别预测是一个典型的多分类问题交叉熵应该更合适。但YOLO V1论文里就是这么干的理由是检测任务的类别通常较少VOC只有20类平方误差也能收敛到不错的结果而且实现更简单。我自己在复现时试过一次把平方误差换成交叉熵结果在VOC上确实能提升一点精度但训练初期更容易出现梯度爆炸。如果你有自己的训练框架可以试试交叉熵但如果是照着论文复现用平方误差就好。这个细节也提醒我们读论文时不要觉得“别人用得好就一定是最好的”很多设计是权衡了精度、稳定性和实现成本之后的结果。5. 从零复现手写一个简化版YOLO V15.1 搭建网络骨架我用PyTorch写了一个精简版YOLO V1没有严格按照论文的24层卷积来而是去掉了一些冗余结构保留了核心逻辑。你可以把它当作一个能跑通的最小实现理解流程后再逐步还原完整结构。import torch import torch.nn as nn class YOLO_V1(nn.Module): def __init__(self, S7, B2, C20): super().__init__() self.S S self.B B self.C C # 特征提取部分4个卷积块逐步缩小特征图到7x7 self.features nn.Sequential( # 448x448x3 - 224x224x16 nn.Conv2d(3, 16, 3, padding1), nn.BatchNorm2d(16), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 224x224x16 - 112x112x32 nn.Conv2d(16, 32, 3, padding1), nn.BatchNorm2d(32), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 112x112x32 - 56x56x64 nn.Conv2d(32, 64, 3, padding1), nn.BatchNorm2d(64), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 56x56x64 - 28x28x128 nn.Conv2d(64, 128, 3, padding1), nn.BatchNorm2d(128), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 28x28x128 - 14x14x256 nn.Conv2d(128, 256, 3, padding1), nn.BatchNorm2d(256), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 14x14x256 - 7x7x512 nn.Conv2d(256, 512, 3, padding1), nn.BatchNorm2d(512), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), ) # 检测头展平后接全连接输出 S*S*(B*5C) self.head nn.Sequential( nn.Flatten(), nn.Linear(7 * 7 * 512, 1024), nn.LeakyReLU(0.1), nn.Dropout(0.5), nn.Linear(1024, S * S * (B * 5 C)) ) def forward(self, x): x self.features(x) # 输入 448x448x3 x self.head(x) # 输出 (batch, S*S*(B*5C)) x x.view(-1, self.S, self.S, self.B * 5 self.C) return x这段代码里有两个细节值得你注意。第一个是使用了BatchNorm这是论文里没有的——论文用的是Leaky ReLU但没有BN复现代码中我加了BN训练速度和稳定性明显提升。第二个是全连接层前加了Dropout比例0.5这是参考论文里的做法。论文说他们用Dropout防止过拟合在训练中确实有效。完整版YOLO V1的卷积层数远多于这个简化版但核心思想完全一致。你先跑通这样一个轻量结构理解数据流再逐步加深网络是个更平滑的学习路径。5.2 数据与标签编码把标注变成7×7×30训练YOLO V1最麻烦的一环不是网络结构而是把数据集的标注信息编码成 7×7×30 的目标张量。我这里以VOC格式的数据为例假设你有每张图的标注包含物体类别和边界框坐标xmin, ymin, xmax, ymax需要转为YOLO V1能用的格式。def encode_label(boxes, classes, S7, B2, C20): # boxes: 一维数组包含多个框每个框为 (cx, cy, w, h)均为归一化坐标 # classes: 每个框对应的类别索引 target torch.zeros(S, S, B * 5 C) cell_size 1.0 / S for box, cls in zip(boxes, classes): cx, cy, w, h box # 计算中心点落在哪个格子 grid_x int(cx // cell_size) grid_y int(cy // cell_size) if grid_x S or grid_y S: continue # 偏移量中心点相对格子左上角的偏移归一化到0-1 local_x (cx - grid_x * cell_size) / cell_size local_y (cy - grid_y * cell_size) / cell_size # 寻找该格子中IoU最大的框作为负责框 best_iou 0 best_idx 0 target_box torch.tensor([cx, cy, w, h]) for b in range(B): pred_box target[grid_y, grid_x, b*5 : b*54] # 初始化时pred_box为0这里用真实框填充后计算IoU iou compute_iou(pred_box, target_box) if iou best_iou: best_iou iou best_idx b # 填入负责框的坐标和置信度 b best_idx target[grid_y, grid_x, b*5 : b*54] torch.tensor([local_x, local_y, w, h]) target[grid_y, grid_x, b*5 4] 1.0 # 置信度 # 类别概率这里用1做硬标签也可以为每个类别赋值1 target[grid_y, grid_x, B*5 cls] 1.0 return target这段代码有两点要特别说明。第一x、y用的是相对于格子的偏移量而w、h用的是归一化全局坐标这两者千万不能搞混。第二上面代码中的 compute_iou 你需要自己实现——它接收两个框的坐标中心点形式计算交并比。实现时注意YOLO V1的x、y是格子内偏移要和全局坐标换算后再算IoU。这个编码函数的性能对你的训练效率影响很大。如果你的数据集有几万张图每次训练都在CPU上跑这个Python循环会非常慢。更高效的做法是提前做成离线文件把每张图的目标张量存成 .npy 或者直接存成pickle训练时只做索引读取。我自己的做法是用多进程并行预处理好所有标签训练时无脑加载。5.3 推理流程从输出张量到画框训练完成后推理过程相对简单核心是三步前向传播得到7×7×30的输出张量解析每个格子的边界框和置信度最后做NMS过滤重叠框。def predict(model, image, conf_threshold0.3, nms_threshold0.4): model.eval() with torch.no_grad(): output model(image.unsqueeze(0))[0] # 7x7x30 S output.size(0) B 2 C 20 boxes [] scores [] classes [] for i in range(S): for j in range(S): # 类别概率对每个格子取最大类别这会得到20类 class_scores torch.softmax(output[i, j, B*5:], dim0) class_score, class_idx torch.max(class_scores, dim0) for b in range(B): conf output[i, j, b*5 4] final_score conf * class_score if final_score conf_threshold: continue # 解码坐标 cx (j output[i, j, b*5]) / S cy (i output[i, j, b*51]) / S w output[i, j, b*52] h output[i, j, b*53] # 转成[xmin, ymin, xmax, ymax]格式 xmin cx - w / 2 ymin cy - h / 2 xmax cx w / 2 ymax cy h / 2 boxes.append([xmin, ymin, xmax, ymax]) scores.append(final_score.item()) classes.append(class_idx.item()) # NMS keep nms(boxes, scores, nms_threshold) return [boxes[i] for i in keep], [scores[i] for i in keep], [classes[i] for i in keep]注意类别分数的处理。论文里用的是每个格子预测一个类别但实际类别预测输出是20维的向量你完全可以用softmax把它变成概率分布再取最大值。有些复现直接用argmax而不做softmax效果差别不大但softmax后分数更平滑和置信度相乘后更容易设定阈值。NMS的实现也不复杂先按分数降序排列然后反复选择分数最高的框删除所有与该框IoU超过阈值的其他框直到没有框为止。我建议你自己手写一遍NMS这是基本操作不要每次都在库函数上调用——手写过一次你才会遇到“框坐标归一化不一致导致NMS失效”之类的坑。6. 训练踩坑实录与排查技巧6.1 训练不收敛、loss为NaNYOLO V1的损失函数设计比较敏感训练时最常遇到的问题就是loss不下降甚至变成NaN。我排查过多次总结出三个高发原因。第一是学习率过大。YOLO V1的损失中坐标项和置信度项尺度差异很大如果学习率太高梯度更新会把参数推到不合理的区域。建议从 1e-4 开始跑2000步看loss下降趋势再做调整。论文里的batch size是64你如果显存不够可以降到16或8但对应学习率也要等比降低。第二是目标张量编码错误。这是最隐蔽的坑。如果你发现loss表面正常但推理时预测框全部挤在图片角落十有八九是x、y的编码方式不对——把全局坐标当成偏移量算进去了。建议在训练前单独写一段测试代码随机构造一个标注框编码成目标张量再解码回来检查是否一致。这一步花10分钟能省下你后面好几天的排查时间。第三是没有做梯度裁剪。YOLO V1的平方误差在训练初期如果遇到极端标注比如某些物体特别大或特别小可能产生非常大的梯度直接把参数炸飞。在优化器更新前加一个 clip_grad_norm_ 是保命操作loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm1.0) optimizer.step()这个操作不会影响模型精度建议默认就加上。6.2 预测框数量惊人、NMS阈值怎么调训练好模型以后你可能发现推理时置信度高于阈值0.5的框还是非常多而且很多框互相重叠得厉害。这说明NMS的作用至关重要。YOLO V1的NMS阈值通常设置在0.4到0.5之间。阈值越低删除的框越多输出越干净但可能会漏掉重叠密集的真实物体阈值越高保留的框越多但可能在一个物体上产生多个重复框。有一种优化策略是按类别分别做NMS先对20个类别分别过滤一遍再合并结果。因为不同类别的物体可以合法地重叠比如一个人骑在自行车上全局NMS可能会把这种合法重叠误删。YOLO V1论文里其实没有强调这一点但在实际应用中很有用。6.3 训练数据增强有限数据下的唯一出路VOC数据集只有一万多张图直接拿来训YOLO V1不做过数据增强很容易过拟合。我在复现时用到的增强手段主要有三种随机缩放对输入图片做0.5到1.5倍的随机缩放模拟不同距离下物体大小的变化。注意缩放后要重新计算所有标注框的坐标。色彩抖动随机调整亮度、对比度、饱和度增强模型对光照变化的鲁棒性。这个对检测任务尤其重要因为真实场景的光照千变万化。随机裁剪随机裁剪图片的一部分然后resize回448×448。裁剪时要注意保留那些中心点仍在图内的物体标注。记住一个原则所有图片变换标注必须同步变换。很多人第一次做检测训练加了随机裁剪却忘记更新标注框导致训练数据里出现大量“错位”标注模型自然学不好。我自己写过一个增强函数输入是图片和标注框列表输出是增强后的图片和框然后用一个断言检查增强后的框是否仍然在图内能有效避免这种低级错误。7. YOLO V1的局限性与后续演进7.1 主要短板小物体、密集场景、定位不准YOLO V1的短板是结构性的。每个格子只能预测两个框并且只能有一个类别这导致小物体和密集场景的检测效果非常差。一个典型例子是检测鸟群几十只鸟聚在一起中心点可能落在同一个格子里YOLO V1最多只能检测出其中一两只其他全部丢失。此外由于输出层的全连接结构对位置编码能力有限YOLO V1在边界框定位上不如两阶段方法精细。从工程角度讲如果你现在的项目只需要检测单个或少量大物体YOLO V1仍然够用。但要处理密集或者尺寸差异大的场景直接上YOLOv5或者更先进的结构更靠谱。这不是说YOLO V1没用而是你应该清楚它的能力边界在哪里。7.2 从V1到V2进了哪些补丁YOLOv2的核心变化有三点去掉了全连接层改为全卷积结构引入了anchor机制预设不同尺度和比例的锚框增加了批量归一化。这三个改动对应的痛点非常明确全连接层导致输出尺寸固定、参数量大没有anchor导致定位不准没有BN导致训练不稳定。你可以这样理解——YOLOv2其实是把V1“砍掉重练”的产物但V1打下的问题定义框架单阶段、网格化、回归式输出被完整保留下来后世的YOLO系列本质上都在这个框架里做迭代。如果你看完这篇文章后想继续学YOLO系列我建议的路线是先复现一遍V1理解检测的基本问题再看V2论文理解anchor是怎么解决定位问题的然后跳到V3理解多尺度特征融合和FPN思路。到了V3这个阶段你基本就有能力阅读任何一篇检测论文了。7.3 学习YOLO V1的正确姿势先手写再调参很多人学目标检测有一个误区一上来就下载一个YOLOv8的预训练模型跑通demo之后觉得自己已经“会检测了”。但这种“会”是黑洞式的——模型为什么输出这些框、损失函数长什么样、遇到问题怎么调完全不了解。等真正在工作中遇到任务只能在网上复制别人的代码出了bug也不知道怎么改。YOLO V1是少有的、适合完全手写的检测模型。论文十几页结构简单损失函数可以直接公式化训练数据也容易获取。我强烈建议你花一个周末从零开始写一遍数据加载、标签编码、网络结构、损失函数、训练循环、NMS全部自己实现。写完之后你会发现再看其他检测模型它们不再是一堆陌生的名词而是你在V1基础上做的各种改良。这个“第一关”过了后面的路就顺了。我自己在第一次跑通YOLO V1训练时花了两天时间排查一个特别蠢的错误——标签编码时把w和h用错了单位导致模型学到的框全都偏到图外。后来一步步打印中间结果才定位到是编码函数的问题。这种经历虽然痛苦但记忆极其深刻以后再写任何检测代码我都会留意坐标系的转换关系。这就是手写模型不可替代的价值你踩过的每个坑都会变成你的能力。在动手过程中你可以重点观察几个现象训练前期的置信度变化趋势、不同格子的预测分布、NMS前后框的数量对比。这些观察能帮你建立对检测模型的直觉而直觉是调参时最宝贵的资产。
返回列表