Morrison.J Android Dev Engineer

YOLOv5模型推理与输出层

2021-06-26
Jasper
AI

YOLOv5的推理与输出层,涵盖模型信息、grid、loss、设备端移植等,尤其是build_targets函数进行了超详细的注释。

模型加载与信息获取

model = attempt_load(weights, map_location=device) # load FP32 model

本文只考虑一个模型的情况,如果考虑多个模型,参考“Pytorch 集成学习”相关内容。

YOLOv5在训练时,除了保存整个网络结构和权重,还将一些必要的信息保存在模型文件中。

ckpt = {'epoch': epoch,
            'best_fitness': best_fitness,
            'training_results': results_file.read_text(),
            'model': deepcopy(de_parallel(model)).half(),
            'ema': deepcopy(ema.ema).half(),
            'updates': ema.updates,
            'optimizer': optimizer.state_dict(),
            'wandb_id': wandb_logger.wandb_run.id if loggers['wandb'] else None}

    # Save last, best and delete
    torch.save(ckpt, last)
    if best_fitness == fi:
        torch.save(ckpt, best)

'model': deepcopy(de_parallel(model)).half(),可知,模型保存是半精度;并且保存整个模型,而不是state_dict。

当完成训练后,做了一下strip,扔掉一些不需要的信息:

# Strip optimizers
for f in last, best:
    if f.exists():
        strip_optimizer(f)  # strip optimizers
def strip_optimizer(f='best.pt', s=''):  # from utils.general import *; strip_optimizer()
    # Strip optimizer from 'f' to finalize training, optionally save as 's'
    x = torch.load(f, map_location=torch.device('cpu'))
    if x.get('ema'):
        x['model'] = x['ema']  # replace model with ema
    for k in 'optimizer', 'training_results', 'wandb_id', 'ema', 'updates':  # keys
        x[k] = None
    x['epoch'] = -1
    x['model'].half()  # to FP16
    for p in x['model'].parameters():
        p.requires_grad = False
    torch.save(x, s or f)
    mb = os.path.getsize(s or f) / 1E6  # filesize
    print(f"Optimizer stripped from {f},{(' saved as %s,' % s) if s else ''} {mb:.1f}MB")

如果是使用了EMA,则提取EMA作为最终的model,并将一些属性设置为None,epoch计数改成-1,转换为FP16(其实这个重复了)。

假设已经启用了EMA,某些信息被保存:
ema.update_attr(model, include=['yaml', 'nc', 'hyp', 'gr', 'names', 'stride', 'class_weights'])

yaml:配置文件
nc:类别个数
hyp:超参
gr:iou loss ratio,默认是1.0
names:labels
stride:跨度信息,表示输出层的缩放比例,默认是 [ 8., 16., 32.]
class_weights:类别间的权重信息

以上信息都可以在train.py或者yolo.py中看到相关的保存代码。

    model.nc = nc  # attach number of classes to model
    model.hyp = hyp  # attach hyperparameters to model
    model.gr = 1.0  # iou loss ratio (obj_loss = 1.0 or iou)
    model.class_weights = labels_to_class_weights(dataset.labels, nc).to(device) * nc  # attach class weights
    model.names = names
    if isinstance(m, Detect):
        m.inplace = self.inplace
        m.stride = torch.tensor([s / x.shape[-2] for x in self.forward(torch.zeros(1, ch, s, s))])  # forward
        m.anchors /= m.stride.view(-1, 1, 1)
        check_anchor_order(m)
        self.stride = m.stride

保存信息在model文件中,给训练和推理带来的极大的便利,至少我们不用向往常那样,拖家带口似的:模型权重文件、label文件、yaml文件、anchors文件。注意,有的信息是在Detect层。

打印Detect层所附带的anchors:

for name, layer in model.named_modules():
    if hasattr(layer, "anchors"):
        print("==========", layer, layer.anchors)
========== Detect(
  (m): ModuleList(
    (0): Conv2d(128, 255, kernel_size=(1, 1), stride=(1, 1))
    (1): Conv2d(256, 255, kernel_size=(1, 1), stride=(1, 1))
    (2): Conv2d(512, 255, kernel_size=(1, 1), stride=(1, 1))
  )
) tensor([[[ 1.25000,  1.62500],
         [ 2.00000,  3.75000],
         [ 4.12500,  2.87500]],

        [[ 1.87500,  3.81250],
         [ 3.87500,  2.81250],
         [ 3.68750,  7.43750]],

        [[ 3.62500,  2.81250],
         [ 4.87500,  6.18750],
         [11.65625, 10.18750]]], device='cuda:0')

