3. YOLOv8源码解析:项目结构、配置文件解读、模型定义与损失函数
YOLOv8 的源码,说实话,我第一次打开的时候也有点懵。文件夹不少,文件更多。但别怕,咱们一步步拆开来看。我个人习惯是先看项目结构,再读配置文件,最后才去啃模型定义和损失函数。这个顺序,能让你少走很多弯路。
3.1 项目结构:先摸清家底
你从 GitHub 上拉下来 ultralytics 这个库,会发现核心代码都在 ultralytics/ 目录下。我把它最关键的几个部分给你列出来:
| 目录/文件 | 作用 | 我的备注 |
|---|---|---|
ultralytics/nn/ |
模型定义的核心,Backbone、Neck、Head 都在这里 | 你改模型结构,主要动这里 |
ultralytics/cfg/ |
存放各种模型的配置文件(.yaml) | 训练参数、模型深度、宽度都在这里调 |
ultralytics/data/ |
数据加载和增强的逻辑 | 我曾经在这里踩过数据增强的坑,后面细说 |
ultralytics/engine/ |
训练、验证、预测的引擎 | 训练循环、回调函数都在这里 |
ultralytics/utils/ |
工具函数,比如损失函数、后处理、指标计算 | 损失函数就在这里,很重要 |
嗯,这里要注意,YOLOv8 把模型定义和训练逻辑分得很开。你如果想换一个 Backbone,只需要在 nn/ 模块里改,其他地方基本不用动。这种设计,说白了就是为了方便你二次开发。
3.2 配置文件解读:YAML 里藏着所有秘密
YOLOv8 的配置文件用的是 YAML 格式。我个人觉得,这是它比之前版本做得好的地方——所有超参数一目了然。我们拿 ultralytics/cfg/models/v8/yolov8n.yaml 来举个例子:
# Parameters
nc: 80 # 类别数
scales: # 模型复合缩放参数
n: [0.33, 0.25, 1024] # depth, width, max_channels
# Backbone
backbone:
- [-1, 1, Conv, [64, 3, 2]] # 0-P1/2
- [-1, 1, Conv, [128, 3, 2]] # 1-P2/4
- [-1, 3, C2f, [128, True]]
- [-1, 1, Conv, [256, 3, 2]] # 3-P3/8
- [-1, 6, C2f, [256, True]]
- [-1, 1, Conv, [512, 3, 2]] # 5-P4/16
- [-1, 6, C2f, [512, True]]
- [-1, 1, Conv, [1024, 3, 2]] # 7-P5/32
- [-1, 3, C2f, [1024, True]]
- [-1, 1, SPPF, [1024, 5]] # 9
# Head
head:
- [-1, 1, nn.Upsample, [None, 2, 'nearest']]
- [[-1, 6], 1, Concat, [1]] # cat backbone P4
- [-1, 3, C2f, [512]] # 12
- [-1, 1, nn.Upsample, [None, 2, 'nearest']]
- [[-1, 4], 1, Concat, [1]] # cat backbone P3
- [-1, 3, C2f, [256]] # 15 (P3/8-small)
- [-1, 1, Conv, [256, 3, 2]]
- [[-1, 12], 1, Concat, [1]] # cat head P4
- [-1, 3, C2f, [512]] # 18 (P4/16-medium)
- [-1, 1, Conv, [512, 3, 2]]
- [[-1, 9], 1, Concat, [1]] # cat head P5
- [-1, 3, C2f, [1024]] # 21 (P5/32-large)
- [[15, 18, 21], 1, Detect, [nc]] # Detect(P3, P4, P5)
1.
scales 下的 depth 和 width 控制模型大小。2. Backbone 里每一行的
[-1, 3, C2f, [256, True]] 表示:输入来自上一层(-1),重复3次 C2f 模块,输出通道256。3. Head 里的
[[15, 18, 21], 1, Detect, [nc]] 表示:从第15、18、21层取特征图,送到检测头。
你想想看,这种配置方式,是不是比直接在代码里硬编码要灵活得多?我曾经为了调一个模型深度,在旧版代码里翻来覆去找了半小时。现在改个数字就行了。
3.3 模型定义:Backbone / Neck / Head 三件套
YOLOv8 的模型定义在 ultralytics/nn/modules.py 和 ultralytics/nn/tasks.py 里。核心就三个部分:
3.3.1 Backbone:CSPDarknet + C2f
Backbone 负责提取特征。YOLOv8 用的是改进版的 CSPDarknet,核心模块是 C2f。它比之前的 C3 模块更轻量,梯度流更丰富。
class C2f(nn.Module):
"""CSP Bottleneck with 2 convolutions."""
def __init__(self, c1, c2, n=1, shortcut=False, g=1, e=0.5):
super().__init__()
self.c = int(c2 * e) # hidden channels
self.cv1 = Conv(c1, 2 * self.c, 1, 1)
self.cv2 = Conv((2 + n) * self.c, c2, 1) # optional act=FReLU(c2)
self.m = nn.ModuleList([Bottleneck(self.c, self.c, shortcut, g, k=((3, 3), (3, 3)), e=1.0) for _ in range(n)])
3.3.2 Neck:FPN + PAN 结构
Neck 负责融合多尺度特征。YOLOv8 延续了 FPN + PAN 的结构,但去掉了对象性分支,只保留分类和回归分支。说白了,就是让不同尺度的特征图互相“串门”,小目标用大特征图,大目标用小特征图。
3.3.3 Head:解耦检测头
Head 是 YOLOv8 变化最大的地方。它把分类和回归分成了两个独立的分支:
class Detect(nn.Module):
"""YOLOv8 Detect head for detection models."""
def __init__(self, nc=80, ch=()):
super().__init__()
self.nc = nc # number of classes
self.nl = len(ch) # number of detection layers
self.cv2 = nn.ModuleList(
nn.Sequential(Conv(x, c3, 3), Conv(c3, c3, 3), nn.Conv2d(c3, 4 * self.reg_max, 1)) for x in ch)
self.cv3 = nn.ModuleList(
nn.Sequential(Conv(x, c3, 3), Conv(c3, c3, 3), nn.Conv2d(c3, self.nc, 1)) for x in ch)
你看,cv2 是回归分支,输出 4 * reg_max 个值;cv3 是分类分支,输出 nc 个类别概率。这种解耦设计,我实测下来,收敛速度比耦合头快了不少。
3.4 损失函数:分类 + 回归 + DFL 三合一
YOLOv8 的损失函数由三部分组成,定义在 ultralytics/utils/loss.py 里:
| 损失项 | 公式/方法 | 作用 |
|---|---|---|
| 分类损失 | BCE Loss(二值交叉熵) | 判断每个 anchor 属于哪个类别 |
| 回归损失 | CIoU Loss | 优化预测框和真实框的重合度 |
| DFL 损失 | 交叉熵 + 积分回归 | 让边界框的分布更集中,提高定位精度 |
这里我想重点说一下 DFL(Distribution Focal Loss)。它把边界框的回归问题,变成了一个离散分布的学习问题。说白了,就是让模型不仅学会框的位置,还要学会对这个位置有多“自信”。
class DFLoss(nn.Module):
"""Distribution Focal Loss."""
def __init__(self, reg_max=16):
super().__init__()
self.reg_max = reg_max
def forward(self, pred_dist, target):
tl = target.long() # target left
tr = tl + 1 # target right
wl = tr - target # weight left
wr = 1 - wl # weight right
return (F.cross_entropy(pred_dist, tl.view(-1), reduction='none').view(tl.shape) * wl +
F.cross_entropy(pred_dist, tr.view(-1), reduction='none').view(tl.shape) * wr).mean()
你仔细看,DFL 不是直接回归一个坐标值,而是预测坐标在 0 到 reg_max 之间的分布。这样模型就知道,这个框的左边大概在位置 5,但可能在 4 到 6 之间。这种不确定性建模,对遮挡和模糊目标的检测特别有用。
3.5 后处理:NMS 与坐标解码
模型输出的原始结果,不能直接用。你需要做两步后处理:
- 坐标解码: 把 DFL 输出的分布,通过积分变成具体的 x, y, w, h 值。
- NMS(非极大值抑制): 去掉重复的检测框,只保留置信度最高的那个。
YOLOv8 的后处理在 ultralytics/utils/ops.py 里。核心代码就几行:
def non_max_suppression(prediction, conf_thres=0.25, iou_thres=0.45):
"""Performs Non-Maximum Suppression (NMS) on inference results"""
# 1. 过滤低置信度预测
# 2. 对每个类别分别做 NMS
# 3. 返回最终的检测框
conf_thres 和 iou_thres 是后处理里最重要的两个参数。我一般先设 conf_thres=0.1 看召回率,再逐步提高 conf_thres 看精度。iou_thres 默认 0.45 对大多数场景够用,但如果是密集小目标,建议降到 0.3 左右。
3.6 整体流程:一张图看懂
说了这么多,咱们用一张图把整个流程串起来。从输入图像到最终输出检测框,YOLOv8 内部经历了这些步骤:
这张图把 YOLOv8 的整个推理链路画清楚了。你从输入图像开始,经过 Backbone 提取特征,Neck 融合多尺度信息,Head 输出原始预测,最后后处理得到最终检测框。每一步都有对应的代码模块,你可以对照着源码去读。
好了,这一章的内容就到这儿。源码解析是个细活,别指望一次全看懂。我的建议是:先跑通一个 demo,然后对照着配置文件改几个参数,看看效果有什么变化。动手试一遍,比看十遍代码都管用。