美文网首页目标检测
结合源码分析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