Detect层

Detect层是YOLOv5最后一层,包含三个输出,分别是下降stride(见stribe属性,8,16,32)倍的网格。

插曲: 当在pytorch环境中load model文件时,使用的pickle lib进行反序列化为对象,再调用对象中的方法完成推理。这时,依赖于项目中的python代码,我们可以在代码(如Detect->forward)中修改逻辑,进行调试或者变更推理过程; 当转换为onnx或者torchscript时,则可以使用c++进行加载和推理,此时,依赖的是纯c++环境。

class Detect(nn.Module):
    def forward(self, x):
        for i in range(self.nl):
            x[i] = self.m[i](x[i])  # conv
            bs, _, ny, nx = x[i].shape  # x(bs,255,20,20) to 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()
            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._make_grid(nx, ny).to(x[i].device)

                y = x[i].sigmoid()
                if self.inplace:
                    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
                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
                    wh = (y[..., 2:4] * 2) ** 2 * self.anchor_grid[i].view(1, self.na, 1, 1, 2)  # wh
                    y = torch.cat((xy, wh, y[..., 4:]), -1)
                z.append(y.view(bs, -1, self.no))

        return x if self.training else (torch.cat(z, 1), x)

Forward

以coco 80类,输入尺寸640x640为例;但输入是可以任意设置的,比如384x640.在函数
def letterbox(img, new_shape=(640, 640), color=(114, 114, 114), auto=True, scaleFill=False, scaleup=True, stride=32)
中,预处理resize使用的padding等比例方式,长短边都是32的倍数,长边是640。

结合https://nextstart.online/2021/06/19/YOLOv5/,Focus负责下降2倍,backbone中的每一个CBL负责将输入下降2倍。

Detect层将下降8,16,32倍的节点concat到一起作为输入,见网络配置文件yolov5s.yaml:

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

[17, 20, 23]表示layer id,构建Detect的参数[nc, anchors]在推理时,已经是反序列化对象中的属性,所以,这个配置在这没有参考价值。

Detect layer 的forward函数,接收的x是长度为3的一个 list of feature map。

x[self.nl]分别经过一个卷积,使得其大小正好是网格的各维度的乘积,.view().permute()操作之后正好是x(bs,3,20,20,85)

插曲: 根据经验,当移植到端设备(比如海思平台)时,可以从这里截断,或者前一点,从卷积之后截断;从不同的位置截断作为网络的输出,推理的后处理步骤就应相应地变化。 甚至如果可以,不用截断,所有Detect层的操作在网络中执行,但要注意某些接口是否支持。

再看看forward的剩余部分。

grid是每一个网格三个anchor,self.grid = [torch.zeros(1)] * self.nl # init grid,等于[tensor([0.]), tensor([0.]), tensor([0.])]

self.grid[i] = self._make_grid(nx, ny).to(x[i].device)

nx和ny表示下降stride倍后的feature map尺寸,这里是网格的尺寸,对于640的输入,正好是20.

def _make_grid(nx=20, ny=20):
    yv, xv = torch.meshgrid([torch.arange(ny), torch.arange(nx)])
    return torch.stack((xv, yv), 2).view((1, 1, ny, nx, 2)).float()

yv和xv等于两个20x20的二维tensor。然后使用torch.stack对它们沿着第2维(dim可选0,1,2)进行堆叠,得到一个三维张量。然后将其view为一张每个点包含两个坐标的网格。

torch.stack表示张量堆叠,第一个参数:张量队列,维度相同,第二参数:堆叠沿着哪个维度进行。重要是理解第二个参数,比如2维。dim=0,表示直接将两个张量毫无修改的叠在一起,得到一个三维张量;dim=1,表示从张量队列的每一个张量中取出第一行,堆叠在一起得到新张量的一个维度数据。 自己整一个2x2的试一把,啥都清楚了。

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

要知道上面的语句干什么事,首先要弄清楚这里输出的box信息是怎么表示的,这个要从dataset的加载和loss的计算(边框回归)中找答案。

