如有错误,恳请指出。


学习yolov5的代码,这里首先从yolov5模型的搭建整个流程来介绍。以yolov5-6.0版本为例,这篇笔记主要是关于yolov5模型的构建分析,其模型的搭建代码全部放在了moodel文件下,主要的yolo.py文件完成了整个模型的搭建(调用了其他的模块)。而关于具体的一些模块的实现,可以参考我的其他文章,这篇笔记主要记录yolo.py这个文件。

在yolov5-6.0版本的yolo.py中,主要有3个内容,Detect类,Model类以及parse_model函数,以下就分别介绍这几个内容。


1. 调用关系

那么,首先需要知道这几个函数的调用关系。一般来说,在使用一个模型的时候,肯定需要先初始化一个模型,类似于:

# Create model
model = Model(opt.cfg).to(device)
model.train()

而在模型的初始化(__init__函数)中,会调用parse_model函数,对于Model传入的是yaml文件的路径,这个yaml类似与字典的结构将会传入给parse_model函数来解析,创建网络的模型结构。

class Model(nn.Module):
    def __init__(self, cfg='yolov5s.yaml', ch=3, nc=None, anchors=None):  # model, input channels, number of classes
        super().__init__()
        
        ...
        with open(cfg, errors='ignore') as f:
            self.yaml = yaml.safe_load(f)  # model dict
        
        # 创建网络模型
        # self.model: 初始化的整个网络模型(包括Detect层结构)
        # self.save: 所有层结构中from不等于-1的序号,并排好序  [4, 6, 10, 14, 17, 20, 23]
        self.model, self.save = parse_model(deepcopy(self.yaml), ch=[ch])  # model, savelist
        ...

yaml文件,以yolov5s.yaml为例,其内容如下:

# YOLOv5 🚀 by Ultralytics, GPL-3.0 license

# Parameters
nc: 20  # number of classes
depth_multiple: 0.33  # model depth multiple
width_multiple: 0.50  # layer channel multiple
anchors:
  - [10,13, 16,30, 33,23]  # P3/8
  - [30,61, 62,45, 59,119]  # P4/16
  - [116,90, 156,198, 373,326]  # P5/32

# YOLOv5 v6.0 backbone
backbone:
  # [from, number, module, args]
  [[-1, 1, Conv, [64, 6, 2, 2]],  # 0-P1/2
   [-1, 1, Conv, [128, 3, 2]],  # 1-P2/4
   [-1, 3, C3, [128]],
   [-1, 1, Conv, [256, 3, 2]],  # 3-P3/8
   [-1, 6, C3, [256]],
   [-1, 1, Conv, [512, 3, 2]],  # 5-P4/16
   [-1, 9, C3, [512]],
   [-1, 1, Conv, [1024, 3, 2]],  # 7-P5/32
   [-1, 3, C3, [1024]],
   [-1, 1, SPPF, [1024, 5]],  # 9
  ]

# YOLOv5 v6.0 head
head:
  [[-1, 1, Conv, [512, 1, 1]],
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],
   [[-1, 6], 1, Concat, [1]],  # cat backbone P4
   [-1, 3, C3, [512, False]],  # 13

   [-1, 1, Conv, [256, 1, 1]],
   [-1, 1, nn.Upsample, [None, 2, 'nearest']],
   [[-1, 4], 1, Concat, [1]],  # cat backbone P3
   [-1, 3, C3, [256, False]],  # 17 (P3/8-small)

   [-1, 1, Conv, [256, 3, 2]],
   [[-1, 14], 1, Concat, [1]],  # cat head P4
   [-1, 3, C3, [512, False]],  # 20 (P4/16-medium)

   [-1, 1, Conv, [512, 3, 2]],
   [[-1, 10], 1, Concat, [1]],  # cat head P5
   [-1, 3, C3, [1024, False]],  # 23 (P5/32-large)

   [[17, 20, 23], 1, Detect, [nc, anchors]],  # Detect(P3, P4, P5)
  ]

parse_model函数就会根据这些内容来不断的构建成一个Sequential结构,其中核心是通过eval()函数来将这些特定的模块名称字符串来转换为字符串的表达式来执行。所以其中也可以看见yaml最后一行是一个检测头Detect,那么通过eval()函数执行后就会调用Detect类,通过Detect来构建模型最后的Head结构。

调用代码如下所示:

