浅析YOLOv8

本文介绍了YOLOv8在目标检测领域的最新进展,主要关注其网络结构的改进,包括Backbone中C3模块替换为C2f模块,PaFPN结构的保留,解耦的检测头设计,以及抛弃了anchorbox。此外,文章还讨论了数据预处理策略、正样本匹配的动态标签分配方法和损失函数的优化,特别是引入了DFL损失。YOLOv8在保持高性能的同时,对代码风格进行了重构,为构建更综合的YOLO框架奠定了基础。
摘要由CSDN通过智能技术生成

转载自 

《目标检测》-第32章-浅析YOLOv8 - 知乎

 

目录

一、数据预处理

二、网络结构

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保持相同的配置,没有太多可说道的,就不做过多解读了。我们继续往下。

二、网络结构

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

Modelwdr
N0.250.342.0
S0.50.342.0
M0.750.671.5
L1.0.1.01.0
X1.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将其通道数

设置为 ,回归分支的通道数 设置为 ,,

。以YOLOv8-L为例(COCO数据集),则解耦头的通道数配置为:

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

2.4 YOLOv8的预测层

另外,从YOLOv8的源码中我们也能看出来,YOLOv8不再采用残留着two-stage痕迹的objectness预测,仅预测classificationregression,如同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_bboxestarget_scores,其中, 是batch size, 是所有预测的anchor总数(没有anchor box),

是类别总数。这三个变量的含义分别是正样本的类别标签(背景都是0)、正样本目标框坐标(背景都是0)以及正样本处的预测框与目标框的IoU(背景都是0)。不过,YOLOv8只用到了target_bboxestarget_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以及基于概率分布的边界框表征。因此,对于还未找到研究点的读者,完全可以从这三个方面来出发,尝试做出一些增量式的改进。当然,这三个点几乎都已经有了大量的相关工作,量的累积已经达到了一定程度,接下来就看能否有质的突破了。

  • 4
    点赞
  • 61
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值