小伙伴关心的问题:yolov4用来做什么(yolo怎么了),本文通过数据整理汇集了yolov4用来做什么(yolo怎么了)相关信息,下面一起看看。

更新:2022-08-01

FreeYOLO这个项目笔者仍在更新,由于专栏已经完结了,所以就不单开新的章节来介绍后续的更新,而是在本章的实验章节中不定时的更新。主要更新包括以下几点:

1.使用SimOTA

在笔者最初的FreeYOLO版本里,使用的label assignment还是FCOS的,即需要手动设定三个尺度范围[0, 64], [64, 128], [128, inf],虽然少了设置anchor box的麻烦,但换个数据集,这三个尺度范围也得调整。比如,笔者想在widerface上去训练,显然以上三个尺度就不合适了,毕竟人脸目标都很小。所以,笔者就选择了SimOTA,完全采用dynamic label assignment来避免这个问题。

其次,笔者在imagenet上训练了一个cspdarknet-l,结构和YOLOv5-L的backbone是一样的,采取13996的配置,唯独第一层我没有使用Focus模块,而是采用了两层卷积。这块没有什么特殊的说道,仅仅是笔者的习惯了。

之所以笔者要在imagenet上去pretrain,是因为笔者在COCO上设置的batchsize只有16,在采用SimOTA的情况下,以这么小的batchsize去train from scratch效果会比较差。

另外,训练过程的多尺度范围也从320-640增加至320-800。

这一版的FreeYOLO被笔者命名为FreeYOLO-v2。

2. FreeYOLOv3

最近,笔者分享了一篇关于YOLOv7的网络结构的介绍,文章链接在下方。感兴趣的读着可以点进去看一看,了结最新的YOLOv7的网络结构,包括backbone、neck以及detection head。

YOLOv7的一大贡献就是在保持性能的前提上(甚至还提升了),进一步压缩了网络的参数量和FLOPs,这显然对于工业界是一次福音。笔者在上面的文章讲解了YOLOv7的网络结构之后,目前已经在imagenet上去预训练了,后续拿到pretrained weight后,笔者会将其用在FreeYOLOv2中,并且,连fpn也采用YOLOv7的带ELAN模块的PaFPN。不过,与v7不同的是,head部分笔者仍保留了YOLOX的decoupled head,所以参数会略比YOLOv7多一点。

总的来说,笔者在完成了网络结构的替换后,相较于FreeYOLOv2,这一次的参数量从67M减少至44M,FLOPs也从87 B减少至72 B。至于性能如何,仍在实验中。

这一版的FreeYOLO被笔者命名为FreeYOLO-v3。

感兴趣的读者可以继续关注这个FreeYOLO项目,每当有好用的东西被提出时,笔者都会及时做个小更新。

〇、序言

时过数月,这一主讲YOLO系列的目标检测入门专栏也将迎来尾声,步入收尾的阶段。

纵观这些年,YOLO从YOLOv1一路高歌猛进,演化至如今享誉盛名的YOLOv5,后来,anchor-free版本的YOLOX也被提了出来。可以说,没有那一系列的通用目标检测器能被发展得如此如火如荼,一代又一代地被改进和优化。主要原因无外乎是YOLO网络结构的简洁(没有太过精心的设计)、训练配置的友好(没有太过敏感的超参)和实时检测的速度。

笔者借此东风,斗胆创建了此专栏,以浅薄的水平竭尽所能地讲解了YOLOv1至YOLOv4,以及小插曲YOLOF。受笔者水平限制,做出的讲解也实在是乏善可陈,其中又添加了不少有失偏颇的个人之见,若是能给读者学习YOLO带去绵薄的帮助,属实是笔者的幸运。

万物有始亦有终,笔者将以本章单节来做个收尾工作。但即便是收尾,笔者也不想做的过于草率。既然如今的YOLO系列已经来到了YOLOX时代(YOLOv5仍旧有着高超的人气),那么笔者就再一次厚颜蹭个热度,为喜欢本专栏的读者献上一个无锚框的YOLO检测器:FreeYOLO。核心就是再也没有anchor box,取而代之的是FCOS检测器的anchor-free机制,即设置三个尺度范围来完成label assignment,无需依赖手工设计的anchor box先验。

为了配合这一章,笔者重新构建了YOLO项目代码,除了FreeYOLO,笔者还在这一新的项目中额外提供了仍旧使用锚框的AnchorYOLO,以及一个单级检测的YOLOF。如果说FreeYOLO是向YOLOX致敬的话,那么这个AnchorYOLO就是向YOLOv4致敬,而YOLOF,虽然笔者斗胆使用了旷视的YOLOF的名字,实则是想向YOLOv2致敬,毕竟时至今日,YOLOv2这一强大的单级检测器仍旧可以胜任很多检测任务。

FreeYOLO的项目代码链接如下,更加良好的代码风格也许会让读者读起来也更加轻松明朗一些:

尽管代码结构和以往有了很大的区别,风格也稍显不同,但很多代码的功能没有变,因此,尽管笔者构建了一个新的YOLO检测器,但也无需从头到脚地、无微不至的全部讲解一遍。所以,接下来,笔者将详略得当地介绍这款无锚框的FreeYOLO。(笔者也同样会穿插着介绍AnchorYOLO和YOLOF)。

这一章将会很长,还请读者安排好阅读时间。

一、FreeYOLO检测器

1.1 数据预处理

读取voc和coco数据集的代码我们放在项目代码的dataset文件下,如读取VOC数据集的dataset/voc.py ,文件内容如下,这里我们只展示出来代码的函数名,以便节省篇幅,具体代码还请读者自行打开文件查阅:

