
如果你一路从YOLOv8、YOLOv9这种堆满trick和工程化细节的版本往前翻翻到2015年的那篇《You Only Look Once: Unified, Real-Time Object Detection》第一反应通常是就这么简单说实话我第一次完整读完YOLO V1论文时的感受是——这也太直球了。没有RPN没有ROI Pooling没有anchor连Feature Pyramid都没有一个网络端到端跑一次直接输出检测框和类别。这篇东西不是我复述论文而是我自己啃V1时整理的笔记和踩坑记录。我会把7×7×30这个输出张量到底怎么来、损失函数那五个项为什么那样拼、训练阶段有哪些反直觉的设定、推理时怎么从张量还原成边框以及V1的命门和对后续版本的铺垫一次性讲透。适合刚入门目标检测、或者看了不少YOLO教程却总觉得差一层窗户纸的朋友。看完这关你再去碰V2、V3、V5、V8会发现很多设计思路的源头都在这里。1. 检测慢在哪YOLO V1把两步并成一步在V1出来之前主流目标检测的玩法是区域提议分类两阶段流水线。R-CNN先用Selective Search从一张图里选出大概2000个候选区域然后逐个缩放、逐个过CNN分类。这个流程的问题不用多解释候选区域本身就和分类网络割裂一张图跑下来要几十秒。Fast R-CNN把特征提取和分类合并到一个网络里速度上来了但结构还是分两步依然不是实时。Faster R-CNN引入了RPN第一次让候选区域也变成网络的一部分检测速度到了7 FPS左右这在当时已经算里程碑了。但你看它的骨架RPN先粗调一轮ROI Pooling再细调一轮两个阶段共享特征又各算各的损失。本质上在哪里和是什么仍然是两个子任务只是被塞进了同一个网络里。YOLO V1的作者Redmon做的第一件事就是把目标检测彻底重新定义成一个回归问题。图像进去网络直接输出坐标和类别概率不存在先提议一堆区域再判断这个中间步骤。You Only Look Once的核心就是图片只看一次检测结果一次给全。这个转向带来三个隐藏红利很多教程不拆开讲。第一是速度。检测不再是多阶段协作而是一次前向传播Titan X上45 FPSFast YOLO版本甚至到155 FPS直接把实时检测拉到新档次。第二是全局上下文。因为网络从头到尾看完一整张图才做判断所以它天然知道车应该在路上、不应该是飘在空中的。相比之下基于候选区域的方法每个区域是独立过分类器的容易出现把背景纹理误判成物体的毛病。第三是特征端到端学出来的没有手工设计的中间产物。整条链路直接优化最终指标设计上极度统一。我在这儿补一个当时检测范式的对比表方便回顾方法是否两阶段实时性结构性特点R-CNN是极差秒级候选区域逐张过CNN重复计算严重Fast R-CNN是中等0.3 FPS左右全图卷积一次ROI层面做分类回归Faster R-CNN是约7 FPSRPN取代Selective Search端到端可训练YOLO V1否45 FPS单网络直接回归坐标和类别现在你可能觉得单阶段回归是常识但2015年这个想法是反直觉的。当时主流看法是检测需要精细化而YOLO赌的是用足够强的特征提取器一个统一的输出张量也能扛住定位和分类两件事。事实证明赌对了。2. 7×7×30的静态语义网格、框与类别概率怎么排布理解了单次回归这个总纲接下来最要紧的就是搞清楚网络的输出到底是什么。YOLO V1的输出是一个7×7×30的张量。七个格子乘七个格子一共49个grid cell每个cell有一个30维向量。这30维由三块拼起来2个bounding box的参数加上20个类别的条件概率。为什么是7×7因为网络对输入图总共做了64倍下采样448除以64正好等于7。7×7就是最后一层特征图的空间分辨率网络直接在这么粗的网格上做预测。那每个cell对应到原始图像多大一片区域448除以7每个cell大概对应64×64像素。YOLO对网格的约定非常朴素如果一个物体的中心点落在某个grid cell里这个cell就负责预测它。这里的中心点落在哪个cell是指物体标注框的中心坐标在7×7网格中的位置而不是说物体整体必须在cell内部。一个大卡车横跨很多cell但只要它的中心点落在其中某一个cell里就由那个cell负责。那一个cell的30维具体怎么拆看下面这张表维度范围内容说明0-4box1的x, y, w, h, confidencex/y是相对本cell的偏移w/h是相对整图的比例5-9box2的x, y, w, h, confidence同上10-2920个类别条件概率P(类别注意一个容易混淆的细节20个类别概率是整格共享的不是每个box各有一套。这意味着无论一个cell预测几个box这些box最后都只能归到同一个类别。换句话说每个cell最多回答这格子里有什么类别的物体但回答不了这格子里同时有车和行人。这直接埋下了V1对密集小目标检测能力弱的种子。再说说这个7×7网格的感受野问题。你可能担心一个网格只看自己那64×64像素的范围怎么判断物体。其实完全不是这样。卷积层堆叠到最后一层每个位置的有效感受野已经覆盖了输入图的很大一块区域7×7特征图上的每个点看到的是全图级别的信息。所以它虽然叫grid cell但决策根本不是局部的。这也就解释了为什么YOLO对背景误检不敏感因为它判断一个区域是不是物体时参考的是全局上下文而不是抠一个小patch。这里有个实用的经验当你打印或可视化7×7×30这个张量时不要用图像上的49个方块去理解它而要把它想成49个带全图视野的预测头。每个预测头只负责输出自己网格区域内的物体但参考特征是全图的。理解这一点后面看损失函数你才会明白为什么一个cell的类别概率可以采用共享形式。3. 从224到448再到检测头网络结构为什么这样搭YOLO V1的网络骨架设计直接继承自GoogLeNet的思路整体是24层卷积加2层全连接。它没用当时已经很流行的ResNet也没有复杂的分支结构就是规规矩矩的卷积堆叠配合1×1卷积做通道降维再穿插3×3卷积做特征提取。1×1卷积在这个结构里不是可有可无的。它的作用有二一是压缩通道数减少后续3×3卷积的计算量二是增加非线性让网络在同样的空间分辨率下有更强的表达能力。这种降维-提取的小单元你在后来的MobileNet、EfficientNet里都能看到影子。网络的前20层是为ImageNet分类设计的输入是224×224。训练策略是先拿这20层和一个全连接分类头在ImageNet上做预训练让卷积层学到通用的视觉特征。做检测的时候把分类头摘掉接上4个卷积层加2个全连接层输入分辨率也从224拉高到448。为什么输入分辨率要翻一倍?这里有个很实际的原因:YOLO V1没有多尺度机制最后就只有7×7这一档特征图。如果输入还是224下采样64倍之后只有3.5×3.5网格直接没法用。把输入提到448最后的特征图才勉强是7×7。对当时的显存来说这个分辨率已经是安全偏大的极限了。整个前向过程的尺寸变化是这样的:448×448×3输入经过若干步长为2的卷积和池化一路降到28×28最后一步卷积以步长2把分辨率压到14×14……不对我再按实际的网络过一遍输入448经过第一个7×7步长2卷积变成224再池化变112第二次池化变56第三次池化变28最后一次步长2卷积变14……等一下准确说是第三次池化后是28×28然后接几组1×1和3×3卷积再经过一个卷积步长2降采样到14×14最后继续卷积到7×7。那是一组步长2卷积把14降到7而不是14直接。总之最终特征图是7×7×1024。我之前在复现时被这些数字绕晕过提醒你一句不要死记每层的输出尺寸记住64倍下采样这个结论然后按输入尺寸除以64验证即可。7×7×1024的特征图随后被展平成向量送进第一个4096维全连接层再加一层全连接映射到1470维——7×7×30等于1470。这里有一个值得展开思考的设计点为什么要加全连接层而不是直接在7×7特征图上接1×1卷积输出7×7×30全连接层把整个特征图摊平了再做映射意味着每个位置的最终预测都参考了全图所有位置的特征。等于是全局决策。好处是能利用上下文坏处是牺牲了位置特异性——这也是为什么V1在定位精细物体上偏粗的原因之一。后面V2改成省略全连接、直接用卷积做检测头损失了一部分上下文建模但换来了更强的空间保真度。有得必有失这是检测头设计里一个值得反复品味的权衡。用一个生活化的类比全连接检测头就像你从高楼俯视整个操场一眼扫过去能大概知道东边有个球门、西边有几个人但要你说出每个人的精确动作就有点困难了。后来V2改成卷积检测头相当于把相机靠近地面每个位置只专注自己的局部区域精确度上去了但全局信息少了一点。两种策略各有利弊V1选择了前者。下面是一个简化版的PyTorch网络定义省去了中间若干重复block但整体结构和论文是对得上的import torch import torch.nn as nn class YOLOv1(nn.Module): def __init__(self, S7, B2, C20): super().__init__() self.S S self.B B self.C C self.features nn.Sequential( # 448 - 224 nn.Conv2d(3, 64, kernel_size7, stride2, padding3), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 224 - 112 nn.Conv2d(64, 192, kernel_size3, padding1), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 112 - 56 nn.Conv2d(192, 128, kernel_size1), nn.LeakyReLU(0.1), nn.Conv2d(128, 256, kernel_size3, padding1), nn.LeakyReLU(0.1), nn.Conv2d(256, 256, kernel_size1), nn.LeakyReLU(0.1), nn.Conv2d(256, 512, kernel_size3, padding1), nn.LeakyReLU(0.1), nn.MaxPool2d(2, 2), # 56 - 28 # 中间还有整段重复的1x1 3x3 block这里省略 nn.Conv2d(512, 1024, kernel_size3, padding1), nn.LeakyReLU(0.1), nn.Conv2d(1024, 1024, kernel_size3, stride2, padding1), # 28 - 14 nn.LeakyReLU(0.1), nn.Conv2d(1024, 1024, kernel_size3, padding1), nn.LeakyReLU(0.1), nn.Conv2d(1024, 1024, kernel_size3, padding1), # 14 - 7 nn.LeakyReLU(0.1), ) self.head nn.Sequential( nn.Flatten(), nn.Linear(1024 * S * S, 4096), nn.LeakyReLU(0.1), nn.Dropout(0.5), nn.Linear(4096, S * S * (B * 5 C)), ) def forward(self, x): x self.features(x) x self.head(x) # 最终形状: (N, S, S, B*5 C) return x.view(-1, self.S, self.S, self.B * 5 self.C)第一个全连接层后面的Dropout不是随手加的后面的训练部分我会专门讲它的作用。你只要记住在V1里全连接层承担了非常重的输出规划任务它是一个参数量巨大的映射器把7×7×1024这种高维特征压缩成1470个预测数值。4. 五个损失项的取舍坐标、宽高、置信度与类别各算各的账V1的损失函数是整篇论文里最值得反复读的部分。它没有用Faster R-CNN那种分阶段的平滑L1或交叉熵组合而是朴实到极致——sum-squared error也就是所有误差项的平方和直接加起来。但朴实的公式背后有一个严重问题一张图片里大部分grid cell是没有物体的。如果对每个cell都同等看待模型会把绝大多数精力花在学会预测没有物体上最终所有置信度都会往0坍缩。所以作者给损失函数加了两个权重λ_coord5和λ_noobj0.5。具体损失拆成五个部分第一是中心坐标损失。对每个负责检测物体的box惩罚预测中心点和真实中心点的x、y误差。这里的x、y是归一化后的相对偏移范围在0到1之间所以平方误差不会因为数值尺度失衡。第二是宽高损失。同样是对负责的box但这里预测的是w和h的平方根而不是直接回归w和h。为什么要开根号这是V1里一个非常精巧的设计。它的逻辑是同样的绝对误差对小目标的影响远大于大目标。比如一个100像素宽的物体预测成90像素人眼基本看不出来但一个10像素宽的物体预测成9像素目标已经明显缩水了。开根号之后同样大小的绝对误差在小物体上会产生更大的梯度相当于自动给小框的误差加了更高的惩罚权重。我举个例子算给你看。假设某大框真实宽度100预测90绝对误差10。平方根误差是(√100-√90)²约等于0.263。再假设一个小框真实宽度10预测9绝对误差同样为10……不对这里应该是真实10预测9绝对误差是1。要公平对比应该用绝对误差相同的场景真实100预测90和真实10预测9前者绝对误差10后者绝对误差1这样不公平。重新来真实100预测90绝对误差是10真实10预测0绝对误差也是10——但后者完全不可比。所以更合理的对比例子应该是真实100预测90绝对误差为10真实10预测9绝对误差为1。两者相对误差都是10%但平方根误差前者是0.263后者是0.0263大框的误差惩罚反而更大。你看开根号不是单纯地放大对小框的惩罚而是让惩罚和物体的相对尺度对齐小框的相对变化在sqrt空间里会导致更大的梯度方向变化这有利于网络更谨慎地预测小尺寸。我实际计算时更喜欢用相对误差去理解开根号让损失在相对尺度上更加均衡这就是它的作用。第三是含物体置信度损失。置信度目标不是简单地设为1或0而是预测框和真实框的IoU。也就是说模型被要求输出一个这个框有多可靠的数值。这个设计很巧妙网络如果框位置预测得准置信度目标自然高预测得偏置信度目标就低相当于置信度和定位质量绑定了。当然在实现时为了反向传播稳定这个IoU目标通常会被detach只把它当作回归目标不求梯度经过它。第四是不含物体置信度损失。所有不含物体的cell其两个box的置信度目标都是0但权重只有0.5防止这种大量负样本冲淡正样本的贡献。我记得第一次自己复现时忘了给这一项加权结果训练出的模型把所有框的置信度都压到接近于0检测完全废掉。这个权重不是调参调的是作者对正负样本严重不平衡的直接回应。第五是类别损失。只有包含物体中心的cell才参与两个box共享同一个类别预测所以是每格一次误差。这里用平方误差而不是交叉熵纯粹是作者图省事——分类当作回归做效果影响不大而且实现简单。损失函数还有一个关键的分配规则如果一个cell里有物体但该cell预测了两个box到底哪个box负责回归答案是选和真实框IoU更大的那个box。这就是所谓的responsible predictor。另一个box虽然也在这个cell里但不对这个物体的误差负责只在置信度上损失。网络训练时每个物体的梯度只流经一个框这个规则避免了两个框同时拉扯导致训练震荡。注意一个容易被忽略的细节置信度的target是动态的它在每个iteration随着预测框的变化而变。不像分类任务里标签是固定的YOLO的置信度标签依赖当前预测框与真实框的IoU所以你在实现loss时要现算现用不能用预计算好的IoU表。5. 训练YOLO V1的那些反直觉设定V1的训练流程里藏着好几个反直觉的设定这些细节直接决定模型能不能收敛比网络结构本身更值得玩味。第一两个阶段训练分类预训练加检测微调。前20层卷积先在ImageNet上做1000类分类预训练得到通用视觉特征。然后摘掉分类头接上4个卷积层和2个全连接层再在VOC检测数据上训练。为什么一定要预训练因为检测数据集规模小从头训练一个24层的深网络容易过拟合而ImageNet这种百倍规模的数据集能先把底层特征学扎实。这不是V1独有的思路而是那个年代迁移学习的标准操作但V1把预训练权重这一步做成了检测模型的标配。第二分辨率跳跃分类阶段用224×224检测阶段用448×448。很多人不理解为什么突然翻倍。原因我们前面说过分类任务对空间细节要求不高224足够检测要定位需要更高分辨率来保留小目标的像素信息。更重要的是7×7这个最终网格分辨率只有在448输入下才能得到。用预训练好的224权重直接跑到448输入上网络不会崩因为卷积是滑窗的对输入尺寸不敏感只是计算量变大。第三warmup。作者在训练的初始阶段先把学习率从0.001线性升到0.01然后再按分段策略衰减。第一个epoch用低学习率是为了让网络在权重还没有适应检测任务时不要大幅震荡。这个技巧现在已经是标配但在当时算是个相当务实的经验。第四激活函数的选择隐藏层全部用leaky ReLU斜率0.1最后一层用线性激活。这里有个容易踩的坑如果你把最后一层也接上sigmoid或者ReLU置信度和坐标的输出范围就会被限制但原版的x、y其实没有强制限制在0到1之间——虽然论文里说x和y是相对cell的偏移所以理应在0到1但实际上网络输出没有做这个约束是靠损失函数在引导它往那个范围走。很多现代复现会给最后一层加sigmoid效果也不错但严格说这不是原版实现。我自己复现时两种都试过sigmoid版本训练更稳但原版在推理时表现更生猛对超出网格的预测框处理得更激进。第五数据增强。论文用了随机缩放、平移、调整饱和度、调整曝光度。最大缩放比例20%最大平移比例2%饱和度调整最多1.5倍。这些参数看起来不起眼但对检测模型鲁棒性的帮助非常明显。尤其是随机平移让目标中心不再是总在网格中心网格分配规则在训练中不断被刁难泛化性才好。第六Dropout接第一个全连接层后比例0.5。全连接层参数量大是过拟合重灾区。当时没有BNDropout就是稳住训练的主要手段。你如果做现代复现把Dropout换成BN或者直接删掉头部的Dropout效果也能接受但必须保证整体正则化力度到位。第七优化器参数SGDmomentum0.9weight decay0.0005。学习率分段第一个epoch从0.001升到0.01之后0.01跑一段时间再降到0.001最后0.0001微调。我复现时把batch设为64总epoch大约135个。如果你显存不够batch降到16或8也可以跑但要记得同步降低学习率否则会发散。我踩过这个坑直接复用论文的0.01初始学习率配batch8loss前十几个iteration直接飙到NaN。总结一个表方便抄作业训练设定数值/策略我的备注预训练ImageNet前20层通用视觉特征迁移检测输入尺寸448×448分辨率翻倍适配7×7网格隐藏层激活Leaky ReLU(0.1)防止死亡神经元最后一层线性激活输出范围不加约束优化器SGD, momentum0.9不要轻易换Adam效果未必好学习率0.001→0.01→0.001→0.0001分段式衰减权重衰减0.0005控制全连接层过拟合Dropout0.5接在第一个FC后全连接层的必要正则数据增强随机缩放20%、平移2%、HSV扰动对泛化帮助极大6. 从7×7×30到检测框推理阶段的后处理链路训练完模型之后推理阶段的输入是一张任意尺寸的图先缩放到448×448过一遍网络拿到7×7×30的输出。接下来几个步骤把它还原成最终的检测框。第一步是解码。每个cell内部把30维向量切成两段前10维是2个box的x、y、w、h和confidence后20维是类别概率。x、y是相对当前cell的偏移量要还原成整图坐标需要乘以7再乘以原图缩放比例——实际操作中通常直接换算成相对于输入图像的坐标。w、h是相对整图的比例直接乘输入图像的宽高就得到实际尺寸。第二步是算每个框的class-specific confidence score。也就是把每个box的置信度和20个类别概率逐项相乘得到一个20维的向量。这个乘积的含义是这个框包含一个物体并且物体属于第c类的概率。如果不乘光看置信度你不知道框里是什么光看类别概率你又不知道这个框可不可信两者相乘才是完整的检测分数。第三步是阈值过滤。一个7×7×30的张量在解码后会产生98个候选框49个cell乘以每个cell两个box每个框有20个分数。把其中所有低于阈值的分数清零常见的分数阈值在0.2到0.3之间。这一步能去掉大量背景噪声。如果你发现检测结果里有大量低置信度的重复框压低了mAP表现就适当调高阈值。第四步是NMS也就是非极大值抑制。NMS的目的是把多个重叠在一起的框合并成一个。一个物体可能被相邻cell的两个框同时框住也可能一个cell的两个框都在物体附近这时你需要保留分数最高的那个框抑制掉和它IoU超过阈值的其他框。NMS要逐类别执行不能跨类别做。因为两个不同类别的物体可能高度重叠比如人和他骑的车如果在所有类别一起做NMS高分的人框会把自行车框抑制掉就漏检了。正确的做法是对每个类别把所有框按分数降序排列取最高的框剔除与它IoU超过0.5的同一类别的其他框重复这个过程。我给出一个简化的PyTorch实现思路def nms(boxes, scores, iou_threshold0.5): boxes: (N, 4) 格式为 [x1, y1, x2, y2] scores: (N,) order scores.argsort(descendingTrue) keep [] while order.numel() 0: i order[0] keep.append(i) if order.numel() 1: break ious box_iou(boxes[i].unsqueeze(0), boxes[order[1:]])[0] mask ious iou_threshold order order[1:][mask] return keep实际工程中还有各种NMS变体比如Soft-NMS、Weighted Boxes Fusion但在V1这个时期标准NMS已经足够。NMS虽然是后处理不影响训练但它对最终精度的影响非常可观。我在复现时多次故意跳过NMS观察效果输出简直没法看——同一个目标周围能冒出一圈重叠框。关于decode时还有一个细节yolo v1在代码实现里通常会把网络的裸输出先经过一个sigmoid尤其是confidence和类别概率部分。因为在训练时loss计算前的confidence和class概率也被sigmoid处理过推理阶段要保持一致。有的实现还会对x、y做sigmoid这就并非原版行为了但只要你的训练和推理保持一致它仍然能work。我自己更倾向对conf和cls做sigmoid对x、y不做这样更贴近Darknet原版。整个推理链路合起来就一句话一次前向一个张量解码过滤NMS输出最终框。没有候选区域没有第二次前向这就是V1快的全部秘密。7. 回看YOLO V1强项、命门与后续版本的引子到了这一步V1的完整拼图你已经看完了。站在今天回看它的成绩单是VOC 2007上mAP 63.4%Titan X上45 FPSFast YOLO版本mAP降到了52.7%但速度来到155 FPS。作为对照当时的Faster R-CNN在VOC 2007上mAP大约73.2%但只有7 FPS。YOLO V1用接近14个点的mAP换来了6倍以上的速度提升而且它是唯一能做到实时推理的端到端检测方案。有趣的是错误模式的差异。论文里专门做了错误分析YOLO V1的错误更多集中在边界框定位不准但背景误检率远低于Faster R-CNN。这正好呼应了V1全图推理的设计它对哪里没有物体的判断非常自信但对物体具体边界在哪却很粗糙。所以后来V2、V3的核心优化方向之一就是定位精度比如引入anchor机制、引入多尺度预测。V1的命门有几个我按严重程度排一下。第一个命门是网格太粗7×7一共49个cell每个cell理论上只能预测一个类别。一群鸟、一排人、一列密集的小车如果它们的中心点落在同一个cell里网络直接认输。这个缺陷在小目标和密集场景下非常致命也是V1被后来者超越的关键原因之一。第二个命门是小目标基本靠猜。448×448输入经过64倍下采样最终特征图上一个小目标的像素信息可能只剩一两个点类别概率和坐标回归都很难做准。第三个命门是长宽比泛化弱。训练集中如果都是横着的卡车、竖着的行人模型遇到旋转过的、长宽比极端的物体就容易失手。因为V1直接回归w和h的绝对值相对整图不像后来的anchor那样提供预设尺度和比例作为先验。第四个命门是定位精度粗。全连接检测头虽然能利用全局上下文但它把空间信息压扁后再展开精细位置信息有损耗所以V1的框经常贴不紧物体边缘。V2直接针对这些命门开刀引入anchor box强化长宽比适应性加BatchNorm稳定训练用多尺度训练提高对不同尺寸目标的鲁棒性把全连接检测头改成卷积检测头提升定位精度。V3又把单尺度检测变成多尺度特征金字塔进一步解决小目标问题。你回头再看V1会发现后面每一个版本的改进点几乎都能在V1的缺陷清单里找到对应项。所以我的建议是如果你真想把YOLO整个家族学透别急着跳到最新的v8先把V1的最小系统跑通。用现成的PyTorch复现项目跑一次推理动手可视化7×7×30张量亲手把NMS的重复框一个个消掉比背十个yolo版本的改进点都管用。我自己这几年翻检测器源码的一个习惯是每次遇到一个新设计先回YOLO V1里找它对应要解决的老问题。网格怎么分、框怎么定义、目标怎么分配、损失怎么组合、后处理怎么做——这套思维框架到现在都没过时。V1就像一个最小可用的坐标轴原点后面所有版本都是在这个原点上的偏移量。如果你想把自定义损失丢进检测器里做实验V1也是最容易下手的地方网络结构极小loss代码几百行搞定训练数据只用VOC也能跑出不像样的但可观察的结果。等你在V1上把数据流、梯度流、标签分配这些基本功练扎实了再进第2关看V2的anchor机制你会觉得豁然开朗。