资讯动态

YOLOv3代码深度解析:从网络结构、损失函数到后处理实战

发布时间:2026/10/20 8:42:49 来源:尧图企业网站定制
1. YOLOv3代码该怎么看先别急着读源码先建立预期每次有人问我“怎么啃YOLOv3代码”我都先说一句话别把YOLOv3当成一个算法把它当成一套生产环境里的流水线。代码里有网络定义、有损失函数、有训练循环、有预测解码这些模块的难度和重要性完全不一样。如果按顺序从头读到尾大概率读三天就放弃了如果先建立预期知道每条代码路径是干什么的再上手就会快很多。这次要聊的“YOLOv3代码”是目标检测学习路线里非常经典的一节课。它的核心价值在于YOLOv3虽然已经不算最新但它的代码结构非常完整——从数据加载、模型构建、损失计算到后处理解码几乎覆盖了目标检测所有核心环节。读懂它之后再去看YOLOv5或者YOLOv8会觉得轻松不少。围绕这份代码我重点讲三件事网络结构在代码里是怎么组织出来的、损失函数为什么长成那样、预测结果是怎么从特征图变成真实框的。这三件事都搞明白YOLOv3代码就算是真正读通了。先看一份典型的YOLOv3代码目录结构。不同版本略有差异但大体是这些模块cfg模型配置文件通常是.cfg格式定义网络结构data数据加载、数据增强、标注文件解析models网络模型定义、损失函数utils工具函数包括置信度过滤、NMS后处理等train.py / detect.py训练入口和推理入口读代码的建议顺序不是从上往下而是先读cfg文件再读模型定义再读损失函数最后读后处理。为什么要这个顺序因为cfg文件是用文本方式描述网络结构读起来最快能让你对整体骨架有印象。模型定义代码是把这个文本结构翻译成具体的运算层。损失函数告诉你模型学什么。后处理告诉你模型怎么输出结果。这个顺序是从静到动、从结构到逻辑的符合人的认知习惯。2. cfg文件不是配置是网络结构的图纸YOLOv3的网络结构是用cfg文件描述的。这个东西很多人会忽略觉得它就是一堆参数设置实际上它是整个代码的第一份核心素材。一个典型的YOLOv3 cfg文件长这样[convolutional] batch_normalize1 filters32 size3 stride1 pad1 activationleaky [shortcut] from-3 activationlinear [route] layers -1, 61 [yolo] mask 0,1,2 anchors 10,13, 16,30, 33,23, 30,61, 62,45, 59,119, 116,90, 156,198, 373,326 classes80 num9 jitter.3 ignore_thresh .7 truth_thresh 1 random1注意几个关键点。2.1 [convolutional]块是网络的基本单元YOLOv3没有复杂的残差结构写在Python代码里而是用多个[convolutional]块堆叠出来的。每个块就是一个卷积层包括卷积核数量filters、卷积核大小size、步长stride、padding设置pad、激活函数activation。Darknet-53骨干网络就是一堆这样的块叠出来的中间穿插着shortcut和route。读cfg文件时把这些数值串起来网络深度、每层通道数就全出来了。2.2 shortcut和route是跨层连接的表达方式shortcut表示把当前层之前的某一层输出加过来对应残差连接from-3表示把前3层的输出和当前层输出相加。route表示特征融合layers-1, 61表示把前一层和第61层的输出拼在一起对应YOLOv3里最重要的特征金字塔融合。很多人读YOLOv3代码时被这两个操作搞晕。我建议在纸上画一遍数据形状变化。比如输入是416x416x3经过骨干网络后你会在三个不同尺度上分别拿到13x13、26x26、52x52的特征图route操作就是把深层特征和浅层特征拼起来让检测头既有高层语义又有低层细节。2.3 [yolo]块是检测头参数mask、anchors、classes、num这些是检测头最重要的配置。特别是anchors它决定了每个特征图位置预测的候选框宽高比例。我见过不少人在跑通代码后直接把anchors改小或改大结果检测精度大幅下降——原因后面讲训练损失时会提到。之前某次复现实验我把配置文件里的width和height从416改成了320原本是想着推理能快一些。结果在同样一张图上小目标漏检明显增多。原因不复杂特征图尺寸变了每个栅格对应的原始区域大小也变了但anchors并没有同步调整候选框和真实目标的匹配关系就乱了。这算是我在YOLOv3代码上踩过的第一个真实的坑。3. 模型定义代码从cfg文本到PyTorch计算图的翻译cfg是图纸模型定义就是施工队。在PyTorch版本的YOLOv3代码里这个翻译过程通常是逐行读取cfg遇到不同的块类型就创建对应的网络层。def create_modules(module_defs): hyperparams module_defs.pop(0) output_filters [channels] module_list nn.ModuleList() for module_i, module_def in enumerate(module_defs): modules nn.Sequential() if module_def[type] convolutional: bn int(module_def[batch_normalize]) filters int(module_def[filters]) kernel_size int(module_def[size]) pad (kernel_size - 1) // 2 if int(module_def[pad]) else 0 modules.add_module(conv_%d % module_i, nn.Conv2d(in_channelsoutput_filters[-1], out_channelsfilters, kernel_sizekernel_size, strideint(module_def[stride]), paddingpad, biasnot bn)) if bn: modules.add_module(batch_norm_%d % module_i, nn.BatchNorm2d(filters, momentum0.9, eps1e-5)) if module_def[activation] leaky: modules.add_module(leaky_%d % module_i, nn.LeakyReLU(0.1)) elif module_def[type] upsample: ...这里面最值得注意的细节是卷积层的padding不是自己拍脑袋设的而是根据卷积核大小算出来的。pad (kernel_size - 1) // 2 if int(module_def[pad]) else 0这样做的目的是保持特征图的空间尺寸不变。你只要记住在YOLOv3的骨干网络里绝大部分卷积不会改变特征图的宽高只有那些stride2的卷积层才用来下采样。这也就是为什么YOLOv3能轻松输出三个不同尺度的特征图——它通过stride控制降采样节奏而不是靠padding操作来改变尺寸。YOLOv3的骨干网络叫Darknet-53。所谓53指的是网络里有53个卷积层全连接层不算。它的结构大体是第1层标准卷积卷积核3x3stride1之后每隔一段距离用stride2的卷积进行下采样在每个下采样阶段之间用多个shortcut残差块堆叠最终输出三个尺度的特征图分别对应下采样8倍、16倍、32倍所以Darknet-53并没有像ResNet那样把残差块叫成block而是用cfg里的多个convolutional块自然堆叠起来的。从代码角度来讲它比ResNet更好理解因为每一层做什么都是文本里写明白的。关于输入尺寸YOLOv3代码里默认是416x416。这是为了适配三个下采样倍数最终得到的特征图正好是13x13、26x26、52x52。如果你换过其他检测模型会发现YOLOv3对输入尺寸的敏感性相对较高因为它对特征图尺寸有严格依赖。后面如果改成320或其他尺寸需要仔细核对特征图对齐情况。4. 损失函数代码YOLOv3到底在学什么YOLOv3的训练代码里最纠结的部分就是损失函数。这部分我强烈建议读慢一点它是模型在学什么的核心答案。YOLOv3损失函数分三部分边界框回归损失、目标置信度损失、分类损失。在代码里通常是这样组织计算的lxy (box_pred[..., :2] - box_target[..., :2]) ** 2 lwh (box_pred[..., 2:] - box_target[..., 2:]) ** 2 lbox lxy.sum() lwh.sum() lconf -(obj_mask * torch.log(conf_pred eps) (1 - obj_mask) * torch.log(1 - conf_pred eps)) lclass -(class_mask * torch.log(cls_pred eps) (1 - class_mask) * torch.log(1 - cls_pred eps))注意不同版本的代码写法不一样有的用MSE有的用BCE但在主干逻辑上是同一件事预测的框和目标的框越接近越好预测的置信度在目标中心和背景区域要区分开分类概率要对上真实类别。4.1 框回归不是直接预测坐标而是预测偏移量YOLOv3不会直接告诉模型目标框的中心在图像坐标(200, 300)而是让模型预测相对于栅格位置的偏移量。这个设计非常关键也是理解代码的关键。来看后处理部分的解码代码box_xy (torch.sigmoid(pred[..., :2]) grid) / stride box_wh torch.exp(pred[..., 2:]) * anchors这里有个容易被忽略的sigmoid。YOLOv3的坐标预测是被限制在0到1之间的原因是它的逻辑是物体中心落在当前栅格内用一个sigmoid把这个概率空间压缩到(0,1)内防止预测坐标跑出栅格太远。所以在读代码时看到任何对坐标预测先做sigmoid再做偏移计算的地方基本都是YOLO系列后续版本沿用下来的思路。宽度和高度的预测则是直接用exp乘以anchor的宽高。exp的作用是让宽高得到一个正数因为在真实世界里物体的宽高不可能是负的。但是exp有个副作用数值很大时很容易训练不稳定所以很多改进版本会把它换成sigmoid乘以一个常数倍数的做法。YOLOv3代码里用exp算是它的一个特征。4.2 置信度区分前景和背景是个二分类问题在YOLOv3损失函数里目标置信度不是分类任务而是二分类任务这个格子到底有没有物体中心落进来。代码里通常会这样处理obj_mask (target[..., 4] 0).float()也就是把标注里有没有目标这个信息转成0或1的掩码。只有物体中心落在某个栅格内这个栅格才被当作正样本其余全是负样本。但这个负样本也分两种情况一种是完全没有目标的格子一种是和目标框有重叠但不够大的格子。YOLOv3引入了一个ignore_thresh的概念。它的意思是如果某个anchor和真实框的IoU超过这个阈值但又不是匹配的那个anchor它在损失计算里会被忽略既不算正样本也不算负样本。这样做的好处是减少模棱两可的样本干扰让模型更明确地学习。代码里常见的写法是iou bbox_wh_iou(anchor, target_wh) ignore_mask (iou ignore_thresh).float()这个细节如果不注意直接看损失那一行会以为所有负样本都在参与训练实际上很多负样本是被ignore_mask屏蔽掉的。4.3 分类损失对每个类别做二分类YOLOv3用的是多标签分类也就是每个类别单独算一个sigmoid交叉熵而不是softmax多分类。这是从YOLOv2就开始的传统目的是让模型可以同时检测重叠的物体比如一个人同时属于如行人和戴帽子的人两个标签。在代码里分类损失的标签是从数据集的标注文件里解析出来的。一个典型标注文件格式是class_id center_x center_y width height比如0 0.5 0.5 0.3 0.4表示类别0中心点在图像中心宽高是图像宽高的30%和40%。YOLOv3的标签是归一化坐标所以在损失计算时预测值也要除以特征图尺寸才能对齐。5. 训练与数据加载三个尺度、九个锚框的样本分配逻辑训练部分最核心的一个环节是标签分配。YOLOv3不像后来YOLOv5那样有复杂的自动分配策略它非常简单粗暴每个真实框只分配给IoU最大的那个anchor也就是和它的宽高比例最接近的先验框。流程是这样的读取一张图的标注得到每个目标的中心坐标和宽高把中心坐标映射到三个特征图尺度上13x13、26x26、52x52在特征图的每个位置有3个anchor由mask指定计算3个anchor和真实框的IoU选出最大的那个作为匹配框把匹配框对应的栅格标记为正样本分类标签置为真实类别置信度标签置为1所以YOLOv3代码里的target张量通常是三维的(batch_size, num_anchors, grid_size, grid_size, 5 num_classes)最后一维的5表示center_x、center_y、width、height、objectness后面的num_classes是各类别的one-hot。这个张量的大小完全由cfg里的三个scale和每个尺度3个anchor决定。从代码实现角度看YOLOv3的训练标签并不是那种容易理解的一张图对应一个目标列表而是被打散到了特征图的每个栅格里。如果你看数据加载部分只是单纯返回了图片和标注那说明还没运行到损失函数那一层只有在损失计算代码里才会把所有标注转化为这种三维张量。有个常见问题是每个栅格预测3个框但每个真实框只匹配一个那么其他两个anchor对应的预测框是不是就浪费了答案是它们会被当作负样本或者被ignore。因为大部分栅格根本没有目标中心所以负样本量极大这也是YOLOv3训练时需要非常强调置信度损失的原因。如果正负样本失衡严重模型会倾向把所有位置都预测成背景。代码里的ignore_mask就是为了缓解这个问题避免被大量没选中的候选框干扰。数据增强方面YOLOv3代码里常用的操作包括随机翻转、随机裁剪、色调变换、饱和度变换、曝光变换、随机缩放等。这些操作在utils/datasets.py里实现。有一点值得注意如果做随机裁剪标注坐标必须跟着变换否则训练就会学错。这一点在代码里通常用矩阵变换方式处理我不建议自己乱改保持默认就好。6. 后处理代码从特征图到检测框的最后一步模型输出的东西不是直接能用的框它还是一堆特征图。后处理要做的事情我拆成三步解码、过滤、去重。6.1 解码把相对量变成绝对坐标在YOLOv3代码里模型的原始输出形状是(batch_size, num_anchors, grid_size, grid_size, 5 num_classes)但实际我们希望通过检测函数得到的是(batch_size, total_predictions, [x1, y1, x2, y2, confidence, class_score, class_id])所以第一步就是把每个栅格的预测偏移量转换成真实框的坐标。关键代码pred[..., :2] torch.sigmoid(pred[..., :2]) grid # 中心坐标 pred[..., 2:] torch.exp(pred[..., 2:]) * anchors # 宽高这里的grid就是每个栅格的位置编号。比如13x13的特征图grid就是13x13的网格每个格子的坐标是(0,0)、(0,1)...(12,12)。用sigmoid预测的偏移量加上grid得到的是该栅格坐标系下的中心坐标。最后再除以stride把它归一化回0到1方便后续除以图像宽高得到实际像素坐标。6.2 过滤按置信度和类别置信度筛掉低质量框解码完之后一般会先按objectness置信度做一次粗筛然后再把类别置信度乘进去得到最终的置信度分数。conf_mask (pred[..., 4] conf_thres).int()比如conf_thres设为0.25那所有objectness分数低于0.25的框直接不要。分类置信度一般取max类别得分比如一张图上预测出猫0.9、狗0.3那就只保留猫。这一步的目的是丢掉大量低质量预测让后续的NMS处理不至于太慢。我在实验里见过有人conf_thres设0.05结果一张图输出几千个框NMS跑了半天看起来就像卡死了一样。实际部署时conf_thres通常设在0.25到0.5之间比较合理。6.3 去重NMS是目标检测的最后一道关卡NMS非极大值抑制解决的核心问题是同一个物体会被多个相邻栅格预测到这些框之间高度重叠我们只需要保留一个最好的。NMS的流程是按置信度从高到低排序选出当前最高的框剔除其他与它IoU过大的框重复上述过程YOLOv3代码里常见有两种NMS实现方式一种是在CPU上用循环实现另一种是利用GPU矩阵运算实现。前者容易理解后者更快。作为学习先读CPU版本def nms(bboxes, scores, iou_thres): order scores.argsort(descendingTrue) keep [] while order.size(0) 0: i order[0] keep.append(i) ious bbox_iou(bboxes[i], bboxes[order[1:]]) mask ious iou_thres order order[1:][mask] return keepiou_thres通常取0.4到0.5。阈值越小抑制越厉害保留的框越少阈值越大保留的框越多但可能多个框同时框住同一个目标。实际项目里我一般用0.45兼顾去重和召回。6.4 一个容易被忽略的细节多类别NMS还是单类别NMSYOLOv3代码里通常是对每个类别分别做NMS也就是“针对猫做一次NMS针对狗做一次NMS”。这样做是合理的因为猫和狗就算框重叠也不应该被互相抑制。但有些初学者在修改代码时把NMS做成了所有类别一起结果在人群、车流等密集场景里大量漏检。如果你跑的是官方开源版本这块代码一般已经处理好了。但如果你从零手写后处理一定要记得按类别分开处理。7. 推理流程detect.py里到底经历了什么训练完模型后用detect.py做推理是一个很好的整体串讲机会。整条推理链大概是读取输入图片缩放至模型需要的尺寸比如416x416把图片转成Tensor归一化到0~1之间输入网络得到三个尺度的原始预测在三个尺度分别做解码把所有尺度的预测结果拼接起来按置信度过滤按类别做NMS输出最终框画到原图上在第1步里有个细节值得注意YOLOv3代码里通常不会拉伸图片而是等比缩放后用灰色填充剩余区域。这样做是为了保持目标的宽高比例不变否则训练时学到的anchor宽高比会失效。有些调参新手直接resize成矩形导致检测框变形严重锚框的适配关系也被破坏。这是非常常见的问题。第4步解码时三个尺度的stride不一样。13x13特征图的stride是3226x26是1652x52是8。这里的stride代表的是原图中每个栅格对应的像素步长。比如13x13的特征图每个栅格在原图中约对应32x32像素的区域。推理时有个性能优化点torch.no_grad()一定要加因为推理不需要计算梯度否则显存占用会高很多速度也会慢不少。我见过有人用训练代码临时改一下做推理没加no_grad结果一张大图直接把显存拉满。8. 从代码到实战YOLOv3代码里最值得抄的四个细节讲到这里YOLOv3代码的主干已经全部过了一遍。最后分享四个我到现在还在用的代码细节它们是从YOLOv3代码里学到的放到任何检测项目里都适用。一、cfg配置驱动模型构建的思路。YOLOv3不是把网络结构写死在Python代码里而是通过读cfg文件来构建模型。这个设计非常优雅改网络结构完全不需要动代码只改文本就行。后来很多检测项目都沿用了这个思路包括一些轻量级检测器。我在自己写小工具时也会把模型参数放到配置文件里而不是硬编码在代码里。二、三个尺度预测的融合思想。YOLOv3通过route融合深层和浅层特征让检测头同时看到全局信息和细节信息。这个思想后来被大量用于多尺度特征融合比如FPN、PANet都是在此基础上改进的。读懂YOLOv3的route和shortcut等于读懂了现代检测器的一半。三、置信度损失中的ignore机制。YOLOv3不把所有负样本都拿来训练而是用ignore_thresh筛掉一部分模糊样本。这个思想在后来很多检测算法里都有体现。你在复现其他模型时如果发现损失函数里加了忽略或者mask操作思路基本都来自这里。四、后处理中按类别做NMS。这个细节很小但影响很大。按类别做NMS能有效防止不同类别物体相互抑制。后来YOLOv5等模型也都沿用了这个逻辑。8.1 关于直接跑通代码的实操建议如果你是第一次跑YOLOv3代码我建议按这个路径来先用官方权重跑通detect.py推理拿几张不同的图试试感受一下检测效果打开cfg文件对着网络结构图逐层对照搞清楚每一层的输入输出打上断点在detect.py的预测输出处打印tensor形状确认三个尺度的输出大小打开损失函数单步调试一次看target张量是怎么生成的最后尝试训练一个小数据集比如只分3类、每类几十张图跑通完整流程第五步很重要。不训练一次你对损失函数和标签分配的理解永远是纸面的。我自己第一次完整训练时数据集只有一两百张图跑了200多个epoch虽然精度一般但整个训练流程中每个模块的关系彻底清楚了。8.2 常见的跑代码问题排查把一份YOLOv3代码跑起来的过程中最容易遇到这几类问题显存不足报错把batch size调小或者把输入尺寸从416降到320训练loss不降检查学习率和数据标注是否归一化正确尤其看看中心点坐标是不是在0~1之间推理结果全是框conf_thres设置太低调高一些推理结果全是空白先检查权重有没有加载成功再看预处理有没有做归一化训练和推理效果差距大多半是数据增强里正则化导致训练分布和测试分布不一致适当减少增强强度YOLOv3代码虽然看起来繁琐但拆开之后每一块都很明确cfg定结构模型定计算损失定方向后处理定输出。这四个环节都跑通你不仅能看懂YOLOv3也能看懂后续绝大多数基于YOLO的检测算法。我个人在看过很多目标检测代码后最大的感受是YOLOv3是那种当时觉得麻烦事后觉得值的代码。它足够老所以没有太多花哨技巧它足够经典所以几乎所有现代检测器的关键设计都能从这里找到源头。把这份代码吃透后面再学任何检测模型都会有一种原来还是那套东西的感觉。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