先看dataset对label文件中的box的处理:
在label文件中,box是以中心点+长宽的方式存放的,也就是xywhn的方式;经过增加处理后,box变成了xyxy的绝对表示,下面的代码,就将xyxy还原到xywhn。 labels[:, 1:5] = xyxy2xywhn(labels[:, 1:5], w=img.shape[1], h=img.shape[0]) # xyxy to xywh normalized

loss计算

class ComputeLoss:
    def __call__(self, p, targets):  # predictions, targets, model
        tcls, tbox, indices, anchors = self.build_targets(p, targets)  # targets
        # Losses
        for i, pi in enumerate(p):  # layer index, layer predictions
            # 这些indices是根据标注框,根据build_targets函数生成的
            b, a, gj, gi = indices[i]  # image, anchor, gridy, gridx
            tobj = torch.zeros_like(pi[..., 0], device=device)  # target obj

            n = b.shape[0]  # number of targets
            if n:
                # 通过索引,提取预测结果中的某些预测结果来进行loss的计算
                ps = pi[b, a, gj, gi]  # prediction subset corresponding to targets

                # 后面的是分别计算边框回归损失、目标置信度损失、类别损失

                # Regression
                pxy = ps[:, :2].sigmoid() * 2. - 0.5 # 区间[-0.5, 1.5]
                pwh = (ps[:, 2:4].sigmoid() * 2) ** 2 * anchors[i]
                pbox = torch.cat((pxy, pwh), 1)  # predicted box
                iou = bbox_iou(pbox.T, tbox[i], x1y1x2y2=False, CIoU=True)  # iou(prediction, target)
                lbox += (1.0 - iou).mean()  # iou loss

                # Objectness
                tobj[b, a, gj, gi] = (1.0 - self.gr) + self.gr * iou.detach().clamp(0).type(tobj.dtype)  # iou ratio

                # Classification
                if self.nc > 1:  # cls loss (only if multiple classes)
                    t = torch.full_like(ps[:, 5:], self.cn, device=device)  # targets
                    t[range(n), tcls[i]] = self.cp
                    lcls += self.BCEcls(ps[:, 5:], t)  # BCE

build_target会在后面的附录中给出,它的作用是生成target(标注目标)的基本信息,包括:目标类别、目标box、一些索引信息、anchors。

其中,Regression loss的计算,也正好与前一小节中,Detect layer forward函数计算xy,wh的方法相对应。

比较简单,对于loss理解可以参考:https://www.freesion.com/article/48061348692/

附录