def parse_model(d, ch):  # model_dict, input_channels(3)
    ...
    for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']):
        # eval()用于执行一个字符串表达式,并且返回该表达式的值
        m = eval(m) if isinstance(m, str) else m  # eval strings
        ...
        # 在args中加入三个Detect层的输出channel, args=[nc, anchor, ch]
        # 获取了Detect的args参数, 在m(*args)就可以构建Detect类, 传入[num class, anchor, channels]
        elif m is Detect:
            args.append([ch[x] for x in f])
            if isinstance(args[1], int):  # number of anchors
                args[1] = [list(range(args[1] * 2))] * len(f)
        ...
        # m_: 得到当前层module  如果n>1就创建多个m(当前层结构), 如果n=1就创建一个m
        m_ = nn.Sequential(*[m(*args) for _ in range(n)]) if n > 1 else m(*args)  # module
        layers.append(m_)
        ...
     return nn.Sequential(*layers), sorted(save)  

具体的实现过程见后续内容,将会对每个模块进行详细解析。


2. parse_model函数解析

刚刚上述也提到过,在model初始化的时候,首先需要的构建整个模型的前向传播过程,那么这里就需要传入yaml文件来辅助构建。有一个问题就是,一般模型的前向传播过程有可能设计不同层数的特征图进行拼接融合,那么这样的操作在yolov5中是通过记录下需要的是第几层的输出来实现的。所以这个函数最后的返回不仅仅是模块间的不断堆叠,还会返回一个参数save,来表示最后会使用这几个层的输出。

有了这个记录的参数,那么在前向传播forward的时候,如果当前层在save参数中,则会使用一个列表y来不断的保存前向传播中中间特征图的结构。因为我们知道这个输出是一会需要用到的,是需要与其他的特征进行concat拼接的。如果不在save参数中,y就不会对其保存,加快运行效率。那么保存好了特征图索引(save)与特征图中间结构(y列表),就可以在向在yaml定义,在规定的层结构进行一些特征图的融合与拼接处理了。这个是在Model类中的_forward_once函数中实现,具体代码可以见下文。

  • yolov5-6.0代码如下:
