更新: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项目大家喜欢。
加油,但行好事,莫问前程~