build_target注解

    # build_targets总共会生成5份网格信息,经过筛选后,包括网格自身和与网格中心点最相邻的两个格子;
    # 也就是说,一个目标框会得到3个target,它们相邻(特殊情况下只有1个).
    def build_targets(self, p, targets):
        # p: 预测结果,一个list,3个元素分别表示P3 P4 P5的预测值
        # (Pdb) p(p[0].shape)
        # torch.Size([8, 3, 80, 80, 85])
        # (Pdb) p(p[1].shape)
        # torch.Size([8, 3, 40, 40, 85])
        # (Pdb) p(p[2].shape)
        # torch.Size([8, 3, 20, 20, 85])
        # p:第0个元素shape:torch.Size([8, 3, 80, 80, 85])
        # p:8,batch-size;3,三个anchor;80,80,表示网格大小;85,表示一个格子的输出向量(class + 5 = 85)

        # targets(image,class,x,y,w,h):图像index,标签index,标注框
        # targets:每一行表示一个标注框,及所对应的图像index,所属的标签index
        # targets:除了标注框不会重复,其它两个index是可能会重复的,因为一张图可能有多个标注框
        # 比如:下面有两张图片,index=6,7,分别有4,3个标注框
        # [6.00000e+00, 5.60000e+01, 4.84862e-01, 5.06090e-01, 8.91676e-02, 1.29275e-01],
        # [6.00000e+00, 5.60000e+01, 8.31239e-01, 5.44641e-01, 3.37522e-01, 4.32708e-01],
        # [6.00000e+00, 6.00000e+01, 7.19967e-01, 7.71785e-01, 5.60066e-01, 4.56429e-01],
        # [6.00000e+00, 4.00000e+01, 7.52664e-01, 6.22213e-01, 1.32134e-01, 3.20710e-01],
        # [7.00000e+00, 0.00000e+00, 7.16337e-01, 4.10335e-02, 4.57878e-02, 5.45111e-02],
        # [7.00000e+00, 0.00000e+00, 5.97853e-01, 3.69099e-02, 3.09791e-02, 3.78452e-02],
        # [7.00000e+00, 2.00000e+01, 5.95949e-01, 7.73930e-02, 7.91287e-02, 4.53688e-02],
        # 标签index == 5.6+01,表示56

        # Build targets for compute_loss(), input targets(image,class,x,y,w,h)
        na, nt = self.na, targets.shape[0]  # number of anchors(default == 3), and number of targets
        tcls, tbox, indices, anch = [], [], [], []
        gain = torch.ones(7, device=targets.device)  # normalized to gridspace gain

        # 0 ~ (na-1)
        # view(na, 1):表示拓展为二维向量,每一行一个元素
        # repeat(1, nt):表示,第一维不变,第二维拓展为nt个元素,且所拓展元素与原值相同
        # 最后:得到一个na x nt的矩阵,表示anchor index
        ai = torch.arange(na, device=targets.device).float().view(na, 1).repeat(1, nt)  # same as .repeat_interleave(nt)

        # targets.repeat(na, 1, 1):targets从2维拓展到3维,第二、三维不做增减,第一维直接拓展na倍
        # 相当于直接将targets复制na份,组成一个list
        # torch.cat:将3维的targets与2维的ai拼接,沿着第2维(也就是第三维),但是ai没有第三维
        # ai[:, :, None]:先将矩阵看成三维(一个元素也看成一维),直接在第三维增加一维,ai的第三维变成一个向量,而且是只有一个元素的向量
        # ai[:, :, None].shape
        # torch.Size([3, 97, 1])
        # ai[None, :, :].shape
        # torch.Size([1, 3, 97])
        # torch.cat的结果是一个3维矩阵,第三维被直接追加,变成了(image,class,x,y,w,h,anchor_index),共7个数
        # 正好符合代码注解:append anchor indices, 追加 anchor indices(indexes)
        targets = torch.cat((targets.repeat(na, 1, 1), ai[:, :, None]), 2)  # append anchor indices

        # 偏移量是半个格子
        g = 0.5  # bias
        off = torch.tensor([[0, 0],
                            [1, 0], [0, 1], [-1, 0], [0, -1],  # j,k,l,m
                            # [1, 1], [1, -1], [-1, 1], [-1, -1],  # jk,jm,lk,lm
                            ], device=targets.device).float() * g  # offsets

        for i in range(self.nl):
            # self.anchors:来自Detect layer, 是一个二维数组,共3组anchors,分别对应三个输出layer
            # for k in 'na', 'nc', 'nl', 'anchors':
            #     setattr(self, k, getattr(det, k))
            # 在Detect layer中,anchors是除以了下降倍数,也就是网格中每个格子的跨度,可理解为anchors所占的格子数
            # m.anchors /= m.stride.view(-1, 1, 1)
            # 获取到输入本层的anchors,默认共3个
            anchors = self.anchors[i]

            # 将网格数(比如:80x80)填充到变量gain相应的位置,其它位置全是 1
            gain[2:6] = torch.tensor(p[i].shape)[[3, 2, 3, 2]]  # xyxy gain

            # Match targets to anchors
            # targets中是归一化的xy尺寸,将其乘以网格数,映射到网格上,比如0.5 x 80 = 40,落在第40个格子上
            t = targets * gain
            if nt:
                # Matches
                # anchors[:, None]:相当于anchors[:, None, :],先看成三维,在第二维上拓展一维
                # 可见,预测值p中的w,h,表示目标的宽和长,所占的格子数
                # r 就表示 w,h ratio,可理解为,w,h 占多少个anchor
                r = t[:, :, 4:6] / anchors[:, None]  # wh ratio

                # w,h 必须满足这样的范围:1/'anchor_t' < w,h ratio < 'anchor_t',结果是,w,h不能太小,也不能太大
                # 限定目标框不应大于anchor的4倍(默认是4,当我们的目标尺寸偏差非常大,可能要考虑修改该超参)
                j = torch.max(r, 1. / r).max(2)[0] < self.hyp['anchor_t']  # compare
                # j = wh_iou(anchors, t[:, 4:6]) > model.hyp['iou_t']  # iou(3,n)=wh_iou(anchors(3,2), gwh(n,2))
                t = t[j]  # filter

                # Offsets
                # 目标框的中心点,xyxy中的第一个xy,表示以整个大网格左上角为坐标原点的中心点坐标
                gxy = t[:, 2:4]  # grid xy
                # gxi = 用网格的尺寸 - gxy,表示以整个大网格右下角为坐标原点的中心点的坐标
                gxi = gain[[2, 3]] - gxy  # inverse
                # gxy % 1. < g:提取小数部分,将 < g的部分记下True,否则记下False
                # 取了小数后,筛选条件就相对于中心点本身所在的小网格而言了
                # 我姑且称下面这两个条件为“中心点条件”
                j, k = ((gxy % 1. < g) & (gxy > 1.)).T
                l, m = ((gxi % 1. < g) & (gxi > 1.)).T
                # 将一个全1的向量,与j,k,l,m四个向量进行stack操作,得到一个二维的张量(5,)
                j = torch.stack((torch.ones_like(j), j, k, l, m))
                # 对targets进行筛选:先将t拓展5倍,根据j的内容可知,第一维全选,后面几维根据jklm进行筛选
                # 由于j是一个二维张量,t 最终从一个三维张量变成二维张量(少了一维)
                # ipdb> p j.shape
                # torch.Size([5, 276])
                # ipdb> t.shape
                # torch.Size([276, 7])
                # ipdb> t.repeat((5, 1, 1)).shape
                # torch.Size([5, 276, 7])
                # 小结一下:t被重复五份,分别对满足给定的“中心点条件”的目标都选下来
                t = t.repeat((5, 1, 1))[j]
                # ipdb> torch.zeros_like(gxy).shape
                # torch.Size([276, 2])
                # ipdb> torch.zeros_like(gxy)[None].shape
                # torch.Size([1, 276, 2])
                # ipdb> off[:, None].shape
                # torch.Size([5, 1, 2])
                # ipdb> (torch.zeros_like(gxy)[None] + off[:, None]).shape
                # torch.Size([5, 276, 2])
                # + 操作:两个张量维度不相同,且其中有一个维度是1,则将1广播到对应更大的维度,再相加
                # 比如,完整的 + 操作可以替换为: torch.zeros_like(gxy).repeat(5,1,1) + off[:, None]
                # 本条语句最终得到:一个形如 t 的偏置,offsets.shape = t.shape
                # 注意这里的t是进行了5倍扩展,并由j筛选过,所以,偏置也应当执行同样的扩展和筛选
                # 至此,我们得到了上下左右加中心点,共5个点的偏移量(实际是3个)
                offsets = (torch.zeros_like(gxy)[None] + off[:, None])[j]

                # 对于t和offsets,可以这么理解:t表示5个一样的中心点,5份;offsets表示这5份中心点的偏移量;
                # t - offsets 立马得到5个位置不一样的中心点
                # 但是,t经过了“中心点条件”筛选,只取了偏移小于g==0.5的两个中心点,所以,5个点中的另外两个被抛弃了。

                # 值得注意的是,假设中心点正好是(0.5,0.5),那么最终只有中心点本身被留下,其它都被过滤掉了,相当于没有做拓展
            else:
                t = targets[0]
                offsets = 0

            # Define
            b, c = t[:, :2].long().T  # image, class
            gxy = t[:, 2:4]  # grid xy
            gwh = t[:, 4:6]  # grid wh
            # gxy - offsets:得到3个中心点坐标
            # .long():取整,具体到那个网格,不用小数了
            # 后面的clamp_的作用是避免超出范围,比较,网格边沿的点,它们的相邻点可能超出网格的外面
            gij = (gxy - offsets).long()
            gi, gj = gij.T  # grid xy indices

            # Append
            a = t[:, 6].long()  # anchor indices
            indices.append((b, a, gj.clamp_(0, gain[3] - 1), gi.clamp_(0, gain[2] - 1)))  # image, anchor, grid indices
            tbox.append(torch.cat((gxy - gij, gwh), 1))  # box,中心点偏移(offsets)和宽高
            anch.append(anchors[a])  # anchors
            tcls.append(c)  # class

        return tcls, tbox, indices, anch

3个相邻点的图示:

loss公式


上一篇 torch nn.Module

Comments

Content