def parse_model(d, ch):  # model_dict, input_channels(3)
    """用在上面Model模块中
    解析模型文件(字典形式),并搭建网络结构
    这个函数其实主要做的就是: 更新当前层的args(参数),计算c2(当前层的输出channel) =>
                          使用当前层的参数搭建当前层 =>
                          生成 layers + save
    :params d: model_dict 模型文件 字典形式 {dict:7}  yolov5s.yaml中的6个元素 + ch
    :params ch: 记录模型每一层的输出channel 初始ch=[3] 后面会删除
    :return nn.Sequential(*layers): 网络的每一层的层结构
    :return sorted(save): 把所有层结构中from不是-1的值记下 并排序 [4, 6, 10, 14, 17, 20, 23]
    """
    LOGGER.info('\n%3s%18s%3s%10s  %-40s%-30s' % ('', 'from', 'n', 'params', 'module', 'arguments'))
    anchors, nc, gd, gw = d['anchors'], d['nc'], d['depth_multiple'], d['width_multiple']
    na = (len(anchors[0]) // 2) if isinstance(anchors, list) else anchors  # number of anchors

    # 每一层特征层的输出channels数: (classes, xywh, conf)
    no = na * (nc + 5)  # number of outputs = anchors * (classes + 5)

    layers, save, c2 = [], [], ch[-1]  # layers, savelist, ch out

    # from, number, module, args 依次遍历backbone与head列表的参数,一行有4个参数所有用4个变量接
    for i, (f, n, m, args) in enumerate(d['backbone'] + d['head']):
        # eval()用于执行一个字符串表达式,并且返回该表达式的值
        m = eval(m) if isinstance(m, str) else m  # eval strings

        # 没用
        for j, a in enumerate(args):
            try:
                args[j] = eval(a) if isinstance(a, str) else a  # eval strings
            except NameError:
                pass

        # depth gain 控制深度   n: 当前模块的次数(间接控制深度)
        n = n_ = max(round(n * gd), 1) if n > 1 else n  # depth gain
        # 如果模型是普通的bottleneck, 则设置好输入输出channel, 更新args
        if m in [Conv, GhostConv, Bottleneck, GhostBottleneck, SPP, SPPF, DWConv, MixConv2d, Focus, CrossConv,
                 BottleneckCSP, C3, C3TR, C3SPP, C3Ghost]:
            c1, c2 = ch[f], args[0]
            if c2 != no:  # if not output
                c2 = make_divisible(c2 * gw, 8)   # gw控制宽度(channels宽度), 需要被8整除

            args = [c1, c2, *args[1:]]  # [64,6,2,2] -> [3,32,6,2,2]
            # 如果当前层是BottleneckCSP/C3/C3TR, 则需要在args中加入bottleneck的个数
            if m in [BottleneckCSP, C3, C3TR, C3Ghost]:
                args.insert(2, n)  # number of repeats 在第二个位置后插入bottleneck个数n
                n = 1
        # BN层只需要返回上一层的输出channel
        elif m is nn.BatchNorm2d:
            args = [ch[f]]
        # Concat层则将f中所有的输出累加得到这层的输出channel
        elif m is Concat:
            c2 = sum([ch[x] for x in f])
        # 在args中加入三个Detect层的输出channel, args=[nc, anchor, ch]
        # 获取了Detect的args参数, 在m(*args)就可以构建Detect类, 传入[num class, anchor, channels]
        elif m is Detect:
            args.append([ch[x] for x in f])
            if isinstance(args[1], int):  # number of anchors
                args[1] = [list(range(args[1] * 2))] * len(f)
        # 维度变化处理: x(1,64,80,80) to x(1,256,40,40)
        # 由于是对wh同时处理转移到channel上, 所以需要平方(**2)处理
        elif m is Contract:
            c2 = ch[f] * args[0] ** 2
        # 维度变化处理: x(1,64,80,80) to x(1,16,160,160)
        # 同样由于是对wh同时处理转移到channel上, 所以需要平方(**2)处理
        elif m is Expand:
            c2 = ch[f] // args[0] ** 2
        # 其他比如是nn.Upsample就返回当前输出channel就可以了
        else:
            c2 = ch[f]

        # m_: 得到当前层module  如果n>1就创建多个m(当前层结构), 如果n=1就创建一个m
        m_ = nn.Sequential(*[m(*args) for _ in range(n)]) if n > 1 else m(*args)  # module

        # 打印当前模块的信息: 'from', 'n', 'params', 'module', 'arguments'
        # eg: 0                -1  1      3520  models.common.Conv                      [3, 32, 6, 2, 2]
        t = str(m)[8:-2].replace('__main__.', '')  # module type
        np = sum([x.numel() for x in m_.parameters()])  # number params 计算这一层的参数量
        m_.i, m_.f, m_.type, m_.np = i, f, t, np  # attach index, 'from' index, type, number params
        LOGGER.info('%3s%18s%3s%10.0f  %-40s%-30s' % (i, f, n_, np, t, args))

        # 把所有层结构中from不是-1的值记下, 用于构建head结构  [6, 4, 14, 10, 17, 20, 23]
        save.extend(x % i for x in ([f] if isinstance(f, int) else f) if x != -1)  # append to savelist
        layers.append(m_)

        # ch: 记录模型每一层的输出channel 初始ch=[3] 后面会删除
        if i == 0:
            ch = []
        ch.append(c2)

    return nn.Sequential(*layers), sorted(save)

在代码中注释我写得挺清楚了,这里就不再做过多的介绍


3. Detect类解析

Detect模块是用来构建Detect层的,其实就是一个1x1的卷积处理。对于后来传进来的x,包含3个列表,因为是3个尺度的特征图。这里对其进行相同输出channel的降维或者是升维处理,反正重点就是让其输出的channel是一致的,具体的channel数值是(nclass + 5),这里的5是xywh + conf。进行卷积处理后,就可以对其的维度进行一些变化,以进行后续的模型训练损失计算。

同时,需要注意,在训练的时候与推理的时候这里Detect的输出是不一致是。在训练时,输出的是一个tensor list 存放三个元素 [bs, anchor_num, grid_w, grid_h, xywh+c+20classes]分别是 [1, 3, 80, 80, 25], [1, 3, 40, 40, 25], [1, 3, 20, 20, 25];而在推理的时候,输出使用的是多尺度堆叠的anchor结果, [bs, anchor_num*grid_w*grid_h, xywh+c+20classes] -> [1, 19200+4800+1200, 25]。具体的原因在之前yolov3-spp中也有介绍过,是因为训练结果设计正负样本的匹配来构建损失函数,而推理不需要只需要进行nms后处理来挑选合适的anchor来显示结果。详细见之前的yolov3-spp笔记:

Yolov3-spp系列 | yolov3spp的正负样本匹配

Yolov3-spp系列 | yolov3spp的训练验证与测试过程

  • yolov5实现代码:
class Detect(nn.Module):
    stride = None  # strides computed during build
    onnx_dynamic = False  # ONNX export parameter

    def __init__(self, nc=80, anchors=(), ch=(), inplace=True):  # detection layer
        # anchor: 传入yaml文件的3个设定好的anchor, 一个列表包含3个子列表
        # ch: [128, 256, 512] 3个输出feature map的channel
        super().__init__()
        self.nc = nc  # number of classes
        self.no = nc + 5  # number of outputs per anchor
        self.nl = len(anchors)  # number of detection layers: 3
        self.na = len(anchors[0]) // 2   # number of anchors: 3
        self.grid = [torch.zeros(1)] * self.nl  # init grid
        self.anchor_grid = [torch.zeros(1)] * self.nl  # init anchor grid

        # 模型中需要保存的参数一般有两种:一种是反向传播需要被optimizer更新的,称为parameter; 另一种不要被更新称为buffer
        # buffer的参数更新是在forward中,而optim.step只能更新nn.parameter类型的参数, 这里的anhcor就是不需要方向传播更新的
        # 动态构建对象的好方法,与setattr的使用方法类似: self.register_buffer('x', y) -> self.x = y
        self.register_buffer('anchors', torch.tensor(anchors).float().view(self.nl, -1, 2))  # shape(nl,na,2)

        # 普通的1x1卷积处理每层特征层
        self.m = nn.ModuleList(nn.Conv2d(x, self.no * self.na, 1) for x in ch)  # output conv
        self.inplace = inplace  # use in-place ops (e.g. slice assignment) 一般都是True 默认不使用AWS Inferentia加速

    def forward(self, x):
        """
        Return:
            train: 一个tensor list 存放三个元素   [bs, anchor_num, grid_w, grid_h, xywh+c+20classes]
                   分别是 [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25]
            inference: 0 [1, 19200+4800+1200, 25] = [bs, anchor_num*grid_w*grid_h, xywh+c+20classes]
                       1 一个tensor list 存放三个元素 [bs, anchor_num, grid_w, grid_h, xywh+c+20classes]
                         [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25]
        """
        # 有空传, 所以需要debug两次才能正常跳进这里处理

        z = []  # inference output
        for i in range(self.nl):
            # (b,c,h,w -> b,nc+5,h,w) 宽高保持不变, 将3个特征层的channel统一
            x[i] = self.m[i](x[i])  # conv
            bs, _, ny, nx = x[i].shape

            # (b,c,h,w) -> (b,3,nc+5,h,w) -> (b,3,h,w,nc+xywh+conf)
            # eg: x(bs,255,20,20) -> x(bs,3,20,20,85)
            x[i] = x[i].view(bs, self.na, self.no, ny, nx).permute(0, 1, 3, 4, 2).contiguous()

            # self.training属于父类nn.Module的一个变量
            # model.train()的调用self.training = True; model.eval()的调用self.training = False
            if not self.training:  # inference
                if self.grid[i].shape[2:4] != x[i].shape[2:4] or self.onnx_dynamic:
                    self.grid[i], self.anchor_grid[i] = self._make_grid(nx, ny, i)

                # sigmoid控制数值范围, 对于xywh都做了sigmoid
                y = x[i].sigmoid()

                # 选择直接inplace置换,或者再重新拼接输出, 这里是yolov5回归机制的核心代码
                if self.inplace:
                    # xy坐标回归预测: bx = 2σ(tx) - 0.5 + cx | by = 2σ(ty) - 0.5 + cy
                    # box center的x,y的预测被乘以2并减去了0.5,所以这里的值域从yolov3里的(0,1)开区间,变成了(-0.5,1.5)
                    # 从表面理解是yolov5可以跨半个格点预测了,这样可以提高对格点周围的bbox的召回.
                    # 还有一个好处就是也解决了yolov3中因为sigmoid开区间而导致中心无法到达边界处的问题
                    y[..., 0:2] = (y[..., 0:2] * 2. - 0.5 + self.grid[i]) * self.stride[i]  # xy

                    # wh宽高回归预测: bw = pw(2σ(tw))^2 | bh = ph(2σ(th))^2
                    # 值域从基于anchor宽高的(0, +∞)变成了(0, 4), 预测的框范围更精准了,通过sigmoid约束让回归的框比例尺寸更为合理
                    y[..., 2:4] = (y[..., 2:4] * 2) ** 2 * self.anchor_grid[i]  # wh

                else:  # for YOLOv5 on AWS Inferentia https://github.com/ultralytics/yolov5/pull/2953 (类似)
                    # 不同的预测特征层尺度不同, 需要乘上不同的系数回归到原图大小
                    xy = (y[..., 0:2] * 2. - 0.5 + self.grid[i]) * self.stride[i]  # xy
                    # 不同的预测特征层使用anchor大小不同, 预测目标尺寸不同, 需要乘上相对于特征点的anchor大小
                    wh = (y[..., 2:4] * 2) ** 2 * self.anchor_grid[i]  # wh
                    # 重新concat拼接在一起
                    y = torch.cat((xy, wh, y[..., 4:]), -1)

                # z是一个tensor list 三个元素 分别是[1, 19200, 25] [1, 4800, 25] [1, 1200, 25]
                z.append(y.view(bs, -1, self.no))

        # Train: [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25]
        # Eval:  0: [1, 19200+4800+1200, 25]
        #        1: [1, 3, 80, 80, 25] [1, 3, 40, 40, 25] [1, 3, 20, 20, 25]
        return x if self.training else (torch.cat(z, 1), x)

    # 构造网格
    def _make_grid(self, nx=20, ny=20, i=0):
        d = self.anchors[i].device
        # yv: tensor([[0, 0, 0, 0], [1, 1, 1, 1], [2, 2, 2, 2], [3, 3, 3, 3], ...])
        # xv: tensor([[0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3], [0, 1, 2, 3], ...])
        yv, xv = torch.meshgrid([torch.arange(ny).to(d), torch.arange(nx).to(d)])

        # 构建网格的特征点
        # (80,80)&(80,80) -> (80,80,2) -> (1,3,80,80,2) 复制3份
        grid = torch.stack((xv, yv), 2).expand((1, self.na, ny, nx, 2)).float()

        # 构建网格的anchor, 每一个特征图(h,w)都有3种不同尺度的anchor
        # stride是为了复原每个预测特征层的anchor的绝对大小, 因为每一层尺度是不一样的
        # (3,2) -> (1,3,1,1,2) -> (1,3,80,80,2)
        anchor_grid = (self.anchors[i].clone() * self.stride[i]) \
            .view((1, self.na, 1, 1, 2)).expand((1, self.na, ny, nx, 2)).float()

        return grid, anchor_grid

注意yolov5的回归机制相比v3进行了改进,也就是核心的回归预测代码:

y = x[i].sigmoid()
y[..., 0:2] = (y[..., 0:2] * 2. - 0.5 + self.grid[i]) * self.stride[i]  # xy
y[..., 2:4] = (y[..., 2:4] * 2) ** 2 * self.anchor_grid[i]  # wh

关于这几行代码去的解释我在注释里解释过,这里再介绍一下:YOLOv5网络详解,这篇博客介绍得非常清楚。这里是yolov5参考yolov4提出的一个改进,用来消除grid网格的敏感度。

  • yolov3的bbox回归机制如下图所示:

在这里插入图片描述

  • 而yolov5的回归机制如下图所示:

在这里插入图片描述

分析:

  • xy回归:当真实目标中心点非常靠近网格的左上角点时,网络的预测值需要负无穷或者正无穷时才能取到,而这种很极端的值网络一般无法达到。为了解决这个问题,作者对偏移量进行了缩放从原来的(0, 1)缩放到(-0.5, 1.5)这样网络预测的偏移量就能很方便达到0或1。所以形式改为bx = 2σ(tx) - 0.5 + cx | by = 2σ(ty) - 0.5 + cy,引入缩放系数scale,yolov5可以跨半个格点预测了,这样还可以提高对格点周围的bbox的召回。
  • wh回归:原来yolov3-spp的计算公式并没有对预测目标宽高做限制,这样可能出现梯度爆炸,训练不稳定等问题。所以yolov5的预测缩放比例变成了: ( 2 ∗ w p r e d / h p r e d ) 2 (2*w_{pred}/h_{pred}) ^2 (2wpred/hpred)2,值域从基于anchor宽高的(0,+∞)变成了(0,4),使得预测的框范围更精准了,通过sigmoid约束,让回归的框比例尺寸更为合理。

其次就是构建网格的时候,针对每个特征点分配到anchor与网格,都是需要乘上步长(下采样倍率)的。这是因为不同的预测特征层尺度不同, 需要乘上不同的系数回归到原图大小,不同的预测特征层使用anchor大小不同, 预测目标尺寸不同, 需要乘上相对于特征点的anchor大小。

其他的可以看注释,我写得比较清楚了。


4. Model类解析

这个模块是整个模型的搭建模块。且yolov5的作者将这个模块的功能写的很全,不光包含模型的搭建,还扩展了很多功能如:特征可视化,打印模型信息、TTA推理增强、融合Conv+Bn加速推理,autoshape函数:模型包含前处理、推理、后处理的模块(预处理 + 推理 + nms)。具体的解析都在注释里了,可以直接查看,对于TTA推理增强与融合Conv+Bn加速推理这两个算是yolov5的一个trick,之后我分别单独对其进行介绍,见博文:

YOLOv5的Tricks | 【Trick3】Test Time Augmentation(TTA)

YOLOv5的Tricks | 【Trick4】参数重结构化(融合Conv+BatchNorm2d)

  • yolov5实现代码:
class Model(nn.Module):
    def __init__(self, cfg='yolov5s.yaml', ch=3, nc=None, anchors=None):  # model, input channels, number of classes
        super().__init__()
        # 如果直接传入的是dict则无需处理; 如果不是则使用yaml.safe_load加载yaml文件
        if isinstance(cfg, dict):
            self.yaml = cfg  # model dict
        else:  # is *.yaml
            import yaml  # for torch hub
            self.yaml_file = Path(cfg).name
            # 如果配置文件中有中文,打开时要加encoding参数
            with open(cfg, errors='ignore') as f:
                self.yaml = yaml.safe_load(f)  # model dict

        # Define model
        ch = self.yaml['ch'] = self.yaml.get('ch', ch)  # input channels
        if nc and nc != self.yaml['nc']:
            LOGGER.info(f"Overriding model.yaml nc={self.yaml['nc']} with nc={nc}")
            self.yaml['nc'] = nc  # override yaml value
        if anchors:
            LOGGER.info(f'Overriding model.yaml anchors with anchors={anchors}')
            self.yaml['anchors'] = round(anchors)  # override yaml value

        # 创建网络模型
        # self.model: 初始化的整个网络模型(包括Detect层结构)
        # self.save: 所有层结构中from不等于-1的序号,并排好序  [4, 6, 10, 14, 17, 20, 23]
        self.model, self.save = parse_model(deepcopy(self.yaml), ch=[ch])  # model, savelist

        # default class names ['0', '1', '2',..., '19']
        self.names = [str(i) for i in range(self.yaml['nc'])]  # default names
        self.inplace = self.yaml.get('inplace', True)

        # Build strides, anchors
        # 获取Detect模块的stride(相对输入图像的下采样率)和anchors在当前Detect输出的feature map的尺度
        m = self.model[-1]  # Detect()
        if isinstance(m, Detect):
            s = 256  # 2x min stride
            m.inplace = self.inplace
            # 传入一个假图像(1,3,356,256)来自动获取每个特征层的下采样倍率
            # 计算三个feature map下采样的倍率: [256//32, 256//16, 256//8] -> [8, 16, 32]
            m.stride = torch.tensor([s / x.shape[-2] for x in self.forward(torch.zeros(1, ch, s, s))])  # forward
            # 求出相对当前feature map的anchor大小 如[10, 13]/8 -> [1.25, 1.625]
            m.anchors /= m.stride.view(-1, 1, 1)
            check_anchor_order(m)
            self.stride = m.stride
            self._initialize_biases()  # only run once

        # Init weights, biases
        initialize_weights(self)   # 进队bn层稍微设置, 激活函数设置为inplace
        self.info()                # 打印模型信息
        LOGGER.info('')

    def forward(self, x, augment=False, profile=False, visualize=False):  # debug同样需要第三次才能正常跳进来
        if augment:     # use Test Time Augmentation(TTA), 如果打开会对图片进行scale和flip
            return self._forward_augment(x)  # augmented inference, None
        return self._forward_once(x, profile, visualize)  # single-scale inference, train

    def _forward_augment(self, x):
        img_size = x.shape[-2:]  # height, width
        s = [1, 0.83, 0.67]  # scales
        f = [None, 3, None]  # flips (2-ud上下flip, 3-lr左右flip)
        y = []  # outputs

        # 这里相当于对输入x进行3次不同参数的测试数据增强推理, 每次的推理结构都保存在列表y中
        for si, fi in zip(s, f):
            # scale_img缩放图片尺寸
            # 通过普通的双线性插值实现,根据ratio来控制图片的缩放比例,最后通过pad 0补齐到原图的尺寸
            xi = scale_img(x.flip(fi) if fi else x, si, gs=int(self.stride.max()))
            yi = self._forward_once(xi)[0]  # forward:torch.Size([1, 25200, 25])
            # cv2.imwrite(f'img_{si}.jpg', 255 * xi[0].cpu().numpy().transpose((1, 2, 0))[:, :, ::-1])  # save

            # _descale_pred将推理结果恢复到相对原图图片尺寸, 只对坐标xywh:yi[..., :4]进行恢复
            # 如果f=2,进行上下翻转; 如果f=3,进行左右翻转
            yi = self._descale_pred(yi, fi, si, img_size)
            y.append(yi)    # [b, 25200, 25] / [b, 18207, 25] / [b, 12348, 25]

        # 把第一层的后面一部分的预测结果去掉, 也把最后一层的前面一部分的预测结果去掉
        # [b, 24000, 25] / [b, 18207, 25] / [b, 2940, 25]
        # 筛除的可能是重复的部分吧, 提高运行速度(有了解的朋友请告诉我一下)
        y = self._clip_augmented(y)  # clip augmented tails
        return torch.cat(y, 1), None  # augmented inference, train

    def _forward_once(self, x, profile=False, visualize=False):
        # y列表用来保存中间特征图; dt用来记录每个模块执行10次的平均时长
        y, dt = [], []  # outputs

        # 对sequence模型进行遍历操作, 不断地对输入x进行处理, 中间结果需要保存的时候另外存储到列表y中
        for m in self.model:
            # 如果只是对前一个模块的输出进行操作, 则需要提取直接保存的中间特征图进行操作,
            # 一般是concat处理, 对当前层与之前曾进行一个concat再卷积; detect模块也需要提取3个特征层来处理
            if m.f != -1:  # if not from previous layer
                x = y[m.f] if isinstance(m.f, int) else [x if j == -1 else y[j] for j in m.f]  # from earlier layers

            # profile参数打开会记录每个模块的平均执行10次的时长和flops用于分析模型的瓶颈, 提高模型的执行速度和降低显存占用
            if profile:
                self._profile_one_layer(m, x, dt)

            # 使用当前模块对特征图进行处理
            # 如果是concat模块: 则x是一个特征图列表, 则对其进行拼接处理, 再交给下一个卷积模块;
            # 如果是C3, Conv等普通的模块: 则x是单一特征图
            # 如果是detct模块: 则x是3个特征图的列表 (训练与推理返回的内容不一样)
            x = m(x)  # run
            # self.save: 把所有层结构中from不是-1的值记下并排序 [4, 6, 10, 14, 17, 20, 23]
            y.append(x if m.i in self.save else None)  # save output

            # 特征可视化
            if visualize:
                feature_visualization(x, m.type, m.i, save_dir=visualize)
        return x

    def _descale_pred(self, p, flips, scale, img_size):
        # de-scale predictions following augmented inference (inverse operation)
        if self.inplace:
            p[..., :4] /= scale  # de-scale xywh坐标缩放回原来大小
            # f=2,进行上下翻转
            if flips == 2:
                p[..., 1] = img_size[0] - p[..., 1]  # de-flip ud
            # f=3,进行左右翻转
            elif flips == 3:
                p[..., 0] = img_size[1] - p[..., 0]  # de-flip lr
        else:
            x, y, wh = p[..., 0:1] / scale, p[..., 1:2] / scale, p[..., 2:4] / scale  # de-scale
            if flips == 2:
                y = img_size[0] - y  # de-flip ud
            elif flips == 3:
                x = img_size[1] - x  # de-flip lr
            p = torch.cat((x, y, wh, p[..., 4:]), -1)
        return p

    # 这里y的一个包含3个子列表的列表, 通过对输入图像x进行了3次不同尺度的变换, 所以得到了3个inference结构
    # 这里看不太懂, 不过大概做的事情就是对第一个列表与最后一个列表的结果做一些过滤处理
    # 把第一层的后面一部分的预测结果去掉, 也把最后一层的前面一部分的预测结果去掉, 然后剩下的concat为一个部分
    def _clip_augmented(self, y):
        # Clip YOLOv5 augmented inference tails
        nl = self.model[-1].nl  # Detect(): number of detection layers (P3-P5)
        g = sum(4 ** x for x in range(nl))  # grid points
        e = 1  # exclude layer count
        i = (y[0].shape[1] // g) * sum(4 ** x for x in range(e))  # indices: (25200 // 21) * 1 = 1200
        y[0] = y[0][:, :-i]  # large: (1,25200,25) -> (1,24000,25)
        i = (y[-1].shape[1] // g) * sum(4 ** (nl - 1 - x) for x in range(e))  # indices: (12348 // 21) * 16 = 9408
        y[-1] = y[-1][:, i:]  # small: (1,12348,25) -> (1,2940,25)
        return y

    def _profile_one_layer(self, m, x, dt):
        c = isinstance(m, Detect)  # is final layer, copy input as inplace fix
        # profile函数返回flops与params, [0]表示计算浮点数
        o = thop.profile(m, inputs=(x.copy() if c else x,), verbose=False)[0] / 1E9 * 2 if thop else 0  # FLOPs
        t = time_sync()
        # 查看模块执行10次的平均时长
        for _ in range(10):
            m(x.copy() if c else x)
        dt.append((time_sync() - t) * 100)
        # 记录相关参数
        if m == self.model[0]:
            LOGGER.info(f"{'time (ms)':>10s} {'GFLOPs':>10s} {'params':>10s}  {'module'}")
        LOGGER.info(f'{dt[-1]:10.2f} {o:10.2f} {m.np:10.0f}  {m.type}')
        # 到最后检测头的时候再输出总花费时长
        if c:
            LOGGER.info(f"{sum(dt):10.2f} {'-':>10s} {'-':>10s}  Total")

    # 对Detect()进行初始化
    def _initialize_biases(self, cf=None):  # initialize biases into Detect(), cf is class frequency
        # https://arxiv.org/abs/1708.02002 section 3.3
        # cf = torch.bincount(torch.tensor(np.concatenate(dataset.labels, 0)[:, 0]).long(), minlength=nc) + 1.
        m = self.model[-1]  # Detect() module
        for mi, s in zip(m.m, m.stride):  # from
            b = mi.bias.view(m.na, -1)  # conv.bias(255) to (3,85)
            b.data[:, 4] += math.log(8 / (640 / s) ** 2)  # obj (8 objects per 640 image)
            b.data[:, 5:] += math.log(0.6 / (m.nc - 0.99)) if cf is None else torch.log(cf / cf.sum())  # cls
            mi.bias = torch.nn.Parameter(b.view(-1), requires_grad=True)

    # 打印模型中最后Detect层的偏置bias信息(也可以任选哪些层bias信息)
    def _print_biases(self):
        m = self.model[-1]  # Detect() module
        for mi in m.m:  # from
            b = mi.bias.detach().view(m.na, -1).T  # conv.bias(255) to (3,85)
            LOGGER.info(
                ('%6g Conv2d.bias:' + '%10.3g' * 6) % (mi.weight.shape[1], *b[:5].mean(1).tolist(), b[5:].mean()))

    # def _print_weights(self):
    #     for m in self.model.modules():
    #         if type(m) is Bottleneck:
    #             LOGGER.info('%10.3g' % (m.w.detach().sigmoid() * 2))  # shortcut weights

    # 参数重结构化: 融合conv2d + batchnorm2d (推理的时候用, 可以加快模型的推理速度)
    def fuse(self):  # fuse model Conv2d() + BatchNorm2d() layers
        LOGGER.info('Fusing layers... ')
        for m in self.model.modules():
            # 对Conv与DWConv(继承Conv)的结构进行卷积与bn的融合
            if isinstance(m, (Conv, DWConv)) and hasattr(m, 'bn'):
                # 融合Conv模块中的conv与bn层(不包含激活函数), 返回的是参数融合后的卷积
                m.conv = fuse_conv_and_bn(m.conv, m.bn)  # update conv
                # 融合后conv的参数就包含了bn的用途, 所以可以删除bn层
                delattr(m, 'bn')  # remove batchnorm
                # 由于不需要bn层, 所以forward函数需要改写:
                # self.act(self.bn(self.conv(x))) -> self.act(self.conv(x))
                m.forward = m.forward_fuse  # update forward
        self.info()
        return self

    # 直接调用common.py中的AutoShape模块  也是一个扩展模型功能的模块
    def autoshape(self):  # add AutoShape module
        LOGGER.info('Adding AutoShape... ')
        #  扩展模型功能 此时模型包含前处理、推理、后处理的模块(预处理 + 推理 + nms)
        m = AutoShape(self)  # wrap model
        copy_attr(m, self, include=('yaml', 'nc', 'hyp', 'names', 'stride'), exclude=())  # copy attributes
        return m

    # 用在上面的__init__函数上, 调用torch_utils.py下model_info函数打印模型信息(需要设置verbose=True才会打印)
    def info(self, verbose=False, img_size=640):  # print model information
        model_info(self, verbose, img_size)

    def _apply(self, fn):
        # Apply to(), cpu(), cuda(), half() to model tensors that are not parameters or registered buffers
        self = super()._apply(fn)
        m = self.model[-1]  # Detect()
        if isinstance(m, Detect):
            m.stride = fn(m.stride)
            m.grid = list(map(fn, m.grid))
            if isinstance(m.anchor_grid, list):
                m.anchor_grid = list(map(fn, m.anchor_grid))
        return self

这里部分的函数还会调用torch_utils来实现,比如fuse_conv_and_bn来实现卷积与批归一化的融合,以及scale_img通过双线性插值来实现图像的缩放(在通过pad0来补齐到原图的尺寸)。这里额外贴上scale_img这个辅助函数,而fuse_conv_and_bn介绍见我令一篇博文:YOLOv5的Tricks | 【Trick4】参数重结构化(融合Conv+BatchNorm2d)

scale_img函数:

# 通过普通的双线性插值实现,根据ratio来控制图片的缩放比例,最后通过pad 0补齐到原图的尺寸
def scale_img(img, ratio=1.0, same_shape=False, gs=32):  # img(16,3,256,416)
    # scales img(bs,3,y,x) by ratio constrained to gs-multiple
    if ratio == 1.0:
        return img
    else:
        h, w = img.shape[2:]
        s = (int(h * ratio), int(w * ratio))  # new size
        img = F.interpolate(img, size=s, mode='bilinear', align_corners=False)  # resize
        if not same_shape:  # pad/crop img
            h, w = [math.ceil(x * ratio / gs) * gs for x in (h, w)]
        return F.pad(img, [0, w - s[1], 0, h - s[0]], value=0.447)  # value = imagenet mean

参考资料:

1. yolov5深度剖析+源码debug级讲解系列(三)yolov5 head源码解析

2. Yolov3-spp系列 | yolov3spp的正负样本匹配

3. Yolov3-spp系列 | yolov3spp的训练验证与测试过程

4. YOLOv5网络详解

5. Yolo系列 | Yolov4v5的模型结构与正负样本匹配

6. 【YOLOV5-5.x 源码解读】yolo.py

7. 模型参数量Params与计算量Flops的计算方法

8. YOLOv5的Tricks | 【Trick4】参数重结构化(融合Conv+BatchNorm2d)

9. YOLOv5的Tricks | 【Trick3】Test Time Augmentation(TTA)

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