美文网首页目标检测
结合源码分析YOLOv3网络模型(一)

结合源码分析YOLOv3网络模型(一)

作者: 海盗船长_coco | 来源:发表于2019-10-30 16:38 被阅读0次

本篇文章并没有涉及YOLOv3的训练内容,实际是根据原作者使用 PyTorch 实现基于YOLOv3的目标检测器的源代码,来进一步理解YOLOv3网络模型的输入,结构及输出。

代码地址

YOLOv3的pytorch实现:https://github.com/ayooshkathuria/YOLO_v3_tutorial_from_scratch

模型的输入

YOLOv3采用darknet-53模型,其中并没有传统的池化层,而是采用下采样来进行深层特征的提取,每下采样一次,其宽高则为原来的一半。由于下采样次数为5次,设初始图像大小为[w,h],最后下采样5次的feature map为[w/32,h/32]。所以原始图片的宽、高应为32的倍数,例如320x320,416x416,480x480等。在源码中作者取得图片大小为416x416,所以对于所有输入图片都会进行预处理。


darknet-53模型

图片的预处理(源码)

在源码中对应与util.py的prep_image和letterbox_image方法,处理图片大小的主要是letterbox_image方法。

def letterbox_image(img, inp_dim):
    """
    将图片按照纵横比进行缩放,将空白部分用(128,128,128)填充,调整图像尺寸
    将缩放后的数据拷贝到画布中心,返回完成缩放
    """
    img_w, img_h = img.shape[1], img.shape[0]
    w, h = inp_dim#inp_diw=416
    new_w = int(img_w * min(w / img_w, h / img_h))
    new_h = int(img_h * min(w / img_w, h / img_h))
    resized_image = cv2.resize(img, (new_w, new_h), interpolation=cv2.INTER_CUBIC)
    canvas = np.full((inp_dim[1], inp_dim[0], 3), 128)
    canvas[(h - new_h) // 2:(h - new_h) // 2 + new_h, (w - new_w) // 2:(w - new_w) // 2 + new_w, :] = resized_image

    return canvas


代码按照长宽比将较长的一边缩放到416,那么较短的一边肯定小于416,将缩放后的图片摆放到画布中心,空白部分用(128,128,128)填充。这样就得到了符合模型输入的416x416大小的图片。


图片预处理实现的主要过程

模型结构

1、多尺度特征融合

YOLOv3为了能够实现对不同大小的物体进行准确的预测,在不同的特征图上进行目标检测。模型对于一张图片,一共在3张feature map进行预测。32倍下采样大小为13x13的feature map更适合于预测大型目标,16倍下采样大小为26x26的feature map更适合于预测中型目标,8倍下采样大小为52x52的feature map更适合于预测小型目标。而一般认为下采样次数少,提取的是图像的浅层特征,所以网络并不是直接将下采样的结果作为最后的feature map。
例如为了得到26x26大小的feature map:
1、网络先是将图片进行4次下采样得到26x26的feature map(下图网络61层的输出),可以看成浅层特征。
2、在对4次下采样的数据进行一次下采样和一次上采样,下采样一次[h,w]变成[h/2,w/2],而上采样一次[h/2,w/2]变成[h,w],所以也能得到26x26的feature map(下图红框中的第二个粉框的输出),可以看成深层特征。
3、最后对浅层特征和深层特征进行拼接(对应于下图的红框),得到最终的26x26的feature map,再在该feature map上进行目标检测。
同理,对于52x52的feature map,也是将浅层特征与深层特征进行拼接(对应于下图的橘色框),得到最终的52x52的feature map。

YOLOv3网络结构

在代码中是通过route层来实现特征融合,摘录了Darknet的forward方法中的一段。例如拼接52x52的feature map。在yolov3.cfg文件中有[route]layers = -1, 36,其中36层的输出是图片下采样3次的浅层特征。-1为当前层的前一层输出,是橘色框中的深层特征。

            elif module_type == "route":
                layers = module["layers"]
                layers = [int(a) for a in layers]#[-1,36]

                if (layers[0]) > 0:
                    layers[0] = layers[0] - i
                if len(layers) == 1:
                    x = outputs[i + (layers[0])]
                else:
                    if (layers[1]) > 0:
                        layers[1] = layers[1] - i
                    map1 = outputs[i + layers[0]]#outputs[i-1]
                    map2 = outputs[i + layers[1]]#outputs[36]
                    # 第二个参数设为 1,这是因为我们希望将特征图沿anchor数量的维度级联起来。
                    #将浅层特征和深层特征进行拼接
                    x = torch.cat((map1, map2), 1)

可能有小伙伴还看到[route]layers = -4的情况,该情况为每次feature map检测以后,接下来的网络卷积从当前feature map的前4张开始。也就是对应上图中小狗图片上方有4个框,从下往上数分别为-1,-2,-3,-4。

2、跳跃连接(short connect)

网络模型借鉴了残差网络的跳跃连接,可以保证网络在深层的情况下也能收敛。使用1x1卷积可以大量减少每次卷积的channel,不仅可以减少模型的参数量,而且可以减少计算量,从而加速检测速度。


跳跃连接

源码实现:在yolov3.cfg文件中有[shortcut] from=-3 activation=linear。摘录了Darknet的forward方法中的一段。其中i表示当前层数。由于outputs[i-1]和outputs[i-3]维度相同,所以可以直接相加。

            elif module_type == "shortcut":
                from_ = int(module["from"])#-3
                #i表示当前层数,跳跃连接将前一层和前三层的输出直接相加
                x = outputs[i - 1] + outputs[i + from_]

3、目标检测

anchor box大小的确定

上述内容提到网络模型一共在3张feature map上进行目标检测,但是每张feature map用的anchor box的大小并不一样。在先前的Faster-Rcnn和SSD模型中,anchor box的大小是人为设定的,而YOLOv3中,anchor box大小是采用k-means聚类得到的,在原始图片大小为416x416的情况下,32倍下采样大小为13x13的feature map采用的anchor box大小为(116,90),(156,198),(373,326)。16倍下采样大小为26x26的feature map采用的anchor box大小为(30,61),(62,45),(59,119)。8倍下采样大小为52x52的feature map采用的anchor box大小为(10,13),(16,30),(33,23)。anchor的大小是在yolov3.cfg的[yolo]中设置。
可以看到对于每张feature map都有3个不同比例大小的anchor box。那么一张图片总共能够产生(13x13+26x26+52x52)x3=10647个anchor box。下图中的红框表示feature map上的一个单元格,蓝框表示一个单元格预测的3种不同比例大小的anchor box,黄框为真实框(groundtruth)。可以看到不同feature map适用于不同大小的目标检测。

不同尺寸的feature map
在darknet.py的create_modules方法中用于读取yolov3.cfg中anchor box大小,而yolo检测层的具体实现, 即在特征图上使用锚点预测目标区域和类别, 功能函数在predict_transform中实现,该方法在下文将会讲到。
[yolo]
mask = 6,7,8  #通过mask来选择不同大小的anchors
anchors = 10,13,  16,30,  33,23,  30,61,  62,45,  59,119,  116,90,  156,198,  373,326
classes=80
num=9
jitter=.3
ignore_thresh = .5
truth_thresh = 1
random=1
# Yolo is the detection layer,对不同尺度的feature map进行检测
        elif x["type"] == "yolo":
            mask = x["mask"].split(",")
            mask = [int(x) for x in mask]

            anchors = x["anchors"].split(",")
            anchors = [int(a) for a in anchors]
            anchors = [(anchors[i], anchors[i + 1]) for i in range(0, len(anchors), 2)]
            anchors = [anchors[i] for i in mask]
            # print('index',anchors)
            detection = DetectionLayer(anchors)  # 锚点,检测,位置回归,分类,这个类见predict_transform中
            module.add_module("Detection_{}".format(index), detection)

在feature map上进行目标检测

选取在32倍下采样大小为13x13的feature map上进行检测为例,其余两个大小的feature map其过程与该feature map是相同的。
将经过32倍下采样的feature map输出定义为prediction,其维度为[batch_size,C,H,W],具体数值为[1,255,13,13]。
由于最终预测输出的维度为85,具体内容为[x,y,h,w,s,cls_1,cls_2,...cls_80]。分别表示相对于原图的x,y,宽,高,预测框中含有目标的置信度,及80个类别分数。因为coco数据集有80种类别。所以输出的维度为5+cls_num=5+80=85。
因此prediction的维度变化为
1、[1,255,13,13]-->[1,85x3,13x13]=[1,255,169]。因为32倍下采样feature map上的一共有13x13=169个单元格
2、[1,255,169]-->[1,169,255],交换1,2维度
3、[1,169,255]-->[1,169x3,85]=[1,507,85],因为每个单元格预测3个anchor box,所以feature map上一共有13x13x3=507个anchor box,每个anchor box输出的维度为85。这85维分别是[tx,ty,tw,th,score,cls_1,cls_2,...,cls_80]。


转化公式

4、根据上方公式,将85维中的前4维[tx,ty,tw,th]变成[bx,by,bw,bh],其中Cx,Cy为当前单元格左上方的坐标。同时将第5维的score(含有目标的置信度),经过sigmoid函数激活,得到其含有目标的概率。cls_1,cls_2,..,cls_80也要经过sigmoid函数激活得到相应的概率。

最终在13x13的feature map上得到的预测输出为[1,507,85],同理在26x26的feature map上的预测输出为[1,2028,85],在52x52的feature map上的预测输出为[1,8112,85]。最后将这些预测进行拼接,对于一张图其最终的预测输出为[1,10647,85]。预测实现的具体细节再util.py的predict_transform方法中实现。

def predict_transform(prediction, inp_dim, anchors, num_classes, CUDA=True):
    """
        prediction[batch_size,C,H,W]-->[1,255,13,13]
        inp_dim=416
        anchors=[(116,90),(156,198),(373,326)]
        num_classses=80
    """
    batch_size = prediction.size(0)
    # stride表示整个网络的步长,等于原始图像大小除以feature map大小
    stride = inp_dim // prediction.size(2)#416/13=32
    # feature map每条边格子的个数
    grid_size = inp_dim // stride#416/32=13
    # 每个方框属性个数,x,y,h,w,置信度+类别个数
    bbox_attrs = 5 + num_classes
    num_anchors = len(anchors)
    #[1,255,13,13]-->[1,85x3,13x13]=[1,255,169]
    prediction = prediction.view(batch_size, bbox_attrs * num_anchors, grid_size * grid_size)
    #[1,255,169]-->[1,169,255]
    prediction = prediction.transpose(1, 2).contiguous()
    # [batch_size,单元格数量*每个单元格锚框个数,5+类别个数]
    #[1,169,255]-->[1,169x3,85]=[1,507,85]
    prediction = prediction.view(batch_size, grid_size * grid_size * num_anchors, bbox_attrs)
    anchors = [(a[0] / stride, a[1] / stride) for a in anchors]  # anchors在feature map上的大小

    # Sigmoid the  tx, ty. and object confidenccet。tx与ty为预测的坐标偏移值
    prediction[:, :, 0] = torch.sigmoid(prediction[:, :, 0])
    prediction[:, :, 1] = torch.sigmoid(prediction[:, :, 1])
    prediction[:, :, 4] = torch.sigmoid(prediction[:, :, 4])

    # 生成了每个格子的左上角坐标,生成的坐标为grid x grid的二维数组
    # a,b分别对应这个二维矩阵的x,y坐标的数组,a,b的维度与grid维度一样。
    # 每个grid cell的尺寸均为1,故grid范围是[0,12](假如当前的特征图13*13)
    grid = np.arange(grid_size)
    a, b = np.meshgrid(grid, grid)
    # x_offset即cx,y_offset即cy,表示当前cell左上角坐标
    x_offset = torch.FloatTensor(a).view(-1, 1)
    y_offset = torch.FloatTensor(b).view(-1, 1)

    if CUDA:
        x_offset = x_offset.cuda()
        y_offset = y_offset.cuda()
    # 这里的x_y_offset对应的是最终的feature map中每个格子的左上角坐标,
    # 比如有13个格子,刚x_y_offset的坐标就对应为(0,0),(0,1)…(12,12)
    # .view(-1, 2)将tensor变成两列,unsqueeze(0)在0维上添加了一维。
    x_y_offset = torch.cat((x_offset, y_offset), 1).repeat(1, num_anchors).view(-1, 2).unsqueeze(0)

    prediction[:, :, :2] += x_y_offset  # bx=sigmoid(tx)+cx,by=sigmoid(ty)+cy

    # log space transform height and the width
    anchors = torch.FloatTensor(anchors)

    if CUDA:
        anchors = anchors.cuda()
    # 这里的anchors本来是一个长度为3的list(三个anchors),然后在0维上(行)进行了grid_size*grid_size个复制,在1维(列)上一次复制(没有变化),
    # 即对每个格子都得到三个anchor。Unsqueeze(0)的作用是在数组上添加一维,这里是在第0维上添加的。添加grid_size是为了之后的公式bw=pw×e^tw的tw。
    anchors = anchors.repeat(grid_size * grid_size, 1).unsqueeze(0)
    # 对网络预测得到的矩形框的宽高的偏差值进行指数计算,然后乘以anchors里面对应的宽高(这里的anchors里面的宽高是对应最终的feature map尺寸grid_size),
    # 得到目标的方框的宽高,这里得到的宽高是相对于在feature map的尺寸
    # bw=pw*e^tw,bh=ph*e^th。anchors提供(ph,pw)。prediction为th,tw
    prediction[:, :, 2:4] = torch.exp(prediction[:, :, 2:4]) * anchors
    # 这里得到每个anchor中每个类别的得分。将网络预测的每个得分用sigmoid()函数计算得到
    prediction[:, :, 5: 5 + num_classes] = torch.sigmoid((prediction[:, :, 5: 5 + num_classes]))
    # 将相对于最终feature map的方框坐标和尺寸映射回原始输入图片(416x416),即将方框的坐标乘以网络的stride即可
    prediction[:, :, :4] *= stride

    return prediction#[1,507,85]
目标检测

对预测的bounding box进行挑选

prediction对于一张图片预测了这么多bounding box,需要从中挑选最合适的几个。
1、预测框的置信度必须大于 objectness 分数阈值,将低于阈值的方框去掉。
2、通过非极大值抑制(NMS)去除重复度较大的框。
3、在80个类别中提取得分最高的那个类的得分max_conf,同时返回这个类对应的序号max_conf_score,
经过一系列操作,得到最终的输出为(ind,x1,y1,x2,y2,s,s_cls,index_cls),ind 是这个预测框所属图片在这个一组图片中的序号,x1,y1为预测框的左上角坐标,x2,y2为预测框的右下角角坐标,s是这个方框含有目标的置信度,s_cls是这个方框中最有可能的类别的概率得分,index_cls是s_cls对应的这个类别所对应的序号.
上述过程通过util.py的write_results方法实现。

def write_results(prediction, confidence, num_classes, nms_conf=0.4):
    '''
    prediction: 输入的预测shape维度(1,10647, 85)
    confidence:0.5
    num_classes:80
    '''
    #得到预测种含有目标置信度大于confidence的数据
    conf_mask = (prediction[:, :, 4] > confidence).float().unsqueeze(2)
    # conf_mask中置信度大于confidence的方框所对应的含有目标的得分为1
    # 根据numpy的广播原理,它会扩展成与prediction维度一样的tensor,所以含有目标的得分小于confidence的方框所有的属性都会变为0,故如果没有检测任何有效目标,则返回值为0
    prediction = prediction * conf_mask
    '''
    保留预测结果中置信度大于阈值的bbox
    下面开始为nms准备
    '''
    # prediction的前五个数据分别表示 (Cx, Cy, w, h, score),这里创建一个新的数组,大小与predicton的大小相同
    box_corner = prediction.new(prediction.shape)

    box_corner[:, :, 0] = (prediction[:, :, 0] - prediction[:, :, 2] / 2)  # x1 = Cx - w/2
    box_corner[:, :, 1] = (prediction[:, :, 1] - prediction[:, :, 3] / 2)  # y1 = Cy - h/2
    box_corner[:, :, 2] = (prediction[:, :, 0] + prediction[:, :, 2] / 2)  # x2 = Cx + w/2
    box_corner[:, :, 3] = (prediction[:, :, 1] + prediction[:, :, 3] / 2)  # y2 = Cy + h/2
    prediction[:, :, :4] = box_corner[:, :, :4]

    batch_size = prediction.size(0)

    write = False

    # 对每一张图片得分的预测值进行NMS操作,因为每张图片的目标数量不一样,所以有效得分的方框的数量不一样,
    # 没法将几张图片同时处理,因此一次只能完成一张图的置信度阈值的设置和NMS,不能将所涉及的操作向量化.
    # 所以必须在预测的第一个维度上(batch数量)上遍历每张图片,将得分低于一定分数的去掉,对剩下的方框进行进行NMS
    for ind in range(batch_size):
        # 选择此batch中第ind个图像的预测结果,image_pred对应一张图片中所有方框的坐标(x1,y1,x2,y2)以及得分,
        image_pred = prediction[ind]  #一个二维tensor 维度为10647x85
        # 只关心有最大值的类别分数,prediction[:, 5:]表示每一分类的分数,
        # 返回每一行中所有类别的得分最高的那个类的得分max_conf,同时返回这个类对应的序号max_conf_score
        max_conf, max_conf_score = torch.max(image_pred[:, 5:5 + num_classes], 1)
        # 维度扩展max_conf: shape=(10647,) => (10647,1)添加一个列的维度,max_conf变成二维tensor,尺寸为10647x1
        max_conf = max_conf.float().unsqueeze(1)
        max_conf_score = max_conf_score.float().unsqueeze(1)
        # 我们移除了每一行的这 80 个类别分数,只保留bbox4个坐标以及objectnness分数,转而增加了有最大值的类别分数及索引。
        seq = (image_pred[:, :5], max_conf, max_conf_score)
        # 将每个方框的(x1,y1,x2,y2,s)与得分最高的这个类的分数s_cls(max_conf)和对应类的序号index_cls(max_conf_score)在列维度上连接起来,
        # 即将10647x5,10647x1,10647x1三个tensor 在列维度进行concatenate操作,得到一个10647x7的tensor,(x1,y1,x2,y2,s,s_cls,index_cls)。
        image_pred = torch.cat(seq, 1)  # shape=(10647, 5+1+1=7)
        # image_pred[:,4]是长度为10647的一维tensor,维度为4的列是置信度分数。假设有15个框含有目标的得分非0,返回15x1的tensor
        non_zero_ind = (torch.nonzero(image_pred[:, 4]))  # torch.nonzero返回的是索引
        try:
            image_pred_ = image_pred[non_zero_ind.squeeze(), :].view(-1, 7)
        except:
            continue

        if image_pred_.shape[0] == 0:  # 当没有检测到时目标时,我们使用 continue 来跳过对本图像的循环,即进行下一次循环。
            continue

        # 获取当前图像检测结果中出现的所有类别
        img_classes = unique(image_pred_[:, -1])  # -1 index holds the class index

        for cls in img_classes:

            # 获取含有该类别的预测数据
            cls_mask = image_pred_ * (image_pred_[:, -1] == cls).float().unsqueeze(1)
            class_mask_ind = torch.nonzero(cls_mask[:, -2]).squeeze()
            image_pred_class = image_pred_[class_mask_ind].view(-1, 7)
            # 开始NMS
            # 按置信度对含有该类别的预测框进行从大到小排序,并获取其下标
            # confidence is at the top
            conf_sort_index = torch.sort(image_pred_class[:, 4], descending=True)[1]
            image_pred_class = image_pred_class[conf_sort_index]
            idx = image_pred_class.size(0)  # Number of detections

            for i in range(idx):
                # Get the IOUs of all boxes that come after the one we are looking at
                # in the loop
                try:
                    ious = bbox_iou(image_pred_class[i].unsqueeze(0), image_pred_class[i + 1:])
                except ValueError:
                    '''
                    在for i in range(idx):这个循环中,因为有一些框(在image_pred_class对应一行)会被去掉,image_pred_class行数会减少,
                    这样在后面的循环中,idx序号会超出image_pred_class的行数的范围,出现ValueError错误。
                    所以当抛出这个错误时,则跳出这个循环,因为此时已经没有更多可以去掉的方框了。
                 '''
                    break

                except IndexError:
                    break

                # 选取IOU<阈值的框
                iou_mask = (ious < nms_conf).float().unsqueeze(1)
                image_pred_class[i + 1:] *= iou_mask

                # 保留那些非零的行数据
                non_zero_ind = torch.nonzero(image_pred_class[:, 4]).squeeze()
                image_pred_class = image_pred_class[non_zero_ind].view(-1, 7)
            # new()创建了一个和image_pred_class相同的tensor,tensor行数等于cls这个类别所有的方框经过NMS剩下的方框的个数,
            # 即image_pred_class的行数,列数为1.
            # 再将生成的这个tensor所有元素赋值为这些方框所属图片对应于batch中的序号ind(一张图片有多个类别),用fill_(ind)实现
            batch_ind = image_pred_class.new(image_pred_class.size(0), 1).fill_(
                ind)  # Repeat the batch_id for as many detections of the class cls in the image
            seq = batch_ind, image_pred_class

            if not write:
                # 将batch_ind, image_pred_class在列维度上进行连接,image_pred_class每一行存储的是(x1,y1,x2,y2,s,s_cls,index_cls),
                # 现在在第一列增加了一个代表这个行对应方框所属图片在一个batch中的序号ind
                output = torch.cat(seq, 1)
                write = True
            else:
                out = torch.cat(seq, 1)
                output = torch.cat((output, out))

    try:
        return output
    except:
        return 0

在原图中绘制预测结果

在detect.py的write方法中实现。

# x为映射到原始图片中一个方框的属性(ind,x1,y1,x2,y2,s,s_cls,index_cls),
# results列表保存了所有测试图片,一个元素对应一张图片
def write(x, results):
    c1 = tuple(x[1:3].int())  # c1为方框左上角坐标x1,y1
    c2 = tuple(x[3:5].int())  # c2为方框右下角坐标x2,y2
    img = results[int(x[0])]  # 在results中找到x方框所对应的图片,x[0]为方框所在图片在所有测试图片中的序号
    cls = int(x[-1])
    color = random.choice(colors)
    label = "{0}".format(classes[cls])
    cv2.rectangle(img, c1, c2, color, 1)  # 在图片上画出(x1,y1,x2,y2)矩形,即我们检测到的目标方框
    t_size = cv2.getTextSize(label, cv2.FONT_HERSHEY_PLAIN, 1, 1)[0]  # 得到一个包含目标名字字符的方框的宽高
    c2 = c1[0] + t_size[0] + 3, c1[1] + t_size[1] + 4
    cv2.rectangle(img, c1, c2, color, -1)  # 在图片上画一个实心方框,我们将在方框内放置目标类别名字
    cv2.putText(img, label, (c1[0], c1[1] + t_size[1] + 4), cv2.FONT_HERSHEY_PLAIN, 1, [225, 255, 255], 1);
    return img

程序运行中的问题

我测试代码时发现运行完,检测的结果并没有保存到我的图片目录下,后来发现是因为我在windows下运行的原因,需要修改detect.py的该处代码,因为windows和Linux的分隔符不同,所以图片未能保存。


修改之处

参考文章:

https://blog.csdn.net/qq_34199326/article/details/84072505
https://zhuanlan.zhihu.com/p/49556105

相关文章

网友评论

    本文标题:结合源码分析YOLOv3网络模型(一)

    本文链接:https://www.haomeiwen.com/subject/vquovctx.html