赞
踩
转载自
目录
一、数据预处理
二、网络结构
2.1 Backbone结构
2.2 PaFPN结构
2.3 Detection head结构
2.4 YOLOv8的预测层
三、正样本匹配
四、损失函数
五、尾声
近期,YOLOv5原班人马推出了YOLO的最新SOTA——YOLOv8,在又一次刷新了YOLO系列的顶峰性能的同时,团队还重构了自YOLOv3和YOLOv5以来的代码风格,似乎不如先前简洁了,但据小道消息称,此次修改代码风格是为了构建一个全新的YOLO集大成框架。
言归正传,本文主要对最新的YOLOv8做一次浅析,主要从网络结构、正样本匹配以及损失函数三个方面来讲解YOLOv8相较于YOLOv5的更新。之所以从这三方面来讲,主要是因为当前的目标检测的绝大部份工作,几乎都是围绕这三点来下功夫的,所以,笔者将有限的精力就投入在这三个点的讲解上,至于YOLOv8的源码的诸多细节,不在本文的范畴之内,还请读者根据自己的需要来选择性地阅读。
首先,我们简单说一下YOLOv8的数据预处理。对于这一块,YOLOv8依旧采用YOLOv5的策略,在训练时,主要采用包括马赛克增强(Mosaic)、混合增强(Mixup)、空间扰动(random perspective )以及颜色扰动(HSV augment)四个增强手段,当然,也还会用到copy paste增强手段,但默认启动概率为0,也就是不使用。
YOLOv8一如既往地采用在COCO数据集上的train from scratch训练策略,不采用imagenet pretrained模型,因而训练的epoch也不会小于300(目前还不清楚具体的训练时长)。
总体来说,数据预处理这一块基本是和YOLOv5保持相同的配置,没有太多可说道的,就不做过多解读了。我们继续往下。
图1. YOLOv5-v6.0的配置文件
首先,我们来简单回顾一下YOLOv5的网络结构,如图1所示,截图自YOLOv5-v6.0版的配置文件,相较于早期YOLOv5的早期版本,目前已经取消了不友好的Focus模块,初始的网络层直接由简单质朴的普通卷积来完成。从图中我们可以看到,YOLOv5网络结构的核心就是CSPBlock模块,用YOLOv5的的语言来说,就是“C3”模块,相关代码如下所示:
- # yolov5//models/common.py
- ...
-
- class Bottleneck(nn.Module):
- # Standard bottleneck
- def __init__(self, c1, c2, shortcut=True, g=1, e=0.5): # ch_in, ch_out, shortcut, groups, expansion
- super().__init__()
- c_ = int(c2 * e) # hidden channels
- self.cv1 = Conv(c1, c_, 1, 1)
- self.cv2 = Conv(c_, c2, 3, 1, g=g)
- self.add = shortcut and c1 == c2
-
- def forward(self, x):
- return x + self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x))
-
-
- class C3(nn.Module):
- # CSP Bottleneck with 3 convolutions
- def __init__(self, c1, c2, n=1, shortcut=True, g=1, e=0.5): # ch_in, ch_out, number, shortcut, groups, expansion
- super().__init__()
- c_ = int(c2 * e) # hidden channels
- self.cv1 = Conv(c1, c_, 1, 1)
- self.cv2 = Conv(c1, c_, 1, 1)
- self.cv3 = Conv(2 * c_, c2, 1) # optional act=FReLU(c2)
- self.m = nn.Sequential(*(Bottleneck(c_, c_, shortcut, g, e=1.0) for _ in range(n)))
-
- def forward(self, x):
- return self.cv3(torch.cat((self.m(self.cv1(x)), self.cv2(x)), 1))
YOLOv5的主干网络的架构规律十分清晰,总体来看就是每用一层stride=2的3×3卷积去降采样feature map的空间分辨率后,会跟着接一个C3模块来进一步强化其中的特征,且C3的基本深度参数分别为“3/6/9/3”,其会根据不同规模的模型的depth(=0.34/0.67/1.0/1.34)来做相应的缩放,输出的通道数的基本配置分别为“128/256/512/1024”,也会根据不同规模的模型的width(=0.25/0.50/0.75/1.0/1.34)来做相应的缩放。
由此可见,YOLOv5的结构是极其简洁,使用者在遵循YOLOv5的大框架下,只需要调整width和depth两个参数即可改变YOLOv5网络结构的规模。在搞清楚了这套逻辑后,使用者是可以很容易地来进行“魔改”的。
在最新的YOLOv8中,大体上也还是继承了这一特点,如图2所示。
图2. YOLOv8网络的配置文件
从图中我们可以看到,原先的C3模块均被替换成了新的“C2f”模块,相关代码如下所示。
- # ultralytics/ultralytics/nn/modules.py
- ...
-
- class Bottleneck(nn.Module):
- # Standard bottleneck
- def __init__(self, c1, c2, shortcut=True, g=1, k=(3, 3), e=0.5): # ch_in, ch_out, shortcut, groups, kernels, expand
- super().__init__()
- c_ = int(c2 * e) # hidden channels
- self.cv1 = Conv(c1, c_, k[0], 1)
- self.cv2 = Conv(c_, c2, k[1], 1, g=g)
- self.add = shortcut and c1 == c2
-
- def forward(self, x):
- return x + self.cv2(self.cv1(x)) if self.add else self.cv2(self.cv1(x))
-
-
- class C2f(nn.Module):
- # CSP Bottleneck with 2 convolutions
- def __init__(self, c1, c2, n=1, shortcut=False, g=1, e=0.5): # ch_in, ch_out, number, shortcut, groups, expansion
- 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))
-
- def forward(self, x):
- y = list(self.cv1(x).split((self.c, self.c), 1))
- y.extend(m(y[-1]) for m in self.m)
- return self.cv2(torch.cat(y, 1))
相较于C3模块,了解YOLOv7的读者不难看出来,新的“C2f”模块在一定程度上是受到了YOLOv7的ELAN模块的启发,加入更多的分支,丰富梯度回传时的支流。其网络结构图如下所示,其中,我们还展示了YOLOv7的ELAN模块和YOLOv5的C3模块,用来做对照。
图3 ELANBlock&C2f&C3模块展示
不过,在笔者看来,相较于YOLOv7的ELAN模块的设计,YOLOv8的“C2f”要简单粗暴的多,在一定程度并不太符合ShuffleNet曾经给出的一些设计准则:要想使卷积推理速度达到最快,输入通道应与输出通道保持一致。这一点,在“C2f”的最后一层1×1卷积是完全看不到的,由于depth参数的变化,C2f中的最后一层卷积的输入通道:(2 + n)*self.c
可能会远大于输出通道c2
。仅从所谓网络结构的“美学性”和“优雅性”来看,这一处会显得有些臃肿,从而不够优雅。不过,这也只是一种感性的批判。
另外,我们再来看一下图2中给出的YOLOv8的配置文件,会发现最后的C5尺度的通道数是有变化的,如图4中的红框所标注出的部分。
图4. 不同规模的YOLOv8的C5通道数
对于较轻量的YOLOv8-N和YOLOv8-S,基本通道数基本就是遵循128->256->512->1024
的变化规律 ,无非是在这基础上乘以各自的width
参数。 但是,对于较大的M/L/X,最后的1024则分别变成了768,512和512。参考OpenMMLAB发表的一篇YOLOv8的解读文章,我们可以单独给C5尺度额外加入一个参数ratio
,简记r
,基础通道数为512,那么从YOLOv8-N到YOLOv8-X,就一共有width(w)
、depth(d)
和ratio(r)
三组可调控的参数:
Model | w | d | r |
---|---|---|---|
N | 0.25 | 0.34 | 2.0 |
S | 0.5 | 0.34 | 2.0 |
M | 0.75 | 0.67 | 1.5 |
L | 1.0. | 1.0 | 1.0 |
X | 1.25 | 1.0 | 1.0 |
我们会发现,width
的变化是保持着同一规律的,但是depth
在L和X之间确实保持一致,可以认为这是YOLOv8“精心调制”后的结果,个人认为其目的是把YOLOv8-X的参数量和GFLOPs都控制在一个可接受的范围内,避免过高,否则,可能相较于其他YOLO模型体现不出“SOTA”的显著优势。
对于r
这个参数,老实说我个人不太喜欢,仅仅加入这么一个因子单独调控C5的通道,很显然这也是为了进一步控制住大模型的参数量和FLOPs,避免1024的通道数会带来过高的参数量和GFLOPs,所以在L和X中,原本的1024被改成了512。相较于上一代的YOLOv5来说,v8的结构上的优雅性和简洁性要有所欠缺,但这不是什么缺点,毕竟SOTA了。
最后,再说一下C2f的配置。在YOLOv5中,我们都知道,C3的配置遵循着3/6/9/3
的配置,而在YOLOv8中,C2f的配置则遵循3/6/6/3
的配置,其中的9被减小到了6,可以猜到,这是为了压缩模型的规模。
至此,YOLOv8的bakcbone我们就说完了,总体上来看,YOLOv8主要是将原先的C3换成了C2f模块,在一些通道数上的控制和C2f模块数量上的控制稍做了一些精心设计,并没有太大的变化。
接下来,我们说一下YOLOv8的PaFPN。一如往常的,YOLOv8仍采用PaFPN结构来构建YOLO的特征金字塔,使多尺度信息之间进行充分的融合。图5展示了YOLOv8-L和YOLOv5-L的PaFPN结构的配置对比,可以看到,大体上几乎是一样的,仅仅是在top-down过程中的上采样操作中少了一层1×1卷积,且C3模块被替换为C2f模块。最后返回的三个尺度的通道数和backbone输出的三个尺度的通道数是相等的。
图5. YOLOv8的PaFPN结构的配置
不过,这里有个小细节,那就是在YOLOv8的配置文件中,我们看不到了anchors
的字样,这是因为YOLOv8终于抛弃了被诟病许久的anchor box。
从YOLOv3到YOLOv5,其检测头一直都是“耦合”(Coupled)的,即使用一层卷积同时完成分类和定位两个任务,直到YOLOX的问世,YOLO系列才第一次换装“解耦头”(Decoupled Head),随后的YOLOv6也同样采用了解耦头结构,更符合先进的检测框架的设计理念。
图6. YOLOX和YOLOv6中的解耦头
在本次更新的YOLOv8中,同样也采用了解耦头的结构,两条并行的分支分别取提取类别特征和位置特征,然后各用一层1×1卷积完成分类和定位任务。当然,这里的定位涉及到了Distribution focal loss(DFL)的概念,这一点我们后续再讲。
图6. YOLOv8的Decoupled head结构
上图展示了YOLOv8的解耦头,但从结构上来看,和YOLOX等工作无异,不过,这里有个小细节,那就是解耦头的类别分支和回归分支的通道数可能是不相等的。我们都知道,在YOLOX的解耦头中,类别分支和回归分支的通道数都是256(还需要考虑width因子),即类别分支和回归分支的通道数是相等的,这一点在YOLOv6中也体现出来了。
然而,YOLOv8认为二者通常是不应该相等的,毕竟表征了两种不同的特征。因此,对于类别分支,YOLOv8将其通道数
设置为 ,回归分支的通道数 设置为 ,,
。以YOLOv8-L为例(COCO数据集),则解耦头的通道数配置为:
这个细节还请读者务必注意到,很多YOLOv8的科普文章都没有说明这一点,包括MMYOLO官方给出的YOLOv8结构图也同样没有展现出这一点。
另外,从YOLOv8的源码中我们也能看出来,YOLOv8不再采用残留着two-stage痕迹的objectness预测,仅预测classification和regression,如同RetinaNet和FCOS。更加符合one-stage的概念。具体来说,检测头的类别预测分支输出的Tensor的维度为
,位置预测分支输出的Tensor的维度为 ,其中, 是batch size, 是类别总数(没有背景标签),
是DFL涉及到的reg_max
参数,默认为16。
尽管可以预料解耦检测头会增加模型的参数量和FLOPs,检测速度也会有所减慢,但在YOLOv8的精心调制下(引人r
因子),只牺牲了不太多的参数量和计算量换取了性能上的提升,如图7所示。但这并不重要。
图7. YOLOv5 vs YOLOv8
YOLOv8的最大亮点就是终于抛弃了被诟病许久的anchor box,有关anchor box的缺陷,已经是业界共识了,虽然它在有些时候可以起到某种先验的作用,但越来越多的工作如FCOS、YOLOX、YOLOv6等anchor-free工作已经证明了这种先验不是必要的。
尽管YOLOv5设计了自动聚类anchor box的一些功能,但是,聚类anchor box是依赖于数据集的,数据集不够充分,无法较为准确地反映数据本身的分布特征,那聚类出来的anchor box恐怕也只是次优,甚至很差,所以,干脆丢掉就完了,何必抱着旧的东西舍不得呢。从这一点上来看,YOLOv8比YOLOv7更进一步,后者还是偏保守了~
既然没有了anchor box,那么首当其冲的就是正负样本匹配的多尺度分配。不过,对于这个问题,早已被dynamic label assignm的研究浪潮给解决了。
不同于YOLOX所使用的SimOTA,YOLOv8在label assignm问题上采用了和YOLOv6相同的TOOD策略,是一种dynamic label assignment。这部分代码借鉴了PP-YOLOE的相关代码,如下所示,为了便于展示,忽略了部分代码。
- # ultralytics/ultralytics/yolo/utils/tal.py
- ...
-
- class TaskAlignedAssigner(nn.Module):
-
- def __init__(self, topk=13, num_classes=80, alpha=1.0, beta=6.0, eps=1e-9):
- super().__init__()
- ...
-
- @torch.no_grad()
- def forward(self, pd_scores, pd_bboxes, anc_points, gt_labels, gt_bboxes, mask_gt):
-
- self.bs = pd_scores.size(0)
- self.n_max_boxes = gt_bboxes.size(1)
-
- if self.n_max_boxes == 0:
- device = gt_bboxes.device
- return (torch.full_like(pd_scores[..., 0], self.bg_idx).to(device), torch.zeros_like(pd_bboxes).to(device),
- torch.zeros_like(pd_scores).to(device), torch.zeros_like(pd_scores[..., 0]).to(device),
- torch.zeros_like(pd_scores[..., 0]).to(device))
-
- mask_pos, align_metric, overlaps = self.get_pos_mask(pd_scores, pd_bboxes, gt_labels, gt_bboxes, anc_points,
- mask_gt)
-
- target_gt_idx, fg_mask, mask_pos = select_highest_overlaps(mask_pos, overlaps, self.n_max_boxes)
-
- # assigned target
- target_labels, target_bboxes, target_scores = self.get_targets(gt_labels, gt_bboxes, target_gt_idx, fg_mask)
-
- # normalize
- align_metric *= mask_pos
- pos_align_metrics = align_metric.amax(axis=-1, keepdim=True) # b, max_num_obj
- pos_overlaps = (overlaps * mask_pos).amax(axis=-1, keepdim=True) # b, max_num_obj
- norm_align_metric = (align_metric * pos_overlaps / (pos_align_metrics + self.eps)).amax(-2).unsqueeze(-1)
- target_scores = target_scores * norm_align_metric
-
- return target_labels, target_bboxes, target_scores, fg_mask.bool(), target_gt_idx
-
- def get_pos_mask(self, pd_scores, pd_bboxes, gt_labels, gt_bboxes, anc_points, mask_gt):
- ...
-
- def get_box_metrics(self, pd_scores, pd_bboxes, gt_labels, gt_bboxes):
- ...
-
- def select_topk_candidates(self, metrics, largest=True, topk_mask=None):
- ...
-
- def get_targets(self, gt_labels, gt_bboxes, target_gt_idx, fg_mask):
- ...
-
- return target_labels, target_bboxes, target_scores
最后代码返回三个变量:target_labels
、target_bboxes
和target_scores
,其中, 是batch size, 是所有预测的anchor总数(没有anchor box),
是类别总数。这三个变量的含义分别是正样本的类别标签(背景都是0)、正样本目标框坐标(背景都是0)以及正样本处的预测框与目标框的IoU(背景都是0)。不过,YOLOv8只用到了target_bboxes
和target_scores
。
有关于TOOD的诸多技术内容,笔者了解得还不够多,目前只停留在会用、知道咋回事、大概怎么整的层面上,还不足以完整地将技术细节全部讲解出来,这一段暂且先留个白,还望读者见谅。
既然没有了objectness预测,那么YOLOv8的损失就主要包括两大部分:类别损失和位置损失。
对于类别损失,YOLOv8采用了和RetinaNet、FCOS等相同的策略,使用sigmoid函数来计算每个类别的概率,并计算全局的类别损失,其学习标签是由TOOD给出的target_scores
,其中,正样本的类别标签就是IoU值,而负样本处全是0。对于这种情况,一个常用的策略是使用Variable Focal loss(VFL), 比如YOLOv6和PP-YOLOE都是这么做的,但YOLOv8则采用简单的BCE,代码如下:
- # ultralytics/ultralytics/yolo/v8/detect/train.py
- ...
-
- # cls loss
- # loss[1] = self.varifocal_loss(pred_scores, target_scores, target_labels) / target_scores_sum # VFL way
- loss[1] = self.bce(pred_scores, target_scores.to(dtype)).sum() / target_scores_sum # BCE
注意看,YOLOv8大概尝试过VFL,相关代码被注释掉了,可以猜测,作者团队发现使用VFL和使用普通的BCE的最终效果是一样,没有明显优势,所以就还是采用了简单的、没有涉及痕迹的BCE。
对于位置损失,YOLOv8将其分别两部分,第一部分就是计算预测框与目标框之间的IoU,一如既往的采用CIoU损失。而第二部分就是DFL,相关代码如下:
- # ultralytics/ultralytics/yolo/utils/loss.py
- ...
-
- class BboxLoss(nn.Module):
-
- def __init__(self, reg_max, use_dfl=False):
- super().__init__()
- self.reg_max = reg_max
- self.use_dfl = use_dfl
-
- def forward(self, pred_dist, pred_bboxes, anchor_points, target_bboxes, target_scores, target_scores_sum, fg_mask):
- # IoU loss
- weight = torch.masked_select(target_scores.sum(-1), fg_mask).unsqueeze(-1)
- iou = bbox_iou(pred_bboxes[fg_mask], target_bboxes[fg_mask], xywh=False, CIoU=True)
- loss_iou = ((1.0 - iou) * weight).sum() / target_scores_sum
-
- # DFL loss
- if self.use_dfl:
- target_ltrb = bbox2dist(anchor_points, target_bboxes, self.reg_max)
- loss_dfl = self._df_loss(pred_dist[fg_mask].view(-1, self.reg_max + 1), target_ltrb[fg_mask]) * weight
- loss_dfl = loss_dfl.sum() / target_scores_sum
- else:
- loss_dfl = torch.tensor(0.0).to(pred_dist.device)
-
- return loss_iou, loss_dfl
-
- @staticmethod
- def _df_loss(pred_dist, target):
- # Return sum of left and right DFL losses
- 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(-1, keepdim=True)
最终,总的损失为三部分损失的加权和。
从YOLOv8的这一次更新可以再一次看出来目标检测研究的三个大趋势:Anchor-free、Dynamic label assignment以及基于概率分布的边界框表征。因此,对于还未找到研究点的读者,完全可以从这三个方面来出发,尝试做出一些增量式的改进。当然,这三个点几乎都已经有了大量的相关工作,量的累积已经达到了一定程度,接下来就看能否有质的突破了。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。