import os.path as osp import torch import torch.utils.data as data import cv2 import numpy as np import random import xml.etree.ElementTree as ET try: from .transforms import mosaic_augment, mixup_augment except: from transforms import mosaic_augment, mixup_augment VOC_CLASSES = ( # always index 0 aeroplane, bicycle, bird, boat, bottle, bus, car, cat, chair, cow, diningtable, dog, horse, motorbike, person, pottedplant, sheep, sofa, train, tvmonitor) class VOCAnnotationTransform(object): ...省略了... class VOCDetection(data.Dataset): def __init__(self, img_size=640, data_dir=None, image_sets=[(2007, trainval), (2012, trainval)], transform=None, color_augment=None, mosaic_prob=0.0, mixup_prob=0.0): self.root = data_dir self.img_size = img_size self.image_set = image_sets self.target_transform = VOCAnnotationTransform() self._annopath = osp.join(%s, Annotations, %s.xml) self._imgpath = osp.join(%s, JPEGImages, %s.jpg) self.ids = list() for (year, name) in image_sets: rootpath = osp.join(self.root, VOC + year) for line in open(osp.join(rootpath, ImageSets, Main, name + .txt)): self.ids.append((rootpath, line.strip())) # augmentation self.transform = transform self.color_augment = color_augment self.mosaic_prob = mosaic_prob self.mixup_prob = mixup_prob if self.mosaic_prob > 0.: print(use Mosaic Augmentation ...) if self.mixup_prob > 0.: print(use Mixup Augmentation ...) def __getitem__(self, index): image, target = self.pull_item(index) return image, target def __len__(self): return len(self.ids) def load_image_target(self, img_id): # load an image image = cv2.imread(self._imgpath % img_id) height, width, channels = image.shape # laod an annotation anno = ET.parse(self._annopath % img_id).getroot() if self.target_transform is not None: anno = self.target_transform(anno) # guard against no boxes via resizing anno = np.array(anno).reshape(-1, 5) target = { "boxes": anno[:, :4], "labels": anno[:, 4], "orig_size": [height, width] } return image, target def load_mosaic(self, index): # load a mosaic image ids_list_ = self.ids[:index] + self.ids[index+1:] # random sample other indexs id1 = self.ids[index] id2, id3, id4 = random.sample(ids_list_, 3) ids = [id1, id2, id3, id4] image_list = [] target_list = [] # load image and target for id_ in ids: img_i, target_i = self.load_image_target(id_) image_list.append(img_i) target_list.append(target_i) image, target = mosaic_augment(image_list, target_list, self.img_size) return image, target def pull_item(self, index): # load a mosaic image if random.random() < self.mosaic_prob: image, target = self.load_mosaic(index) # MixUp if random.random() < self.mixup_prob: new_index = np.random.randint(0, len(self.ids)) new_image, new_target = self.load_mosaic(new_index) image, target = mixup_augment(image, target, new_image, new_target) # augment image, target = self.color_augment(image, target) # load an image and target else: img_id = self.ids[index] image, target = self.load_image_target(img_id) # augment image, target = self.transform(image, target) return image, target def pull_image(self, index): ...省略了... def pull_anno(self, index): ...省略了... if __name__ == "__main__": from transforms import BaseTransforms, TrainTransforms, ValTransforms img_size = 640 format = RGB pixel_mean = [123.675, 116.28, 103.53] pixel_std = [58.395, 57.12, 57.375] trans_config = [{name: DistortTransform, hue: 0.1, saturation: 1.5, exposure: 1.5}, {name: RandomHorizontalFlip}, {name: JitterCrop, jitter_ratio: 0.3}, {name: ToTensor}, {name: Resize}, {name: Normalize}, {name: PadImage}] transform = TrainTransforms( trans_config=trans_config, img_size=img_size, pixel_mean=pixel_mean, pixel_std=pixel_std, format=format ) color_augment = BaseTransforms( img_size=img_size, pixel_mean=pixel_mean, pixel_std=pixel_std, format=format ) dataset = VOCDetection(img_size=img_size, data_dir=D:\\python_work\\object-detection\\dataset\\VOCdevkit, transform=transform, color_augment=color_augment, mosaic_prob=0.5, mixup_prob=0.5) np.random.seed(0) class_colors = [(np.random.randint(255), np.random.randint(255), np.random.randint(255)) for _ in range(20)] print(Data length: , len(dataset)) for i in range(1000): image, target= dataset.pull_item(i) # to numpy image = image.permute(1, 2, 0).numpy() # to BGR format if format == RGB: # denormalize image = image * pixel_std + pixel_mean image = image[:, :, (2, 1, 0)].astype(np.uint8) elif format == BGR: image = image * pixel_std + pixel_mean image = image.astype(np.uint8) image = image.copy() img_h, img_w = image.shape[:2] boxes = target["boxes"] labels = target["labels"] for box, label in zip(boxes, labels): x1, y1, x2, y2 = box cls_id = int(label) color = class_colors[cls_id] # class name label = VOC_CLASSES[cls_id] image = cv2.rectangle(image, (int(x1), int(y1)), (int(x2), int(y2)), (0,0,255), 2) # put the test on the bbox cv2.putText(image, label, (int(x1), int(y1 - 5)), 0, 0.5, color, 1, lineType=cv2.LINE_AA) cv2.imshow(gt, image) # cv2.imwrite(str(i)+.jpg, img) cv2.waitKey(0)

是上方的代码许多读者应该很熟悉了,就不做过多介绍了,其中mosaic_prob和mixup_prob是调用Mosaic和Mixup这两个数据增强的概率。马赛克增强就不多说了,而Mixup增强参考YOLOv5的实现,即再读取一张马赛克图像,将两张马赛克图像拼接在一起,这一点与YOLOX的实现是不一样的。

这里,在代码的最下方笔者给出了一段测试代码,我们可以运行这段代码,来看一下在VOC数据集上的预处理效果,比如像下面这样:

图1 VOC数据集的预处理结果

同理,读者也可以运行dataset/coco.py文件,来查看在COCO数据集上的可视化结果,这里就不予以演示了。

关于数据预处理操作,读者可以打开dataset/transforms.py文件来查看,代码如下所示,同样,为了节约篇幅,还是仅展示类的名称:

import random import cv2 import math import numpy as np import torch import torchvision.transforms.functional as F def refine_targets(target, img_size): ...洗一下数据,去除太极端的标签和无效的标签... def mosaic_augment(image_list, target_list, img_size): ...马赛克增强... def mixup_augment(origin_image, origin_target, new_image, new_target): ...Mixup增强... class Compose(object): ... # Convert ndarray to tensor class ToTensor(object): ...ndarray类型转换为torch的tensor类型... # DistortTransform class DistortTransform(object): ...色彩空间变换,写法借鉴了Cvpods库... # JitterCrop class JitterCrop(object): ...随机剪裁,,写法借鉴了Cvpods库... # RandomHFlip class RandomHorizontalFlip(object): ...随机水平翻转... # Normalize tensor image class Normalize(object): ...图像归一化... # Resize tensor image class Resize(object): ...将输入图像的最长边resize到指定的尺寸,如640... # Pad tensor image class PadImage(object): ...将resize后的图像填补成一个方块,如640×640,以便组成batch去训练... def __init__(self, img_size=640, adaptive=False) -> None: self.img_size = img_size self.adapative = adaptive def __call__(self, image, target=None): img_h0, img_w0 = image.shape[1:] assert max(img_h0, img_w0) <= self.img_size 最短边自适应地去填补,尽可能减少补入过多的0 if self.adapative: if img_h0 > img_w0: pad_img_h = self.img_size pad_img_w = (img_w0 // 32 + 1) * 32 elif img_h0 < img_w0: pad_img_h = (img_h0 // 32 + 1) * 32 pad_img_w = self.img_size else: pad_img_h = self.img_size pad_img_w = self.img_size pad_image = torch.zeros([image.size(0), pad_img_h, pad_img_w]).float() 直接填补成方块 else: pad_image = torch.zeros([image.size(0), self.img_size, self.img_size]).float() pad_image[:, :img_h0, :img_w0] = image return pad_image, target # BaseTransforms class BaseTransforms(object): ...只包含色彩空间变换和水平翻转两个增强的基础变换... # TrainTransform class TrainTransforms(object): 根据输入的trans_config变量来设置训练所需的数据增强 def __init__(self, trans_config=None, img_size=640, pixel_mean=(123.675, 116.28, 103.53), pixel_std=(58.395, 57.12, 57.375), format=RGB): self.trans_config = trans_config self.img_size = img_size self.pixel_mean = pixel_mean self.pixel_std = pixel_std self.format = format self.transforms = Compose(self.build_transforms(trans_config)) def build_transforms(self, trans_config): transform = [] for t in trans_config: if t[name] == DistortTransform: transform.append(DistortTransform(hue=t[hue], saturation=t[saturation], exposure=t[exposure])) elif t[name] == RandomHorizontalFlip: transform.append(RandomHorizontalFlip()) elif t[name] == JitterCrop: transform.append(JitterCrop(jitter_ratio=t[jitter_ratio])) elif t[name] == ToTensor: transform.append(ToTensor(format=self.format)) elif t[name] == Resize: transform.append(Resize(img_size=self.img_size)) elif t[name] == Normalize: transform.append(Normalize(pixel_mean=self.pixel_mean, pixel_std=self.pixel_std)) elif t[name] == PadImage: transform.append(PadImage(img_size=self.img_size)) return transform def __call__(self, image, target): image, target = self.transforms(image, target) target = refine_targets(target, self.img_size) return image, target # ValTransform class ValTransforms(object): 仅在测试阶段会调用该类,只做resize和normalize处理 def __init__(self, img_size=640, pixel_mean=(123.675, 116.28, 103.53), pixel_std=(58.395, 57.12, 57.375), format=RGB): self.img_size =img_size self.pixel_mean = pixel_mean self.pixel_std = pixel_std self.format = format self.transforms = Compose([ ToTensor(format=format), Resize(img_size=img_size), Normalize(pixel_mean=self.pixel_mean, pixel_std=self.pixel_std), PadImage(img_size=img_size, adaptive=True) ]) def __call__(self, image, target=None): return self.transforms(image, target)

1.2 FreeYOLO模型代码

接着,我们介绍一下FreeYOLO的网络代码的实现。这一部代码读者可以在该项目的下方文件中找到:

models/detector/yolo_free/yolo_free.py

import torch import numpy as np import torch.nn as nn from ...backbone import build_backbone from ...neck import build_neck, build_fpn from ...head.decoupled_head import DecoupledHead from .loss import Criterion # Anchor-free YOLO class FreeYOLO(nn.Module): def __init__(self, cfg, device, num_classes = 20, conf_thresh = 0.05, nms_thresh = 0.6, trainable = False, topk = 1000): super(FreeYOLO, self).__init__() # --------- Basic Parameters ---------- self.cfg = cfg self.device = device self.stride = cfg[stride] self.num_classes = num_classes self.trainable = trainable self.conf_thresh = conf_thresh self.nms_thresh = nms_thresh self.topk = topk # --------- Network Parameters ---------- ## backbone self.backbone, bk_dim = build_backbone(cfg=cfg, trainable=trainable) ## neck self.neck = build_neck(cfg=cfg, in_dim=bk_dim[-1], out_dim=bk_dim[-1]) ## fpn self.fpn = build_fpn(cfg=cfg, in_dims=bk_dim, out_dim=cfg[head_dim]) ## non-shared heads self.non_shared_heads = nn.ModuleList( [DecoupledHead(cfg) for _ in range(len(cfg[stride])) ]) ## pred head_dim = cfg[head_dim] self.obj_preds = nn.ModuleList( [nn.Conv2d(head_dim, 1, kernel_size=1) for _ in range(len(cfg[stride])) ]) self.cls_preds = nn.ModuleList( [nn.Conv2d(head_dim, self.num_classes, kernel_size=1) for _ in range(len(cfg[stride])) ]) self.reg_preds = nn.ModuleList( [nn.Conv2d(head_dim, 4, kernel_size=1) for _ in range(len(cfg[stride])) ]) # --------- Network Initialization ---------- if trainable: # init bias self.init_yolo() # --------- Criterion for Training ---------- if trainable: self.criterion = Criterion(cfg=cfg, device=device, loss_obj_weight=cfg[loss_obj_weight], loss_cls_weight=cfg[loss_cls_weight], loss_reg_weight=cfg[loss_reg_weight], num_classes=num_classes)

对于backbone,我们仍采用先前在YOLOv4中所使用的CSPDarkNet53,读者可以在本项目的models/backbone/cspdarknet.py 文件中找到。这里,为了方便读者使用这一网络,笔者将cspdarknet53的预训练权重上传到了云端,然后使用torch的torch.hub.load_state_dict_from_url函数去下载权重文件,无需读者单独去下载然后保存在指定位置,这样会方便些。

""" This is a CSPDarkNet-53 with Mish. """ import os import torch import torch.nn as nn model_urls = { "cspdarknet53": "https://github.com/yjh0410/PyTorch_YOLO-Family/releases/download/yolo-weight/cspdarknet53.pth", } ...此处省略CSPDarkNet-53的结构代码... # Build CSPDarkNet def build_cspdarknet(pretrained=False, res5_dilation=False): # build backbone backbone = CSPDarkNet53(res5_dilation=res5_dilation) feat_dims = [256, 512, 1024] # 载入imagenet 预训练权重 if pretrained: print(Loading pretrained weight ...) url = model_urls[cspdarknet53] checkpoint = torch.hub.load_state_dict_from_url( url=url, map_location="cpu", check_hash=True) # checkpoint state dict checkpoint_state_dict = checkpoint.pop("model") # model state dict model_state_dict = backbone.state_dict() # check for k in list(checkpoint_state_dict.keys()): if k in model_state_dict: shape_model = tuple(model_state_dict[k].shape) shape_checkpoint = tuple(checkpoint_state_dict[k].shape) if shape_model != shape_checkpoint: checkpoint_state_dict.pop(k) else: checkpoint_state_dict.pop(k) print(k) backbone.load_state_dict(checkpoint_state_dict) return backbone, feat_dims

对于neck,我们仍采用久经YOLO考验的SPP结构,读者可以在本项目的models/neck/spp.py文件中找到。不过,SPP的最终实现,我们参考了Scaled-YOLOv4给出的CSP结构的SPP,如下代码所示:

import torch import torch.nn as nn from ..basic.conv import Conv # Spatial Pyramid Pooling class SPP(nn.Module): """ Spatial Pyramid Pooling """ def __init__(self, in_dim, out_dim, expand_ratio=0.5, pooling_size=[5, 9, 13], norm_type=BN, act_type=relu): super(SPP, self).__init__() inter_dim = int(in_dim * expand_ratio) self.cv1 = Conv(in_dim, inter_dim, k=1, act_type=act_type, norm_type=norm_type) self.m = nn.ModuleList( [ nn.MaxPool2d(kernel_size=k, stride=1, padding=k // 2) for k in pooling_size ] ) self.cv2 = Conv(inter_dim*(len(pooling_size) + 1), out_dim, k=1, act_type=act_type, norm_type=norm_type) def forward(self, x): x = self.cv1(x) x = torch.cat([x] + [m(x) for m in self.m], dim=1) x = self.cv2(x) return x # SPP block with CSP module class SPPBlockCSP(nn.Module): """ CSP Spatial Pyramid Pooling Block """ def __init__(self, in_dim, out_dim, expand_ratio=0.5, pooling_size=[5, 9, 13], act_type=lrelu, norm_type=BN, depthwise=False ): super(SPPBlockCSP, self).__init__() inter_dim = int(in_dim * expand_ratio) self.cv1 = Conv(in_dim, inter_dim, k=1, act_type=act_type, norm_type=norm_type) self.cv2 = Conv(in_dim, inter_dim, k=1, act_type=act_type, norm_type=norm_type) self.m = nn.Sequential( Conv(inter_dim, inter_dim, k=3, p=1, act_type=act_type, norm_type=norm_type, depthwise=depthwise), SPP(inter_dim, inter_dim, expand_ratio=1.0, pooling_size=pooling_size, act_type=act_type, norm_type=norm_type), Conv(inter_dim, inter_dim, k=3, p=1, act_type=act_type, norm_type=norm_type, depthwise=depthwise) ) self.cv3 = Conv(inter_dim * 2, out_dim, k=1, act_type=act_type, norm_type=norm_type) def forward(self, x): x1 = self.cv1(x) x2 = self.cv2(x) x3 = self.m(x2) y = self.cv3(torch.cat([x1, x3], dim=1)) return y

对于head,我们参考YOLOX的设计,使用decoupled head,而不再是以往的coupled head,如图2所示:

图2 YOLOX使用的Decoupled Head,FreeYOLO借鉴了这一结构

Decoupled head的好处是把两种语义完全不同的分类和定位任务分开,让head专门去学用于分类的特征和用于定位的特征,这有助于加快网络的收敛。相关代码读者可以在models/head/decoupled_head中看到,如下方所示:

import torch import torch.nn as nn from ..basic.conv import Conv class DecoupledHead(nn.Module): def __init__(self, cfg): super().__init__() print(==============================) print(Head: Decoupled Head) self.num_cls_head=cfg[num_cls_head] self.num_reg_head=cfg[num_reg_head] self.act_type=cfg[head_act] self.norm_type=cfg[head_norm] self.head_dim = cfg[head_dim] self.cls_feats = nn.Sequential(*[Conv(self.head_dim, self.head_dim, k=3, p=1, s=1, act_type=self.act_type, norm_type=self.norm_type, depthwise=cfg[head_depthwise]) for _ in range(self.num_cls_head)]) self.reg_feats = nn.Sequential(*[Conv(self.head_dim, self.head_dim, k=3, p=1, s=1, act_type=self.act_type, norm_type=self.norm_type, depthwise=cfg[head_depthwise]) for _ in range(self.num_reg_head)]) def forward(self, x): """ in_feats: (Tensor) [B, C, H, W] """ cls_feats = self.cls_feats(x) reg_feats = self.reg_feats(x) return cls_feats, reg_feats

最后的预测仍是objectness、classification和bbox regression。前两个没有变动,最后的bbox regression采用了FCOS的做法,即预测anchor到边界框的四个距离。

而对于AnchorYOLO,最后的三个预测仍是使用anchor box的:

## pred head_dim = cfg[head_dim] self.obj_preds = nn.ModuleList( [nn.Conv2d(head_dim, self.num_anchors * 1, kernel_size=1) for _ in range(len(cfg[stride])) ]) self.cls_preds = nn.ModuleList( [nn.Conv2d(head_dim, self.num_anchors * self.num_classes, kernel_size=1) for _ in range(len(cfg[stride])) ]) self.reg_preds = nn.ModuleList( [nn.Conv2d(head_dim, self.num_anchors * 4, kernel_size=1) for _ in range(len(cfg[stride])) ])

对于YOLOF,由于上一章我们详细讲过了YOLOF的工作,对与本项目的YOLOF就不做过多介绍。读者仅需知道,本项目中的YOLOF除了backbone使用的是CSPDarkNet53的DC5特征图外,其他和我们先前实现的YOLOF中的YOLOF-R50-DC5是一样的。

至于前向传播部分的代码,笔者多说一句,在测试阶段,由于输入图片不是固定的,而是场边resize到指定尺寸如608,短边自适应调整。具体来说,将顶输入图片是800×484,长边800调整成608,短边480按照长边比调整至368,得到608×368尺寸的图片,然后,由于368不是32的倍数,所以我们自适应确定去补零,由于368最接近的32倍数的数是384,所以,我们将608×368的图片补零至608×384。注意,由于每次输入的图片可能不同,所以我们要为每一张输入图像都去单独生成所有的anchor box。在以往的YOLO实现中,我们保证输入尺寸都是一样的,所以anchorbox生成一次就可以一直用了,这里稍微改了策略,所以可能会有些速度上的差别。

笔者就不做介绍了,接下来,让我们移步到label assignment部分

1.3 正样本匹配

这一部分的代码读者可以在yolo_free文件夹下的matcher.py文件中找到。在文章的开头便说道,FreeYOLO的正样本匹配采用的是FCOS的方法。由于FreeYOLO中只有3个尺度,所以,设置三个尺度范围:[0, 64], [64, 128], [128, +∞]。当然,我们也可以采用YOLOX中的SimOTA,完全不需要去设置这个仍需手动设置的尺度范围。不过,限于笔者的精力和算力,这一步的改进就不做了,感兴趣的读者可以尝试一下。

正样本制作的代码如下:

import math import torch class Matcher(object): def __init__(self, num_classes, center_sampling_radius, object_sizes_of_interest): self.num_classes = num_classes self.center_sampling_radius = center_sampling_radius self.object_sizes_of_interest = object_sizes_of_interest def get_deltas(self, anchors, boxes): assert isinstance(anchors, torch.Tensor), type(anchors) assert isinstance(boxes, torch.Tensor), type(boxes) deltas = torch.cat((anchors - boxes[..., :2], boxes[..., 2:] - anchors), dim=-1) return deltas @torch.no_grad() def __call__(self, fpn_strides, anchors, targets): """ fpn_strides: (List) List[8, 16, 32, ...] stride of network output. anchors: (List of Tensor) List[F, M, 2], F = num_fpn_levels targets: (Dict) dict{boxes: [...], labels: [...], orig_size: ...} """ gt_objectness = [] gt_classes = [] gt_anchors_deltas = [] device = anchors[0].device # List[F, M, 2] -> [M, 2] anchors_over_all_feature_maps = torch.cat(anchors, dim=0).to(device) for targets_per_image in targets: # generate object_sizes_of_interest: List[[M, 2]] object_sizes_of_interest = [anchors_i.new_tensor(scale_range).unsqueeze(0).expand(anchors_i.size(0), -1) for anchors_i, scale_range in zip(anchors, self.object_sizes_of_interest)] # List[F, M, 2] -> [M, 2], M = M1 + M2 + ... + MF object_sizes_of_interest = torch.cat(object_sizes_of_interest, dim=0) # [N, 4] tgt_box = targets_per_image[boxes].to(device) # [N,] tgt_cls = targets_per_image[labels].to(device) # [N,] tgt_obj = torch.ones_like(tgt_cls) # [N, M, 4], M = M1 + M2 + ... + MF deltas = self.get_deltas(anchors_over_all_feature_maps, tgt_box.unsqueeze(1)) has_gt = True if tgt_box.shape[0] == 0: has_gt = False # no gt elif tgt_box.max().item() == 0.: has_gt = False # no valid bbox if has_gt: if self.center_sampling_radius > 0: # bbox centers: [N, 2] centers = (tgt_box[..., :2] + tgt_box[..., 2:]) * 0.5 is_in_boxes = [] for stride, anchors_i in zip(fpn_strides, anchors): radius = stride * self.center_sampling_radius # [N, 4] center_boxes = torch.cat(( torch.max(centers - radius, tgt_box[:, :2]), torch.min(centers + radius, tgt_box[:, 2:]), ), dim=-1) # [N, Mi, 4] center_deltas = self.get_deltas(anchors_i, center_boxes.unsqueeze(1)) # [N, Mi] is_in_boxes.append(center_deltas.min(dim=-1).values > 0) # [N, M], M = M1 + M2 + ... + MF is_in_boxes = torch.cat(is_in_boxes, dim=1) else: # no center sampling, it will use all the locations within a ground-truth box # [N, M], M = M1 + M2 + ... + MF is_in_boxes = deltas.min(dim=-1).values > 0 # [N, M], M = M1 + M2 + ... + MF max_deltas = deltas.max(dim=-1).values # limit the regression range for each location is_cared_in_the_level = \ (max_deltas >= object_sizes_of_interest[None, :, 0]) & \ (max_deltas <= object_sizes_of_interest[None, :, 1]) # [N,] tgt_box_area = (tgt_box[:, 2] - tgt_box[:, 0]) * (tgt_box[:, 3] - tgt_box[:, 1]) # [N,] -> [N, 1] -> [N, M] gt_positions_area = tgt_box_area.unsqueeze(1).repeat( 1, anchors_over_all_feature_maps.size(0)) gt_positions_area[~is_in_boxes] = math.inf gt_positions_area[~is_cared_in_the_level] = math.inf # if there are still more than one objects for a position, # we choose the one with minimal area # [M,], each element is the index of ground-truth try: positions_min_area, gt_matched_idxs = gt_positions_area.min(dim=0) except: print(gt_positions_area.shape, tgt_box.shape, tgt_box.shape[0]) # ground truth objectness [M,] tgt_obj_i = tgt_obj[gt_matched_idxs] # anchors with area inf are treated as background. tgt_obj_i[positions_min_area == math.inf] = 0 # ground truth classification [M,] tgt_cls_i = tgt_cls[gt_matched_idxs] # anchors with area inf are treated as background. tgt_cls_i[positions_min_area == math.inf] = self.num_classes # ground truth regression [M, 4] tgt_reg_i = self.get_deltas(anchors_over_all_feature_maps, tgt_box[gt_matched_idxs]) gt_objectness.append(tgt_obj_i) gt_classes.append(tgt_cls_i) gt_anchors_deltas.append(tgt_reg_i) else: num_anchors = anchors_over_all_feature_maps.shape[0] tgt_obj_i = torch.zeros(num_anchors, device=device) tgt_cls_i = torch.zeros(num_anchors, device=device) + self.num_classes tgt_reg_i = torch.zeros([num_anchors, 4], device=device) gt_objectness.append(tgt_obj_i) gt_classes.append(tgt_cls_i) gt_anchors_deltas.append(tgt_reg_i) # [B, M], [B, M], [B, M, 4] return torch.stack(gt_objectness), torch.stack(gt_classes), torch.stack(gt_anchors_deltas)

这一部分的实现借鉴了旷视官方的cvpods库中的fcos项目的代码。所以,再次感谢开源工作~

也许,有读者会问,如此一来,FreeYOLO又和FCOS有什么区别呢?从微观角度来说,区别在于YOLO有objectness、classification和bbox三个预测分支,FCOS只有classification和bbox两个预测分支,但其实这并没有太实质的差别,YOLO无非是把背景标签放在了objectness分支中。因此,从宏观角度来说,FreeYOLO和FCOS没差别,甚至,我们可以从框架的角度来讲,几乎所有的one-stage工作都没有差别,都是用backbone和head去直接端到端地输出所有的预测罢了。事实上,YOLO系列之所以能历久弥新,就是因为善于将已有的工作融合进来,而不是刻意去将自己与别的工作划分开来,一定要摆出泾渭分明的界限。

对于AnchorYOLO,我们采取和先前由我们实现的YOLOv3中一样的标签匹配方法,不过,借鉴FCOS的center sampling策略,除了中心点所在的cell,我们也同样考虑3×3邻域的cell来增加正样本的数量:

# label assignment for result in label_assignment_results: grid_x, grid_y, level, anchor_idx = result stride = fpn_strides[level] x1s, y1s = x1 / stride, y1 / stride x2s, y2s = x2 / stride, y2 / stride fmp_h, fmp_w = fmp_sizes[level] # 3x3 center prior for j in range(grid_y - 1, grid_y + 2): for i in range(grid_x - 1, grid_x + 2): is_in_box = (j >= y1s and j < y2s) and (i >= x1s and i < x2s) is_valid = (j >= 0 and j < fmp_h) and (i >= 0 and i < fmp_w) if is_in_box and is_valid: gt_objectness[level][bi, j, i, anchor_idx, 0] = 1.0 gt_classes[level][bi, j, i, anchor_idx, label] = 1.0 gt_bboxes[level][bi, j, i, anchor_idx] = torch.as_tensor([x1, y1, x2, y2])

不过,老实说,这个做法属实是简单粗暴了,代码实现也是简单粗暴了,完全啊没考虑一个anchor box可能已经被分配给其他gt的歧义情况。感兴趣的读者可以尝试更好的增加正样本的策略。

言归正传,在完成了正样本匹配后,我们即可去计算损失。

1.4 计算训练损失

这一部分的代码读者可以在yolo_free文件夹下的loss.py文件中找到。与YOLO系列保持一致,FreeYOLO的损失也包含三部分:objectness、classification和bbox regression。

对于objectness损失,我们采用BCE,而不是本专栏以往采用的MSE。最后,算好的loss用正样本数量去做归一化。以往,我们的YOLO实现中的损失都是在batch这个维度做归一化,这里我们做了调整,和主流方法对应上。

# loss self.obj_lossf = nn.BCEWithLogitsLoss(reduction=none) # objectness loss loss_objectness = self.obj_lossf(pred_obj, gt_objectness) loss_objectness = loss_objectness.sum() / num_foreground

对于bbox regression损失,我们采用GIoU损失函数。最后也用正样本数量去做归一化。当然,感兴趣的读者也可以尝试CIoU。

# regression loss matched_pred_delta = pred_delta[foreground_idxs] matched_tgt_delta = gt_shifts_deltas[foreground_idxs] ious = get_ious(matched_pred_delta, matched_tgt_delta, box_mode="ltrb", iou_type=giou) loss_bboxes = (1.0 - ious).sum() / num_foreground

对于classification损失,我们也采用BCE,而不是本专栏以往采用的CE。使用BCE去计算分类损失早在RetinaNet和YOLOv3中就使用了,以前之所以笔者没用,是笔者水平有限,还望读者见谅。不过,这里我们把先前预测的GIoU标签耦合到了类别标签里。如下方所示:

# classification loss matched_pred_cls = pred_cls[foreground_idxs] matched_tgt_cls = gt_classes_target[foreground_idxs] * ious.unsqueeze(1).clamp(0.) loss_labels = self.cls_lossf(matched_pred_cls, matched_tgt_cls) loss_labels = loss_labels.sum() / num_foreground

其中,matched_tgt_cls是one-hot格式的标签,ious就是上方GIoU部分算出来的正样本与gt间的GIoU,注意,这里我们会做个剪裁操作,保证ious在01之间,不要负值。事实上,这种做法并不新鲜,对于YOLOv1和YOLOv2,objectness的正样本标签就是IoU,而不是1,虽然YOLOv3和YOLOv4没在这么做,不过熟悉YOLOv5项目的读者应该了解,YOLOv5将GIoU作为objectness标签。这种做法,专业点来说,叫IoU-aware,其目的就是希望检测器能对自己的定位精度有个定量的评价。所以,我们也可以把GIoU作为objectness的学习标签,不过,GIoU我们只计算了正样本的,没计算负样本的,从实现的角度来讲,干脆把GIoU乘在同样只在正样本上去计算的类别损失的类别标签上最方便,YOLOX便是采用了这样的做法。

公式化地来说,假定某个gt的one-hot是[0,1,0,0],其对应的预测框与gt框的GIoU是0.83,则最终的one-hot标签为[0,0.83,0,0]。

最后,我们将三个损失加权求和,然后保存在一个dict变量中。

# total loss losses = self.loss_obj_weight * loss_objectness + \ self.loss_cls_weight * loss_labels + \ self.loss_reg_weight * loss_bboxes loss_dict = dict( loss_objectness = loss_objectness, loss_labels = loss_labels, loss_bboxes = loss_bboxes, losses = losses )

参照YOLOX的经验,objectness和classification两部分损失的权重设置为1.0,bbox的损失设置为5.0。bbox的损失权重设置这么大,可能是因为没有了anchor box先验,学习的难度会有所增加。而对AnchorYOLO,我们则将三个权重都设置为1.0,毕竟有anchor box,box部分会相对容易学习。

二、训练FreeYOLO

训练代码还请读者打开train.py来查看,考虑到篇幅,就不贴出来了。相较于以往的笔者YOLO项目的训练代码风格,本项目的训练代码看起来能更干净一些。训练的主题代码笔者将其放在了engine.py文件中。

对于训练配置,参照YOLOX的经验,我们将初始学习率设置为0.01/64, 即,在batchsize为64的情况下,初始学习率为0.01,对于其他大小的batchsize,初始学习率等比例缩放。比如本项目设置batchsize为16,则初始学习率为0.0025。优化器使用祖传的SGD+momentum,学习率策略使用余弦退火,省去阶梯式学习率衰减策略的手工参数的麻烦了。并且,使用EMA策略。训练的总epoch数设置为250。训练期间,我们以0.5的概率去调用mosaic和mixup,并且在最后15epoch关掉他们兄弟俩。简而言之,训练配置如下:

初始学习率:0.01/64;Batchsize=16;优化器SGD+momentum 0.9,weight decay=5e-4;Cosine Annealing Scheduler;250 epochs;初始1 epoch的warmup。Mosaic 和Mixup以0.5的概率去分别调用。最后15epoch关闭他们俩,参考YOLOX的经验。

另外,我们还是用了torch的amp库提供的混合精度训练。这在以往的笔者实现的YOLO项目中是没有的经笔者测试,对于Tesla专业卡,比如V100、A40,混合精度训练能够显著提速和省显存。而对于RTX、GTX系列的卡,如3090,提速不明显(本来就很快了),但仍就省显存。提速不明显的原因笔者认为可能是tensor数量较比专业卡更少。对于大epoch训练,混合精度带来的性能损失是可以忽略不计的。

对于单张GPU,读者可以参考笔者提供的训练脚本train.sh中给出的训练参数去训练模型。

对于多张GPU,读者可以参考train_ddp.sh给出的在两张卡上的分布式训练命令,读者可以根据自己卡的数量来调整GPU数量--nproc_per_node=2。

读者可以在README中找到已训练好的模型下载链接。

三、测试FreeYOLO

在完成了训练后,我们即可调用test.py文件来去在voc或coco上测试我们的模型。例如,我们可以通过下面的命令来运行测试代码:

python test.py -d coco \ --cuda \ -v yolo_free \ --img_size 640\--weight path/to/weight \ --root path/to/dataset/ \ --show

运行上面这段代码,读者可以看到在COCO val数据集上的可视化结果,这里就不展示了,这个专栏已经展示了太多的COOC的可视化结果,笔者已经快看腻了,嘿嘿~不过,笔者特意采集了一些真实场景下的视频来测试FreeYOLO,如下图所示,以下是视频检测结果的部分截图,做了一些必要的隐私处理。

可以看到,对车和人的检测效果还是看起来不错的,因为校园里的物体不是太丰富,基本都是车和人。第六张图是笔者在大楼上把手机相机的镜头拉大拍的街景,很模糊,所以检测结果不稳定。总的来说,这款FreeYOLO至少能用的~当然,笔者的现有精力也只能做到这一程度了,抱有更多期待的读者还请见谅。

四、计算AP

随后,我们来计算一下FreeYOLO和AnchorYOLOd AP。

4.1 VOC

在VOC上,我们计算VOC2007 test上的mAP:

ModelScalemAPYOLOF60883.7FreeYOLO60884.9AnchorYOLO60884.4

4.2 COCO

在COCO-val上的AP结果如下,除了AP结果,我们也给出了在2080ti上的FPS测试,以供参考。

图3 COCO数据集

其实,这个FreeYOLO还是有很多可优化的地方,比如以下两点:

第一,对于多尺度训练,笔者沿用的参数还是YOLOv4以前的配置:{320, 352, 384, ...,608, 640}。但现在YOLOv5和YOLOX都使用更大范围的尺度,如YOLOv5是从640*0.5-640*1.5,YOLOX是从448-832。笔者尝试过这种更大的多尺度范围:320-800,还会再涨1-2个点。

第二,对于正样本匹配,笔者实现的FreeYOLO项目用的是FCOS的正样本制作方法,设置了三个尺度范围:(0, 64), (64, 128), (128, ∞),虽然没有anchor box了,但这些用于正样本匹配的超参也是麻烦,需要根据不同数据集做不同的调整,既然近来诸如OTA、SimOTA这些dynamic label assignment方法被提出了,我们也可以利用这些新工作来进一步提升性能。笔者尝试过SimOTA,但没有涨点,可能是笔者参数没调好。

以上两点笔者受限于贫瘠的计算资源就不去做进一步的优化了,感兴趣的读者可以自行尝试一下。

五、优化FreeYOLO

5.1 FreeYOLOv2

在上面的FreeYOLO基础上,笔者引入了SimOTA,并且将多尺度训练调整至320-800,网络的backbone也做了一次替换,激活函数全部替换为SiLU,不再是Mish和LeakyReLU。具体情况读者可以看FreeYOLO项目的源码。这一次优化,我们将其命名为FreeYOLOv2。

5.2 FreeYOLO3

我们将FreeYOLOv2的backbone和fpn都替换为YOLOv7的带ELAN模块的backbone和PaFPN。训练配置和FreeYOLOv2保持一致。这一次优化,我们将其命名为FreeYOLOv3。

5.3 实验结果

优化后的FreeYOLOv2和FreeYOLOv3的实验结果分别如下:

图4 FreeYOLOv2 & FreeYOLOv3的实验结果

可以看到,在使用了YOLOv7的backbone和pafpn结构后,模型的参数量(Params)和计算量(FLOPs)均有明显地下降,但性能并没有发生退化,并且FPS也有明显提升。由此可见,YOLOv7的网络结构改进是很出色的。注意,这里的FreeYOLOv3虽然使用了yolov7的backbone和pafpn,但在head上,笔者还是保留了YOLOX的decoupled head,所以参数量要比yolov7大一些。

六、本地运行

笔者同样提供了demo.py文件,支持读者在本地的图片/视频/摄像头去使用模型。 例如,我们可以通过下面的命令去调用FreeYOLO检测本地的图片:

python demo.py --mode image \ --path_to_img data/demo/images/ \ -v yolo_free \ --img_size 640 \ --cuda \ --weight path/to/weight

检测本地视频:

python demo.py --mode video \ --path_to_img data/demo/videos/your_video \ -v yolo_free \ --img_size 640 \ --cuda \ --weight path/to/weight

调用本地的摄像头:

python demo.py --mode camera \ -v yolo_free \ --img_size 640 \ --cuda \ --weight path/to/weight

六、尾声

有很多话想在这里说出来,但奈何编辑至此,知乎的编辑器已经很卡了,写一段话的等待都成了一种折磨。所以,笔者就不再多言,衷心祝愿各位读者能够实现自己的科研梦想。以后,笔者将不再会更新专栏,也不再会做目标检测的科普了,人生很短,可我还有很多更想去做的事情,不得不和各位读者说声再见~希望这最后的FreeYOLO项目大家喜欢。

加油,但行好事,莫问前程~

更多yolov4用来做什么(yolo怎么了)相关信息请关注本站,本文仅仅做为展示!