目标检测方法(目标检测怎么学)《目标检测》-第32章-浅析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保持相同的配置,没有太多可说道的,就不做过多解读了。我们继续往下。

二、网络结构

2.1 Backbone结构

图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) 三组可调控的参数:

ModelwdrN0.250.342.0S0.50.342.0M0.750.671.5L1.0.1.01.0X1.251.01.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模块数量上的控制稍做了一些精心设计,并没有太大的变化。

2.2 PaFPN结构

接下来,我们说一下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。

2.3 Detection head结构

从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将其通道数 CclsC_{cls} 设置为 max(Co3,NC)\max(C_o^{3},N_C) ,回归分支的通道数 CregC_{reg} 设置为 ,,max(16,Co3/4,4∗reg_max)\max(16,C_o^{3}/4,4*reg\_max)。以YOLOv8-L为例(COCO数据集),则解耦头的通道数配置为:

Ccls=max(256,80)=256C_{cls}=\max(256,80)=256

Creg=max(16,256/4,4∗16)=64C_{reg}=\max(16, 256/4, 4*16)=64

这个细节还请读者务必注意到,很多YOLOv8的科普文章都没有说明这一点,包括MMYOLO官方给出的YOLOv8结构图也同样没有展现出这一点。

2.4 YOLOv8的预测层

另外,从YOLOv8的源码中我们也能看出来,YOLOv8不再采用残留着two-stage痕迹的objectness预测,仅预测classification和regression,如同RetinaNet和FCOS。更加符合one-stage的概念。具体来说,检测头的类别预测分支输出的Tensor的维度为[B,NC,H,W][B, N_C,H,W],位置预测分支输出的Tensor的维度为[B,4∗R,H,W][B,4*R,H,W] ,其中, BB 是batch size, NCN_C 是类别总数(没有背景标签), RR 是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∈RB×M\in \mathbb{R}^{B\times M} 、target_bboxes∈RB×M×4\in \mathbb{R}^{B\times M\times 4}和target_scores∈RB×M×NC\in \mathbb{R}^{B\times M\times N_C},其中, BB 是batch size, MM是所有预测的anchor总数(没有anchor box),NCN_C是类别总数。这三个变量的含义分别是正样本的类别标签(背景都是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以及基于概率分布的边界框表征。因此,对于还未找到研究点的读者,完全可以从这三个方面来出发,尝试做出一些增量式的改进。当然,这三个点几乎都已经有了大量的相关工作,量的累积已经达到了一定程度,接下来就看能否有质的突破了。

    THE END
    喜欢就支持一下吧
    点赞7 分享
    评论 抢沙发
    头像
    欢迎您留下宝贵的见解!
    提交
    头像

    昵称

    取消
    昵称表情代码图片

      暂无评论内容