手撕目标检测之第一篇:目标检测的总体流程

总体流程

代码参考1:https://github.com/bubbliiiing/yolo3-pytorch(超超超大神) 博客: https://blog.csdn.net/weixin_44791964
代码参考2:https://youtu.be/YDkjWEN8jNA
自己的代码:https://github.com/chongchongchongya/python/tree/main/pytorch/YOLOv3-pytorch-master
在学习了很多关于深度学习的知识和一些编程能力之后,阅读的代码的能力是存在的,但是自己编写代码可能就面临着 ctrl_c、ctrl_v 难以解决的问题,想要编写一些自己想法的代码,却发现无从先手,只能简单的复用别人的代码,就会造成个人能力一直由于无法打破代码和想法之间的隔阂而无法得到大幅度的提升。直接上手当下最新的网络架构,可能有些吃力,本系列将以简单的 yolov3 为始,逐步过渡到最新的 yolov6、swim Transformer v2、DETR等,一起踏入到真正的深度学习;
了解深度学习知识和拥有深度学习代码能力是不同的概念;

前言

了解 VOC 数据集

VOC官网
参考大神链接1:https://zhuanlan.zhihu.com/p/362044555
参考大神链接2:https://blog.csdn.net/cengjing12/article/details/107820976

目标检测中所说的 VOC 数据集一般指的是在 PASCAL VOC 挑战赛中Object Detection(检测任务)所使用的图片数据集。当前比较适用的为 VOC2007 和 VOC2012,且两者是互斥的。

论文中针对 VOC2007和VOC2012 的具体用法有以下几种:

  • 1、只用VOC2007的trainval 训练,使用VOC2007的test测试。
  • 2、只用VOC2012的trainval 训练,使用VOC2012的test测试,这种用法很少使用,因为大家都会结合VOC2007使用。
  • 3、使用 VOC2007 的 train+val 和 VOC2012的 train+val 训练,然后使用 VOC2007的test测试,这个用法是论文中经常看到的 07+12 ,研究者可以自己测试在VOC2007上的结果,因为VOC2007的test是公开的。
  • 4、使用 VOC2007 的 train+val+test 和 VOC2012的 train+val训练,然后使用 VOC2012的test测试,这个用法是论文中经常看到的 07++12 ,这种方法需提交到VOC官方服务器上评估结果,因为VOC2012 test没有公布。
  • 5、先在 MS COCO 的 trainval 上预训练,再使用 VOC2007 的 train+val、 VOC2012的 train+val 微调训练,然后使用 VOC2007的test测试,这个用法是论文中经常看到的 07+12+COCO 。
  • 6、先在 MS COCO 的 trainval 上预训练,再使用 VOC2007 的 train+val+test 、 VOC2012的 train+val 微调训练,然后使用 VOC2012的test测试 ,这个用法是论文中经常看到的 07++12+COCO,这种方法需提交到VOC官方服务器上评估结果,因为VOC2012 test没有公布。

0、VOC数据集下载

这个一般没有下载限制,很快便可以下载完成。

1、VOC 数据集的20个类别及其层级结构:

VOC数据集在大类上包含四个大类,分别是 vehicle、household、animal、person,在四个大类中又细分为很多的小类如下:总共 20 个小类(加背景 21 类)

手撕目标检测之第一篇:目标检测的总体流程
一般我们在使用的时候使用的为黑体的 20 个小的类别作为最终的预测结果,

; 2、下载文件的架构

.
└── VOCdevkit     #根目录
    └── VOC2007   #不同年份的数据集
        ├── Annotations          # 存放xml文件,与JPEGImages中的图片一一对应,解释图片的内容等等
        ├── ImageSets            # 该目录下存放的都是txt文件,txt文件中每一行包含一个图片的名称,末尾会加上±1表示正负样本
        │   ├── Layout           # 包含Layout标注信息的图像文件名列表
        │   ├── Main             # 存放的是分类和检测的数据集分割文件
        │   │ ├── train.txt    # 写着用于训练的图片名称
        │   │ ├── val.txt      # 写着用于验证的图片名称
        │   │ ├── trainval.txt # train与val的合集。
        │   │ ├── test.txt     # 写着用于测试的图片名称
        │   │ ├── *.txt       # 一些其他各个类别的txt文件说明
        │   └── Segmentation
        ├── JPEGImages           #存放源图片
        ├── SegmentationClass    #存放的是图片,语义(class)分割相关
        └── SegmentationObject   #存放的是图片,实例(object)分割相关

做目标检测的话,一般我们只需要使用其中的 JPEGImages、Annotations、以及 train.txt、val.txt、以及 test.txt。后续如果进行语义分割或者实例分割的话,将会用到其他的文件夹。

3、标签文件Annotations

其中xml主要介绍了对应图片的基本信息,如来自那个文件夹、文件名、来源、图像尺寸以及图像中包含哪些目标以及目标的信息等等,xml内容如下:

<annotation>
    <folder>VOC2007folder> # 所属的版本
    <filename>000001.jpgfilename>  # 文件名
    <source>
        <database>The VOC2007 Databasedatabase>
        <annotation>PASCAL VOC2007annotation>
        <image>flickrimage>
        <flickrid>341012865flickrid>
    source>
    <owner>
        <flickrid>Fried Camelsflickrid>
        <name>Jinky the Fruit Batname>
    owner>
    <size>  # 图像尺寸, 用于对 bbox 左上和右下坐标点做归一化操作
        <width>353width>
        <height>500height>
        <depth>3depth>
    size>
    <segmented>0segmented>  # 是否用于分割,1有分割标注,0表示没有分割标注。
    <object> # 图片包含的物体检测框之一
        <name>dogname>  # 物体类别
        <pose>Leftpose>  # 拍摄角度:front, rear, left, right, unspecified
        <truncated>1truncated>  # 目标是否被截断(比如在图片之外),或者被遮挡(超过15%)
        <difficult>0difficult>  # 检测难易程度,这个主要是根据目标的大小,光照变化,图片质量来判断,0表示不是,1表示是
        <bndbox> # 真实的检测框的坐标,为 两个对角的坐标
            <xmin>48xmin>
            <ymin>240ymin>
            <xmax>195xmax>
            <ymax>371ymax>
        bndbox>
    object>
    <object> # 图片包含的第二个检测框
        <name>personname>
        <pose>Leftpose>
        <truncated>1truncated> # 是否被标记为截断,0表示没有,1表示是
        <difficult>0difficult>
        <bndbox>
            <xmin>8xmin>
            <ymin>12ymin>
            <xmax>352xmax>
            <ymax>498ymax>
        bndbox>
    object>
annotation>

Annotations 中的 xml 文件不能直接用于神经网络的训练,我们需要将其转换为固定的文本格式,以便于更好用于计算损失函数。
针对目标检测任务,需要将转换为下面的 txt 文件格式:

#图片名称 置信度 检测狂的真实坐标
000004 0.702732 89 112 516 466
000006 0.870849 373 168 488 229

4、评估指标

PASCAL的评估标准是 mAP(mean average precision)。每个类别都有一个 AP(根据选取top-1到top-N的不同,召回率和准确率会形成曲线,计算曲线的包含的面积,则为AP),20个类别的 AP 取平均值就是 mAP。详细解析参考
在后面评估网络的时候,我们也会手撕这一段代码;

5、建立自己的VOC类型数据集

5.1、建立文件夹

一般情况下,我们需要在别人的预训练模型下,训练自己的模型,这就需要我们可以制作自己的数据集,我们可以按照 VOC 数据集的格式存放自己的数据集。具体为创建仿造 VOC,先建立一个根文件夹 VOCself。并在其中建立三个子文件夹,分别为 Annotations、ImageSets(里面还有 Main 文件夹)、JPEGImages。如下图所示:

手撕目标检测之第一篇:目标检测的总体流程
文件夹说明:
  • 1、JPEGImages中存放要训练的图片。
  • 2、Annotations中存放着XML信息,XML文件名与训练图片的文件名一一对应。
  • 3、ImageSets中存放文件夹Main,Main中存放四个txt文件,train存放着用于训练图片名字集合,test存放着用于测试的名字集合。
    手撕目标检测之第一篇:目标检测的总体流程

; 5.2、将训练图片放到JPEGImages

将选取的图片放置于放于目录:JPEGImages下,然后将图片重命名为VOC2007的”000005.jpg”形式。重命名的python代码为:

import os
def rename_imgs(path):
    count=0
    for file in os.listdir(path)
        Olddir=os.path.join(path,file)
        if os.path.isdir(Olddir):
            continue
        filename=os.path.splitext(file)[0]
        filetype=os.path.splitext(file)[1]
        Newdir=os.path.join(path,str(count).zfill(6)+filetype)
        os.rename(Olddir,Newdir)
        count += 1
if __name__ == "__main__":
    rename_imgs(自己的 JPEGImages 路径)

5.3、标注图片,标注文件保存到Annotations

目前的情况下,我们会使用 labelImg 对图片进行标签的标注,可以自动生成对应的xml格式的信息文件,官方github连接,简单快速的使用参考
注意:标注自己的图片的时候,类别名称请用小写字母,比如汽车使用car,不要用Car 。

5.4、生成ImageSets\Main里的四个txt文件

  • 1、test.txt是测试集
  • 2、train.txt是训练集
  • 3、val.txt是验证集
  • 4、trainval.txt是训练和验证集

VOC2007中,trainval大概是整个数据集的50%,test也大概是整个数据集的50%;train大概是trainval的50%,val大概是trainval的50%。txt文件中的内容为样本图片的名字(不带后缀),格式如下:

手撕目标检测之第一篇:目标检测的总体流程
上面所占百分比可根据自己的数据集修改,如果数据集比较少,test和val可少一些。在自己的文件夹下生成自己数据集的代码如下:
import os
import random

def set_txt(path, trainval_percent = 0.5, train_percent = 0.5):

    xmlfilepath = path + "\\Annotations"
    txtsavepath = path  + "\\ImageSets\\Main"
    total_xml = os.listdir(xmlfilepath)
    num = len(total_xml)

    tv = int(num * trainval_percent)
    tr = int(tv * train_percent)
    trainval = random.sample(range(num), tv)
    train = random.sample(trainval, tr)

    ftrainval = open( txtsavepath + '\\trainval.txt', 'w' )
    ftest = open( txtsavepath +'\\test.txt', 'w' )
    ftrain = open( txtsavepath + '\\train.txt', 'w' )
    fval = open( txtsavepath + '\\val.txt', 'w' )
    for i in range(num):
        name = total_xml[i][:-4] + '\n'
        if i in trainval:
            ftrainval.write(name)
            if i in train:
                ftrain.write(name)
            else:
                fval.write(name)
        else:
            ftest.write(name)
    ftrainval.close()
    ftrain.close()
    fval.close()
    ftest.close()

if __name__ == "__main__":
    set_txt(自己的 VOCself 路径)

处理 VOC 数据集

为了更方便的使用 VOC 数据集,我们需要进行下面两步操作:

  1. 合并 VOC2007 和 VOC2014 :因为单一的 2007 或者 2014 数据太小,不能够满足我们平常训练或者对网络的微调,将两者和在一起,形成 VOC 07+12,也可以在别人使用COCO数据集训练完成的情况下,再使用 VOC 07+12,形成 07+12+COCO。这样对自己的网络的性能,也会有一个较为准确的判断。
  2. 重新划分 训练集 、验证集 以及 测试集
  3. 根据换分的结果,转换 Annotation 中的 xml ,变成 txt 文件格式,方便网络对图片进行读取和训练等操作。

操作1、合并 VOC2007 和 VOC2012

操作准备:
1、下载 VOC2007 训练集和验证集,以及测试集。下载 VOC2012 训练集和验证集;如下:

手撕目标检测之第一篇:目标检测的总体流程

新建VOC07_12 文件夹,其中包含一下文件夹(ImageSet 中还包含 一个 Main 文件夹):

手撕目标检测之第一篇:目标检测的总体流程

注意:VOC2012数据集是VOC2007数据集的升级版,一共有11530张图片。对于检测任务,VOC2012的trainval/test包含08-11年的所有对应图片。 trainval有11540张图片共27450个物体。 对于分割任务, VOC2012的trainval包含07-11年的所有对应图片, test只包含08-11。trainval有 2913张图片共6929个物体。

所以对于目标检测来说,不能简单将两个文件夹的图片进行合并,因为会存在重复的现象,需要我们手动将重复的图片进行过滤, 代码如下:

import os
import shutil

def combine(path_07, path_12, path_07_12):
"""
    path_07: 2007 的路径
    path_12: 2012 的路径
"""
    annotations_path_07 = path_07 + "\\Annotations"
    jpegimages_path_07  = path_07 + "\\JPEGImages"
    annotations_path_12 = path_12 + "\\Annotations"
    jpegimages_path_12  = path_12 + "\\JPEGImages"

    jpegimages_07 = os.listdir(jpegimages_path_07)
    jpegimages_12 = os.listdir(jpegimages_path_12)
    num_07 = 0
    for img in jpegimages_07:
        num_07 += 1

        shutil.copy(jpegimages_path_07+'/'+img, path_07_12 +'\\JPEGImages\\'+img)

        shutil.copy(annotations_path_07+'/'+img.split(".")[0] + ".xml", path_07_12 +'\\Annotations\\'+img.split(".")[0] + ".xml")

    num_12 = 0
    for img in jpegimages_12:

        if img.split("_")[0] != "2007":
            num_12 += 1

            shutil.copy(jpegimages_path_12+'/'+img, path_07_12 +'\\JPEGImages\\'+img)

            shutil.copy(annotations_path_12+'/'+img.split(".")[0] + ".xml", path_07_12 +'\\Annotations\\'+img.split(".")[0] + ".xml")
        else:
            print("same! ")

    if ( num_07 + num_12 ) == len(os.listdir( path_07_12 +'\\Annotations' )):
        print("正确 !")

if __name__ == "__main__":
    combine("自己下载的VOC2007路径", "自己下载的VOC2012路径","自己建立的VOC07+12路径")

操作2、重新划分 训练集 、验证集 以及 测试集

在上面建立自己的VOC类型数据集的时候,也提到了如何划分数据集,可以直接使用上面的程序,将源文件修改为自己刚刚合并的文件夹VOC07+12,具体的分配比率可以按照自己的想法来设置,如果是已经在coco数据集上做过预训练的权重的话,可以将训练集 、验证集 以及 测试集的比例调整为 0.5、0.3、0.2。当然按照0.25、0.25、0.5的比例也是可以的,下面的程序根据0.5、0.3、0.2划分的,程序如下:

import os
import random

def divide_voc_dataset(path, trainval_percent = 0.8, train_percent = 0.6):
"""
    训练集:0.48    -> 大约 12639
    测试集:0.32    -> 大约 8426
    验证集:0.2     -> 大约 5267
"""

    xmlfilepath  = os.path.join(path, "\\Annotations")
    txtsavepath  = os.path.join(path, "\\ImageSets\\Main")
    traintxtpath = os.path.join(path, "\\ImageSets\\SelfMain")
    temp_xml     = os.listdir(xmlfilepath)
    total_xml    = []

    for xml in temp_xml:
        if xml.endswith(".xml"):
            total_xml.append(xml)

    num = len(total_xml)

    tv       = int(num * trainval_percent)
    tr       = int(tv * train_percent)
    trainval = random.sample(range(num), tv)
    train    = random.sample(trainval, tr)

    print("train and val size",tv)
    print("train size",tr)

    ftrainval       = open( os.path.join(txtsavepath, '\\trainval.txt'), 'w' )
    ftest           = open( os.path.join(txtsavepath, '\\test.txt'), 'w' )
    ftrain          = open( os.path.join(txtsavepath, '\\train.txt'), 'w' )
    fval            = open( os.path.join(txtsavepath, '\\val.txt'), 'w' )

    for i in range(num):
        name = total_xml[i][:-4] + '\n'
        if i in trainval:
            ftrainval.write(name)
            if i in train:
                ftrain.write(name)
            else:
                fval.write(name)
        else:
            ftest.write(name)

    ftrainval.close()
    ftrain.close()
    fval.close()
    ftest.close()

if __name__ == "__main__":
    random.seed(0)

    datapath= ""

    divide_voc_dataset(datapath)

转换完成之后,将生成下面四个文件:
后面的文件的大小其实也对对应着自己划分数据集的比例。

手撕目标检测之第一篇:目标检测的总体流程

操作3、转换 Annotation 中的 xml

从上面可以看到 Annotation 中的 xml 文件需要进一步处理,如果不进行处理的话,直接使用的话,每次都要从中找到自己的需要的数据,比较麻烦,我们会将其处理成为下面的形似:

手撕目标检测之第一篇:目标检测的总体流程
依次分别为:图片路径 检测框的左上角、右下角的坐标 所属的类别 检测框的左上角、右下角的坐标 所属的类别 …

因为一张图片中可能存在多个检测框,所以后面五个数据可能会循环的出现。

注意到:上面的类别使用数字来代替的,我们需要建立一个 txt 文件,来使得数字和类别进行对应,这样,使用数字来代替物体类别的话,方便,且在接下的神经网路计算损失函数的时候,也更加容易。接下来我们将新建这个文件;

当然若是你想要在划分数据集的时候,也进行xml文件的处理,也是可以的,这边选择新生成三个文件夹,专门用于生成自己电脑上的专属路径,这样我们上面划分的数据,可以反复使用。

在ImageSets新建一个自己的另外的文件,可以按照自己的习惯命名,这里命名为SelfMain,用来后续存放self_train.txt、self_val.txt、self_test.txt 以及 voc_classes.txt(用来存放数字和类别名的对应)。

voc_classes.txt中的内容如下:

aeroplane
bicycle
bird
boat
bottle
bus
car
cat
chair
cow
diningtable
dog
horse
motorbike
person
pottedplant
sheep
sofa
train
tvmonitor

对应的行号就是与类别对应的数字。

处理 xml 文件的程序如下:

import os
import random
try:
    import xml.etree.cElementTree as ET
except ImportError:
    import xml.etree.ElementTree as ET
"""
ET 学习了解链接
https://zhuanlan.zhihu.com/p/152207687
"""

def get_classes(classes_path):
    with open(classes_path, encoding='utf-8') as f:
        class_names = f.readlines()
    class_names = [c.strip() for c in class_names]
    return class_names, len(class_names)

def convert_annotation( img_id, path, self_txt_file ):
"""
     函数用于读取 annotation 中的 xml 文件, 并写入到传入的文件中。

     参数:
     img_id         : 给定的xml文件的名称
     path           : 传入的路径
     self_txt_file  : 将要写入文件的句柄
"""

    xml_file = open( os.path.join(path, "Annotations\\%s.xml" % (img_id)), encoding='utf-8' )
    tree=ET.parse(xml_file)
    root = tree.getroot()

    for obj in root.iter('object'):
        difficult = 0
        if obj.find('difficult') != None:
            difficult = obj.find('difficult').text
        cls = obj.find('name').text
        if cls not in classes or int(difficult)==1:
            continue

        cls_id = classes.index(cls)

        xmlbox = obj.find('bndbox')
        b = (int(float(xmlbox.find('xmin').text)), int(float(xmlbox.find('ymin').text)), int(float(xmlbox.find('xmax').text)), int(float(xmlbox.find('ymax').text)))
        self_txt_file.write(" " + ",".join([str(a) for a in b]) + ',' + str(cls_id))

def divide_voc_dataset(path, trainval_percent = 0.8, train_percent = 0.6, mode = 0):
"""
    参数:
        path: 输入的路径
        trainval_percent: 训练集和验证集在整个数据集所占的比例
        train_percent   : 训练集在训练集和验证集所占的比例
        mode            :
                        == 0 -> 代表同时生成 Mian 和 SelfMain 里面的文件划分
                        == 1 -> 代表仅仅生成 Main 里面的文件划分
                        == 2 -> 代表 进行生成 SelfMain 里面的文件划分
"""

    txt_name_list = ["train", "val", "test"]

    if mode == 0 or mode == 1:

        xmlfilepath  = os.path.join(path, "\\Annotations")
        txtsavepath  = os.path.join(path, "\\ImageSets\\Main")
        traintxtpath = os.path.join(path, "\\ImageSets\\SelfMain")

        temp_xml     = os.listdir(xmlfilepath)
        total_xml    = []

        for xml in temp_xml:
            if xml.endswith(".xml"):
                total_xml.append(xml)

        num = len(total_xml)

        tv       = int(num * trainval_percent)
        tr       = int(tv * train_percent)
        trainval = random.sample(range(num), tv)
        train    = random.sample(trainval, tr)

        print("train and val size",tv)
        print("train size",tr)

        ftrainval       = open( os.path.join(txtsavepath, '\\trainval.txt'), 'w' )
        ftest           = open( os.path.join(txtsavepath, '\\test.txt'), 'w' )
        ftrain          = open( os.path.join(txtsavepath, '\\train.txt'), 'w' )
        fval            = open( os.path.join(txtsavepath, '\\val.txt'), 'w' )

        for i in range(num):
            name = total_xml[i][:-4] + '\n'
            if i in trainval:
                ftrainval.write(name)
                if i in train:
                    ftrain.write(name)
                else:
                    fval.write(name)
            else:
                ftest.write(name)

        ftrainval.close()
        ftrain.close()
        fval.close()
        ftest.close()

    if mode == 0 or mode == 2:
        print( "Generate SelfMain/selftrain.txt、SelfMain/selfval.txt and SelfMain/selfval.txt for train." )
        for txt_name in txt_name_list:
            img_ids = open( os.path.join(path, "ImageSets\\Main\\%s.txt" % (txt_name)), encoding='utf-8' ).read().strip().split()
            self_txt_file = open( os.path.join(path, 'ImageSets\\SelfMain\\self%s.txt' % txt_name), 'w', encoding='utf-8' )
            for img_id in img_ids:
                print(img_id)

                self_txt_file.write("%s/JPEGImages/%s.jpg" % ( os.path.abspath(path), img_id ))
                convert_annotation(img_id, path, self_txt_file)
                self_txt_file.write('\n')

if __name__ == "__main__":
    random.seed(0)

    datapath= "........VOC07+12"
    classes_path        = datapath + "\\ImageSets\\SelfMain\\voc_classes.txt"

    classes, _      = get_classes(classes_path)
    divide_voc_dataset(datapath, mode=2)

运行上述文件,就可以将xml文件读取出来,也可以选择不同的模式,将之前的一些步骤也都包含在其中,可自主选择。如果需要的标定框的形式不同,也可根据形式来改变转换的结果,如有的需要 (x_min, y_min, x_max, y_max),或者需要 (x_min, y_min, h, w),可根据自己的需求选择不同的模式来复合。

了解 coco 数据集

1、COCO数据集的简介

MS COCO的全称是Microsoft Common Objects in Context,起源于微软于2014年出资标注的Microsoft COCO数据集,与ImageNet竞赛一样,被视为是计算机视觉领域最受关注和最权威的比赛之一。
COCO数据集是一个大型的、丰富的物体检测,分割和字幕数据集。这个数据集以scene understanding为目标,主要从复杂的日常场景中截取,图像中的目标通过精确的segmentation进行位置的标定。图像包括91类目标,328,000影像和2,500,000个label。目前为止有语义分割的最大数据集,提供的类别有80 类,有超过33 万张图片,其中20 万张有标注,整个数据集中个体的数目超过150 万个。
官网地址:https://cocodataset.org/

2、数据集的大小和版本

参考大神链接1:https://zhuanlan.zhihu.com/p/101984674

如下图所示:主要存在2014、2015、2017三个版本的数据集。

  • 2014:训练集 + 验证集 + 测试集(总共25G)
  • 2015:测试集(总共12G)
  • 2017:训练集 + 验证集 + 测试集(有标签 总共25G)+ 无标签图片(19G)

手撕目标检测之第一篇:目标检测的总体流程
  • 记录数量: 330K图像、 80个对象类别、每幅图像有5个标签、25万个关键点。

对应的80个类别分别为:

person(人)  bicycle(自行车)  car(汽车)  motorbike(摩托车)  aeroplane(飞机)  bus(公共汽车)  train(火车)  truck(卡车)  boat(船)
traffic light(信号灯)  fire hydrant(消防栓)  stop sign(停车标志)  parking meter(停车计费器)  bench(长凳)
bird(鸟)  cat(猫)  dog(狗)  horse(马)  sheep(羊)  cow(牛)  elephant(大象)  bear(熊)  zebra(斑马)  giraffe(长颈鹿)
backpack(背包)  umbrella(雨伞)  handbag(手提包)  tie(领带)  suitcase(手提箱)
frisbee(飞盘)  skis(滑雪板双脚)  snowboard(滑雪板)  sports ball(运动球)  kite(风筝) baseball bat(棒球棒)  baseball glove(棒球手套)  skateboard(滑板)  surfboard(冲浪板)  tennis racket(网球拍)  bottle(瓶子)  wine glass(高脚杯)  cup(茶杯)  fork(叉子)  knife(刀)spoon(勺子)  bowl(碗)
banana(香蕉)  apple(苹果)  sandwich(三明治)  orange(橘子)  broccoli(西兰花)  carrot(胡萝卜)  hot dog(热狗)  pizza(披萨)  donut(甜甜圈)  cake(蛋糕)
chair(椅子)  sofa(沙发)  pottedplant(盆栽植物)  bed(床)  diningtable(餐桌)  toilet(厕所)  tvmonitor(电视机)
laptop(笔记本)  mouse(鼠标)  remote(遥控器)  keyboard(键盘)  cell phone(电话)
microwave(微波炉)  oven(烤箱)  toaster(烤面包器)  sink(水槽)  refrigerator(冰箱)
book(书)  clock(闹钟)  vase(花瓶)  scissors(剪刀)  teddy bear(泰迪熊)  hair drier(吹风机)  toothbrush(牙刷)

数据集下载链接:https://cocodataset.org/#download

3、下载文件架构

下载的文件一般可以用于多种任务:
内容包括:

  • 目标检测与实例分割(Dectection)
  • 人体关键点检测(keypoints)
  • 材料识别(stuff)
  • 全景分割(Panoptic)
  • 图像描述(Captions)

1、图片文件

以 test2014 为例子:打开文件样式如下:

手撕目标检测之第一篇:目标检测的总体流程
图片的名称如:COCO_test2014_000000000001
  • COCO :代表属于coco书籍
  • test2014 : 代表属于 2014 的测试集
  • 000000000001 : 为图片的名称

; 2、标签文件Annotations

(建议使用软件 Dadroit Viewer 打开)

以 2014 Train/Val annotations 标注文件为例,打开文件包含以下内容:

  • 1、captions为图像描述的标注文件
  • 2、instances为目标检测与实例分割的标注文件
  • 3、person_keypoints为人体关键点检测的标注文件。
    _train 和 _val 分别对应着训练集和验证集的标签文件;
    手撕目标检测之第一篇:目标检测的总体流程
    其注释文件中的内容就是一个字典数据结构,包括如下图5个key-value对:
    手撕目标检测之第一篇:目标检测的总体流程

其中info、images、licenses三个key是三种类型标注文件共享的,也就是在三种json文件中都存在的描述类型,还存在的annotations和categories按照不同的任务有所不同,

  • 1、info字段:包括下图中的内容,即使对当前数据集的一些描述。
    手撕目标检测之第一篇:目标检测的总体流程
  • 2、 licenses字段:包括下图中的内容,里面集合了不同类型的licenses,并在images中按照id号被引用,基本不参与到数据解析过程中。
    手撕目标检测之第一篇:目标检测的总体流程
  • 3、images字段:包括下图中的内容,对应了每张图片的详细信息,其中的id号是被分配的唯一id
    手撕目标检测之第一篇:目标检测的总体流程
  • 4、categories字段:包括下图中的内容。其中supercategory是父类,name是子类,id是类别id(按照子类统计)。比如下图中所示的。coco数据集共计有80个类别(按照name计算的)
    手撕目标检测之第一篇:目标检测的总体流程
  • 5、annotations字段:包括下图中的内容,每个序号对应一个注释,一张图片上可能有多个注释。
    手撕目标检测之第一篇:目标检测的总体流程
  • category_id:该注释的类别id;
  • id:当前注释的id号
  • image_id:该注释所在的图片id号
  • area:区域面积
  • bbox:目标的矩形标注框(这里的box的标注格式是[x, y, width, height])
  • iscrowd:0或1。0表示标注的单个对象,此时segmentation使用polygon表示;1表示标注的是一组对象,此时segmentation使用RLE格式。
  • segmentation:
  • 若使用polygon标注时,则记录的是多边形的坐标点,连续两个数值表示一个点的坐标位置,因此此时点的数量为偶数
  • 若使用RLE格式(Run Length Encoding(行程长度压缩算法))
RLE算法概述
将图像中目标区域的像素值设定为1,背景设定为0,则形成一个张二值图,该二值图可以使用z字形按照位置进行
编码,例如:0011110011100000......

但是这样的形式太复杂了,可以采用统计有多少个0和1的形式进行局部压缩,因此上面的RLE编码形式为:
2-0-4-1-2-0-3-1-5-0......(表示有2个0,4个1,2个0,3个1,5个0)

在目标检测中,我们一般使用的为 Annotations 中 annotations字段 以及对应的图片数据,当然这些都还需要进一步的处理,使得可以更好进行读取和训练网络;

4、处理coco数据集

因为coco数据集比较多,一般情况下,我们会使用coco数据集来对网络进行预训练,最后在voc数据集或者其他数据集上进行微调,千万别直接在自己的小型数据集上直接训练网络,会造成费事费力且效果不好的情况,
一般情况下,除了自身硬件特别顶,我们都是采用别人预训练好的一些网络,将别人的权重拿过来,在上面微调,效果也都是挺好的,目前大部分的预训练权重都是在coco上训练的,虽然一般情况下,我们不会自己训练,但还是掌握一下比较好;

coco 数据集和 voc 数据集注释的差别在于一个是 json 文件,一个是 xml 文件,xml 文件的处理方式我们在上面已经学习到了,json 的处理方式也很类似。

因为coco数据集比较多,要是不行的话,就选择14或者17其中的一个进行处理也是可以的,过程类似。
我们下载了 coco 2014 和 coco 2017,将两者合并成为一个数据集,2014 train(13G) + 2017 train(18G) = train(31G), 2014 val(6G) + 2017 val(1G) = val(7G) (数据量emmm有点大)。

COCO Annotations,COCO标注的基础信息,在大多数情况下, COCO API 可以用于帮助我们从复杂的json注释文件中轻松访问数据和标签(在后面网络进行测试的时候会用到)。

首先创建一个 coco14+17 的文件,用来存放合并之后的文件,再在其中创建train、val、以及 annotations 文件夹

对于图片来说,我们可以选择直接复制将两个文件夹合并的方式,对于 annotations 来说,我们可以将14和17里面的 annotaions 的文件放在一起,用于生成可用于训练下 train.txt 和 val.txt.。此文件存放在 当前根 文件夹下。
代码如下:

import json
import os
from collections import defaultdict

def change_cat(cat):
"""
    改变原有的coco数据集的标签类别, 使其连续
"""
    if cat >= 1 and cat  11:
        cat = cat - 1
    elif cat >= 13 and cat  25:
        cat = cat - 2
    elif cat >= 27 and cat  28:
        cat = cat - 3
    elif cat >= 31 and cat  44:
        cat = cat - 5
    elif cat >= 46 and cat  65:
        cat = cat - 6
    elif cat == 67:
        cat = cat - 7
    elif cat == 70:
        cat = cat - 9
    elif cat >= 72 and cat  82:
        cat = cat - 10
    elif cat >= 84 and cat  90:
        cat = cat - 11
    return cat

def combine(path_14="", path_17="", path_1417="", mode = 1417):
"""
    path_14     : coco2014 的路径
    path_17     : coco2017 的路径
    path_14_17  : 自己的 coco14+17 的路径
    mode        :
                == 14 代表只对 coco2014 进行处理                        : 只需要填入参数 path_14 和 切换 mode
                == 17 代表只对 coco2017 进行处理                        : 只需要填入参数 path_14 和 切换 mode
                == 1417 代表对 coco2014 + 2017 进行处理,生成 coco14+17  : 只需要填入参数 path_1417 和 切换 mode

    无论是哪一种模式, 都会再当前路径的跟目录下面生成 train.txt 和 val.txt,
"""

    name_box_id_train = defaultdict(list)
    name_box_id_val = defaultdict(list)
    if mode == 14:
        path = path_14
        if not os.path.exists(os.path.join( path, "Annotations\\instances_train2014.json" )):
            print("当前模型和输入的参数不匹配, 请重新输入参数: coco14的正确路径 + mode = 14")
            return -1
    elif mode == 17:
        path = path_17
        if not os.path.exists(os.path.join( path, "Annotations\\instances_train2017.json" )):
            print("当前模型和输入的参数不匹配, 请重新输入参数: coco17的正确路径 + mode = 17")
            return -1
    elif mode == 1417:
        path = path_1417
        if not os.path.exists(os.path.join( path, "Annotations\\instances_train2014.json" )) and \
           not os.path.exists(os.path.join( path, "Annotations\\instances_train2017.json" )) :
            print("当前模型和输入的参数不匹配, 请重新输入参数: coco14 和 coco 的正确路径 + mode = 14")
            return -1

    if mode ==14 or mode == 1417:
        annotations_train_path_14              = os.path.join( path, "Annotations\\instances_train2014.json" )
        annotations_val_path_14                = os.path.join( path, "Annotations\\instances_val2014.json" )

        data_annotations_train_path_14         = json.load(open(annotations_train_path_14, encoding='utf-8'))['annotations']
        data_annotations_val_path_14           = json.load(open(annotations_val_path_14, encoding='utf-8'))['annotations']
        for ant in data_annotations_train_path_14:
            id = ant['image_id']
            name = os.path.join( os.path.abspath(path), 'train%d\\COCO_train2014_%012d.jpg' % (mode, id) )
            cat = change_cat(ant['category_id'])

            name_box_id_train[name].append([ant['bbox'], cat])

        for ant in data_annotations_val_path_14:
            id = ant['image_id']
            name = os.path.join( os.path.abspath(path), 'val%d\\COCO_train2014_%012d.jpg' % (mode, id) )
            cat = change_cat(ant['category_id'])

            name_box_id_val[name].append([ant['bbox'], cat])
    len14_train = len(name_box_id_train)
    len14_val   = len(name_box_id_val)
    print("In coco 2014, the train  and val image is : {}-----{}".format( len14_train, len14_val ))

    if mode ==17 or mode == 1417:
        annotations_train_path_17              = os.path.join( path, "Annotations\\instances_train2017.json" )
        annotations_val_path_17                = os.path.join( path, "Annotations\\instances_val2017.json" )

        data_annotations_train_path_17         = json.load( open(annotations_train_path_17, encoding='utf-8') )['annotations']
        data_annotations_val_path_17           = json.load( open(annotations_val_path_17, encoding='utf-8') )['annotations']

        for ant in data_annotations_train_path_17:
            id = ant['image_id']
            name = os.path.join( os.path.abspath(path), 'train%d\\%012d.jpg' % (mode, id) )
            cat = change_cat(ant['category_id'])

            name_box_id_train[name].append([ant['bbox'], cat])

        for ant in data_annotations_val_path_17:
            id = ant['image_id']
            name = os.path.join( os.path.abspath(path), 'val%d\\%012d.jpg' % (mode, id) )
            cat = change_cat(ant['category_id'])

            name_box_id_val[name].append([ant['bbox'], cat])
    total_train = len(name_box_id_train)
    total_val   = len(name_box_id_val)
    print("In coco 2017, the train  and val image is : {}-----{}".format( total_train - len14_train, total_val - len14_val ))

    print("The train and val image total is : {}-----{}".format( total_train, total_val ))

    f = open(os.path.join(path, 'train.txt'), 'w')
    for key in name_box_id_train.keys():
        print(key)
        f.write(key)
        box_infos = name_box_id_train[key]
        for info in box_infos:
            x_min = int(info[0][0])
            y_min = int(info[0][1])
            x_max = x_min + int(info[0][2])
            y_max = y_min + int(info[0][3])

            box_info = " %d,%d,%d,%d,%d" % (
                x_min, y_min, x_max, y_max, int(info[1]))
            f.write(box_info)
        f.write('\n')
    f.close()

    f = open(os.path.join(path, 'val.txt'), 'w')
    for key in name_box_id_val.keys():
        print(key)
        f.write(key)
        box_infos = name_box_id_val[key]
        for info in box_infos:
            x_min = int(info[0][0])
            y_min = int(info[0][1])
            x_max = x_min + int(info[0][2])
            y_max = y_min + int(info[0][3])

            box_info = " %d,%d,%d,%d,%d" % (
                x_min, y_min, x_max, y_max, int(info[1]))
            f.write(box_info)
        f.write('\n')
    f.close()

if __name__ == "__main__":
    path_14 = ""
    path_17 = ""
    path_1417 = ""
    combine(path_14, path_17, path_1417, mode=1417)

运行成功应该和下面类似:

手撕目标检测之第一篇:目标检测的总体流程

还需要创建coco数据集所对应类别的 txt (如cococlasses.txt):

person
bicycle
car
motorbike
aeroplane
bus
train
truck
boat
traffic light
fire hydrant
stop sign
parking meter
bench
bird
cat
dog
horse
sheep
cow
elephant
bear
zebra
giraffe
backpack
umbrella
handbag
tie
suitcase
frisbee
skis
snowboard
sports ball
kite
baseball bat
baseball glove
skateboard
surfboard
tennis racket
bottle
wine glass
cup
fork
knife
spoon
bowl
banana
apple
sandwich
orange
broccoli
carrot
hot dog
pizza
donut
cake
chair
sofa
pottedplant
bed
diningtable
toilet
tvmonitor
laptop
mouse
remote
keyboard
cell phone
microwave
oven
toaster
sink
refrigerator
book
clock
vase
scissors
teddy bear
hair drier
toothbrush

1、加载和处理数据集

在上面我们已经创建了可用于训练的TXT文件,在真正训练的时候,我们并不是一次加载全部的数据集,而是分批次的加载数据集,再者一次加载全部的数据集,电脑本身的缓存也是放不下,可以根据自身电脑缓存的大小,在后续选择 batch 的size。

理解 pytorch 框架下的 Dateset 和 dataloader 机制的大神链接:https://blog.csdn.net/zyq12345678/article/details/90268668

代码如下:


import cv2
import math
import torch
import numpy as np
from PIL import Image
from config.config import IMAGE_SIZE, IMAGE_TRANS_SCALE, IMG_GRID
import albumentations as A
from albumentations.pytorch import ToTensorV2
from utils.utils import iou_width_height as iou_wh, preprocess_input, cvtColor
from torch.utils.data.dataset import Dataset
"""
一般来说PyTorch中深度学习训练的流程是这样的:
    1. 创建Dateset(对数据进行处理,返回可以训练的图片和对应的信息)
        dataset = self_def_dataset
    2. Dataset传递给DataLoader(构建一个可迭代对象)
         dataloader = torch.utils.data.DataLoader(dataset,batch_size=64,shuffle=False,num_workers=8,.....)
    3. DataLoader迭代产生训练数据提供给模型
        for i in range(epoch):
        for index,(img,label) in enumerate(dataloader):
            pass
"""

class Dataset_loader(Dataset):
    def __init__(self, annotation_lines, box_mode, input_shape, anchors,  train, diff_out = IMG_GRID, transform=None):
        """自定义自己的数据集类

        Arguments:
            annotation_lines        -- 要加载的 annotation 文件中的每一行的的句柄
            box_mode                -- 对应对检测框进行不同的处理
                                    == 0 : 代表输出的检测框的格式为 (x_min, y_min, x_max,y_max)
                                    == 1 : 代表输出的检测框的格式为 (x_cent, y_cent, w, h)
            input_shape             -- 网络期望得到的图片的大小
            anchors                 -- 聚类获得的先验框
            train                   -- 是否是训练模式
            diff_out                -- 三种不同的特征层的输出大小
                                        : 依据输入图片的不同可以主动调整
            transform               -- 对图片进行的一些的增强操作
"""
        super().__init__()
        self.annotations_lines      = annotation_lines
        self.annotations_lenght     = len(annotation_lines)
        self.box_mode               = box_mode
        self.input_shape            = input_shape
        self.anchors                = torch.Tensor(anchors[0] + anchors[1] + anchors[2])
        self.train                  = train
        self.diff_out               = diff_out
        self.transform              = transform

        self.num_anchors            = self.anchors.shape[0]
        self.num_anchors_per_scale  = self.num_anchors // 3
        self.ignore_iou_thresh      = 0.5
        self.length                 = len(self.annotations_lines)

    def __len__(self):
"""
        返回当前数据的总长度
"""
        return  self.length

    def __getitem__(self, index):
        """返回一个组对应的图片, 检测框和类别的真值,

        Arguments:
            index            -- 选取照片的开始的索引值
"""

        index       = index % self.length
        line        = self.annotations_lines[index].split()

        image       = np.array(Image.open(line[0]).convert("RGB"))
        bboxes      = np.array([np.array(list(map(int,box.split(',')))) for box in line[1:]])

        if self.transform:
            augmentations = self.transform(image=image, bboxes=bboxes)
            image = augmentations["image"]
            bboxes = augmentations["bboxes"]

        img_h                   = self.input_shape
        img_w                   = self.input_shape

        targets                 = [torch.zeros( ( self.num_anchors // 3, out_size, out_size, 6) ) for out_size in self.diff_out]

        for box in bboxes:

            iou_anchors         = iou_wh(torch.tensor(box[2:4]), self.anchors)

            anchor_idxs         = iou_anchors.argsort( dim=0, descending=True )
            x, y, w, h, label   = box

            has_anchors         = [False] * 3

            for anchor_idx in anchor_idxs:

                scale_idx       = torch.div(anchor_idx, self.num_anchors_per_scale, rounding_mode='trunc')

                anchor_on_scale = anchor_idx % self.num_anchors_per_scale

                feat_scale      = self.diff_out[scale_idx]

                i, j            = int(feat_scale * (x / img_w)), int(feat_scale * (y / img_h))

                anchor_taken    = targets[scale_idx][anchor_on_scale, i, j, 0]

                if not anchor_taken and not has_anchors[scale_idx]:
                    targets[scale_idx][anchor_on_scale, i, j, 0] = 1
                    has_anchors[scale_idx] = True

                    x_cell, y_cell, w_cell, h_cell = feat_scale * x / img_w - i, feat_scale * y  /img_h - j, feat_scale * w / img_w, feat_scale * h / img_h
                    box_coordinates = torch.tensor(
                        [x_cell, y_cell, w_cell, h_cell]
                    )
                    targets[scale_idx][anchor_on_scale, i, j, 1:5] = box_coordinates
                    targets[scale_idx][anchor_on_scale, i, j, 5] = int(label)

                elif not anchor_taken and iou_anchors[anchor_idx] > self.ignore_iou_thresh:
                    targets[scale_idx][anchor_on_scale, i, j, 0] = -1

        return image, targets

train_transforms = A.Compose(
    [

        A.LongestMaxSize(max_size=IMAGE_SIZE),

        A.PadIfNeeded(
            min_height=IMAGE_SIZE, min_width=IMAGE_SIZE, border_mode=cv2.BORDER_CONSTANT
        ),

        A.Normalize(mean=[0, 0, 0], std=[1, 1, 1], max_pixel_value=255,),

        ToTensorV2(),
    ],

    bbox_params=A.BboxParams(format="coco", label_fields=[],check_each_transform=False),
)

val_transforms = A.Compose(
    [

        A.LongestMaxSize(max_size=IMAGE_SIZE),

        A.PadIfNeeded(
            min_height=IMAGE_SIZE, min_width=IMAGE_SIZE, border_mode=cv2.BORDER_CONSTANT
        ),

        A.Normalize(mean=[0, 0, 0], std=[1, 1, 1], max_pixel_value=255,),

        ToTensorV2(),
    ],

    bbox_params=A.BboxParams(format="coco", min_visibility=0, label_fields=[], check_each_transform=False),
)

2、编写神经网路代码

在此以yolov3为例子(可能有点古老,但是新手入门嘛,慢慢来),对照着yolov3中论文中的网络架构图,来实现网络:

手撕目标检测之第一篇:目标检测的总体流程
我们可以看到,网络架构中,有很多地方都是重复的,只是一些参数上的差别,我们先将这些重复地方进行模块化,后面组合起来也方便:
先完成配置文件,让自己的网络清晰可见,新建一个config.py文件,在其中写入:

config = [
    (32, 3, 1),
    (64, 3, 2),
    ["R", 1],
    (128, 3, 2),
    ["R", 2],
    (256, 3, 2),
    ["R", 8],
    (512, 3, 2),
    ["R", 8],
    (1024, 3, 2),
    ["R", 4],
    (512, 1, 1),
    (1024, 3, 1),
    "S",
    (256, 1, 1),
    "U",
    (256, 1, 1),
    (512, 3, 1),
    "S",
    (128, 1, 1),
    "U",
    (128, 1, 1),
    (256, 3, 1),
    "S",
]

在 model.py 中编写一个标准卷积模块:


import torch
import torch.nn as nn
from torchsummary import summary
from config import model_config

class CNNBlock(nn.Module):

    def __init__( self, in_channels, out_channels, bn_act=True, **kwargs ):
        super().__init__()

        self.conv = nn.Conv2d(in_channels, out_channels, bias= not bn_act, **kwargs)
        self.bn = nn.BatchNorm2d(out_channels)
        self.leaky = nn.LeakyReLU(0.1)
        self.use_bn_act = bn_act

    def forward(self, x):
        if self.use_bn_act:
            return self.leaky(self.bn(self.conv(x)))
        else:
            return self.conv(x)

一般情况下,标准卷积模块为 卷积 + BN + 激活函数,但是因为我们设计的为通用的卷积模块,在yolohead的时候,我们不希望再标准卷积中使用激活函数和BN,所以设置了一个flag。且存在BN和激活函数的情况下,偏差是没有必要的。

从网络架构图中可以发现,还需要一个稍微大一点的基础残差模块,定义如下:


class ResidualBlock(nn.Module):
    def __init__(self, channels, use_residual=True, num_repeats=1):
        super().__init__()
        self.layers =  nn.ModuleList()

        for _ in range(num_repeats):

            self.layers += [
                nn.Sequential(
                    CNNBlock(channels, channels // 2, kernel_size=1),
                    CNNBlock(channels // 2, channels, kernel_size=3, padding=1),
                )
            ]
        self.use_residual = use_residual
        self.num_repeats = num_repeats

    def forward(self, x):
        for layer in self.layers:
            x = layer(x) + x if self.use_residual else layer(x)

        return x

上面两个基础模块已经可以将上面的网络架构搭起来了,但是,我们还需要大家 yolov3 多尺度预测的分类头,目前来说就是对特征图进行两次卷积,只不过输入的特征图尺度为三种不同的尺度:
代码如下:


class ScalePrediction(nn.Module):
    def __init__(self, in_channels, num_classes):
        super().__init__()
        self.pred = nn.Sequential(
            CNNBlock(in_channels, 2 * in_channels, kernel_size=3, padding=1),
            CNNBlock(2 * in_channels, 3 * (num_classes + 5 ),  bn_act=False, kernel_size=1)
        )
        self.num_classes = num_classes

    def forward(self, x):
        return (
            self.pred(x)
            .reshape(x.shape[0], 3, self.num_classes + 5, x.shape[2], x.shape[3])
            .permute(0, 1, 3, 4, 2)
        )

模块搭建完毕,将其按照网络架构有序组合:


class YOLOv3(nn.Module):
    def __init__(self, in_channels=3, num_classes=80):
        super().__init__()
        self.num_classes =num_classes
        self.in_channels = in_channels

        self.layers = self._create_conv_layers()

    def forward(self, x):
        outputs = []
        route_connection = []

        for layer in self.layers:
            if  isinstance(layer, ScalePrediction):
                outputs.append(layer(x))
                continue

            x = layer(x)

            if isinstance(layer, ResidualBlock) and layer.num_repeats == 8:
                route_connection.append(x)

            elif isinstance(layer, nn.Upsample):
                x = torch.cat([x, route_connection[-1]], dim=1)
                route_connection.pop()

        return outputs

    def _create_conv_layers(self):

        layers = nn.ModuleList()
        in_channels = self.in_channels

        for module in model_config:
            if isinstance(module, tuple):
                out_channels, kernel_size, stride = module
                layers.append(
                        CNNBlock(
                            in_channels,
                            out_channels,
                            kernel_size=kernel_size,
                            stride=stride,
                            padding=1 if kernel_size == 3 else 0,
                            )
                    )
                in_channels = out_channels
            elif isinstance(module, list):
                num_repeats = module[1]
                layers.append(
                    ResidualBlock(
                        in_channels,
                        num_repeats=num_repeats,
                    )
                )
            elif isinstance(module, str):
                if module == "S":
                    layers += [
                        ResidualBlock(in_channels, use_residual=False, num_repeats=1),
                        CNNBlock(in_channels, in_channels //2, kernel_size=1),
                        ScalePrediction(in_channels // 2, self.num_classes)
                    ]
                    in_channels = in_channels // 2

                elif module == "U":
                    layers.append(nn.Upsample(scale_factor=2),)

                    in_channels = in_channels * 3
        return layers

搭建完网络,打印一下网络架构,看是否和自己预想中的一样


if __name__ == "__main__":
    num_classes = 20
    IMAGE_SIZE = 416
    model = YOLOv3(num_classes=num_classes)
    x = torch.randn((2, 3, IMAGE_SIZE, IMAGE_SIZE))

    summary(model,(3, 416, 416))

到此网络架构完成了。其实深度学习编写网络架构可能是其中最简单的事情了,比较难的是前端的数据处理和后端的优化函数的实现。

3、编写损失函数

根据 yolov3 论文中表明的的损失函数,总共包括以下:

  • 检测框的位置偏差
  • 中心位置偏差
  • 宽高偏差
  • 预测置信度的偏差(没有物体的检测框也就预测分数,也要算损失函数)
  • 有物体的置信度偏差
  • 没有物体的置信度偏差
  • 预测类别的偏差

损失函数详细解释链接

如下图:

手撕目标检测之第一篇:目标检测的总体流程
对照损失函数依次编写代码(新建 loss.py):
BCEWithLogitsLoss():
ℓ ( x , y ) = L = { l 1 , … , l N } ⊤ , l n = − w n [ y n ⋅ log ⁡ σ ( x n ) + ( 1 − y n ) ⋅ log ⁡ ( 1 − σ ( x n ) ) ] \ell(x, y) = L = {l_1,\dots,l_N}^\top, \quad l_n = – w_n \left[ y_n \cdot \log \sigma(x_n) + (1 – y_n) \cdot \log (1 – \sigma(x_n)) \right]ℓ(x ,y )=L ={l 1 ​,…,l N ​}⊤,l n ​=−w n ​[y n ​⋅lo g σ(x n ​)+(1 −y n ​)⋅lo g (1 −σ(x n ​))]

import torch
import torch.nn as nn
from utils import intersection_over_union

class YoloLoss(nn.Module):
    def __init__(self):
        super().__init__()

        self.mse     = nn.MSELoss()

        self.bce     = nn.BCEWithLogitsLoss()

        self.entropy = nn.CrossEntropyLoss()
        self.sigmoid = nn.Sigmoid()

        self.lambda_class = 1
        self.lambda_noobj = 10
        self.lambda_obj   = 1
        self.lambda_box   = 10

    def forward(self, predictions, target, anchors):

        obj   = target[..., 0]      == 1

        noobj = target[..., 0]      == 0

        no_object_loss = self.bce(
            (predictions[..., 0:1][noobj]), (target[..., 0:1][noobj]),
        )

        anchors = anchors.reshape(1, 3, 1, 1, 2)

        box_preds = torch.cat([self.sigmoid(predictions[..., 1:3]), torch.exp(predictions[..., 3:5]) * anchors], dim=-1)

        ious = intersection_over_union(box_preds[obj], target[..., 1:5][obj]).detach()
        object_loss = self.mse(self.sigmoid(predictions[..., 0:1][obj]), ious * target[..., 0:1][obj])

        predictions[..., 1:3] = self.sigmoid(predictions[..., 1:3])
        target[..., 3:5] = torch.log( (1e-16 + target[..., 3:5] / anchors) )
        box_loss = self.mse(predictions[..., 1:5][obj], target[..., 1:5][obj])

        class_loss = self.entropy(
            (predictions[..., 5:][obj]), (target[..., 5][obj].long()),
        )

        return (
            self.lambda_box * box_loss
            + self.lambda_obj * object_loss
            + self.lambda_noobj * no_object_loss
            + self.lambda_class * class_loss
        )

在编写上述代码的时候,我们使用到了一些自己写的 api,可以新建一个 utils.py, 专门存放自己写的一些小工具。


def intersection_over_union(boxes_preds, boxes_labels, box_format="midpoint"):
"""

    计算两个框之间的面积的交并比(iou)的函数

    Parameters:
        boxes_preds (tensor) : 网络预测出来的框的坐标 (BATCH_SIZE, 4)
        boxes_labels (tensor): 真实标签下的框的坐标   (BATCH_SIZE, 4)
        box_format (str)     : 选择自己的模式, midpoint/corners, if boxes (x,y,w,h) or (x1,y1,x2,y2)

    Returns:
        tensor: 返回检测框之间的 iou
"""

    if box_format == "midpoint":
        box1_x1 = boxes_preds[..., 0:1]  - boxes_preds[..., 2:3] / 2
        box1_y1 = boxes_preds[..., 1:2]  - boxes_preds[..., 3:4] / 2
        box1_x2 = boxes_preds[..., 0:1]  + boxes_preds[..., 2:3] / 2
        box1_y2 = boxes_preds[..., 1:2]  + boxes_preds[..., 3:4] / 2
        box2_x1 = boxes_labels[..., 0:1] - boxes_labels[..., 2:3] / 2
        box2_y1 = boxes_labels[..., 1:2] - boxes_labels[..., 3:4] / 2
        box2_x2 = boxes_labels[..., 0:1] + boxes_labels[..., 2:3] / 2
        box2_y2 = boxes_labels[..., 1:2] + boxes_labels[..., 3:4] / 2

    if box_format == "corners":
        box1_x1 = boxes_preds[..., 0:1]
        box1_y1 = boxes_preds[..., 1:2]
        box1_x2 = boxes_preds[..., 2:3]
        box1_y2 = boxes_preds[..., 3:4]
        box2_x1 = boxes_labels[..., 0:1]
        box2_y1 = boxes_labels[..., 1:2]
        box2_x2 = boxes_labels[..., 2:3]
        box2_y2 = boxes_labels[..., 3:4]

    x1 = torch.max(box1_x1, box2_x1)
    y1 = torch.max(box1_y1, box2_y1)
    x2 = torch.min(box1_x2, box2_x2)
    y2 = torch.min(box1_y2, box2_y2)

    intersection = (x2 - x1).clamp(0) * (y2 - y1).clamp(0)
    box1_area = abs((box1_x2 - box1_x1) * (box1_y2 - box1_y1))
    box2_area = abs((box2_x2 - box2_x1) * (box2_y2 - box2_y1))

    return intersection / (box1_area + box2_area - intersection + 1e-6)

4、编写后处理代码

4.1、非极大值抑制

我们知道,在 yolov3 针对单一物体的检测框的输出并不是唯一的,如果将检测框的输出全部展示出来的话,或者使用的话,将有很大一部分是重叠,我们需要选出里面可能性最高的,效果最好的的一个检测框,来进行输出,也就所说的非极大值抑制:


def non_max_suppression(bboxes, iou_threshold, threshold, box_format="corners"):
"""
    对网络输出的所有检测框进行非极大值抑制

    Parameters:
        bboxes (list)        : 包含所有的网络输出的检测框,每一个检测框的形式为 [class_pred, prob_score, x1, y1, x2, y2]
        iou_threshold (float): 要计算 检测框和其他检测框 iou 的阈值(计算所有检测框的,并依次排序)
        threshold (float)    : 小于这个阈值的认为网络输出不正确
        box_format (str)     : "midpoint" or "corners" 检测框坐标所使用的形式

    Returns:
        list: bboxes after performing NMS given a specific IoU threshold
"""

    assert type(bboxes) == list

    bboxes = [box for box in bboxes if box[1] > threshold]
    bboxes = sorted(bboxes, key=lambda x: x[1], reverse=True)
    bboxes_after_nms = []

    while bboxes:

        chosen_box = bboxes.pop(0)

        bboxes = [
            box
            for box in bboxes
            if box[0] != chosen_box[0]
            or intersection_over_union(
                torch.tensor(chosen_box[2:]),
                torch.tensor(box[2:]),
                box_format=box_format,
            )
            < iou_threshold
        ]

        bboxes_after_nms.append(chosen_box)

    return bboxes_after_nms

4.2、mAP网络评价

训练网络总归要有一个评价指标吧,这里使用的为 MAP,这个可以参考上面给出的解析链接。简单理解就是先计算单一类别的准确度,再计算所有类别的准确度。


def mean_average_precision(
    pred_boxes, true_boxes, iou_threshold=0.5, box_format="midpoint", num_classes=80, ap_print = true
):
"""

    计算 mean average precision (mAP)

    Parameters:
        pred_boxes (list)    : 网络预测的所有的检测框(在 NMS 之后的),
                             : 单一检测框的形式为 [train_idx, class_prediction, prob_score, x1, y1, x2, y2]
                             : train_idx, 指示图片编号, 用于区分不同的图片
        true_boxes (list)    : 真实的检测框标签
        iou_threshold (float): 和真实检测框的 iou 阈值
        box_format (str)     : 框的坐标形式
        num_classes (int)    : 当前数据集的类别总数
        ap_print             : 是否打印每一类的 AP 的值

    Returns:
        float: 返回所有类别总的 mAP
"""

    average_precisions = []

    epsilon = 1e-6

    for c in range(num_classes):

        detections = []
        ground_truths = []

        for detection in pred_boxes:
            if detection[1] == c:
                detections.append(detection)

        for true_box in true_boxes:
            if true_box[1] == c:
                ground_truths.append(true_box)

        amount_bboxes = Counter([gt[0] for gt in ground_truths])

        for key, val in amount_bboxes.items():
            amount_bboxes[key] = torch.zeros(val)

        detections.sort(key=lambda x: x[2], reverse=True)

        TP = torch.zeros((len(detections)))
        FP = torch.zeros((len(detections)))

        total_true_bboxes = len(ground_truths)

        if total_true_bboxes == 0:
            continue

        for detection_idx, detection in enumerate(detections):

            ground_truth_img = [
                bbox for bbox in ground_truths if bbox[0] == detection[0]
            ]

            num_gts = len(ground_truth_img)
            best_iou = 0

            for idx, gt in enumerate(ground_truth_img):
                iou = intersection_over_union(
                    torch.tensor(detection[3:]),
                    torch.tensor(gt[3:]),
                    box_format=box_format,
                )

                if iou > best_iou:
                    best_iou = iou
                    best_gt_idx = idx

            if best_iou > iou_threshold:

                if amount_bboxes[detection[0]][best_gt_idx] == 0:

                    TP[detection_idx] = 1
                    amount_bboxes[detection[0]][best_gt_idx] = 1
                else:
                    FP[detection_idx] = 1

            else:
                FP[detection_idx] = 1

        TP_cumsum = torch.cumsum(TP, dim=0)
        FP_cumsum = torch.cumsum(FP, dim=0)

        recalls = TP_cumsum / (total_true_bboxes + epsilon)
        precisions = TP_cumsum / (TP_cumsum + FP_cumsum + epsilon)

        precisions = torch.cat((torch.tensor([1]), precisions))
        recalls = torch.cat((torch.tensor([0]), recalls))

        class_ap = torch.trapz(precisions, recalls)
        if ap_print:
            print("The current class {} AP : {}".format(c, class_ap))
        average_precisions.append(class_ap)

    return sum(average_precisions) / len(average_precisions)

5、编写训练和测试代码

准备好了所有的先前的条件,我们便可以进行训练了,让我们开始编写训练函数。


    pmgressbar_train = tqdm(train_loader, desc=f"Train epoch {cur_epoch + 1}/{all_epoch}", postfix=dict, mininterval=0.3)

    model.train()

    train_losses = []
    for iteration, (images, targets) in enumerate(train_loader):

        images = images.to(config.DEVICE)
        y0, y1, y2 = (
            targets[0].to(config.DEVICE),
            targets[1].to(config.DEVICE),
            targets[2].to(config.DEVICE),
        )

        with torch.cuda.amp.autocast():
            out = model(images)
            loss = (
                  loss_fn(out[0], y0, scaled_anchors[0])
                + loss_fn(out[1], y1, scaled_anchors[1])
                + loss_fn(out[2], y2, scaled_anchors[2])
            )

        train_losses.append(loss.item())
        optimizer.zero_grad()

        scaler.scale(loss).backward()
        scaler.step(optimizer)
        scaler.update()

        train_mean_loss = sum(train_losses) / len(train_losses)
        pmgressbar_train.set_postfix(**{'train_loss' : train_mean_loss,
                                        'lr'         : get_lr(optimizer)})
        pmgressbar_train.update()

    pmgressbar_train.close()
    print("一个epoch的训练集的训练结束. ")
    print("开始验证集的测试 .")
    pmgressbar_val = tqdm(val_loader, desc=f"Train epoch {cur_epoch + 1}/{all_epoch}", postfix=dict, mininterval=0.3)

    model.eval()
    val_losses = []
    tot_class_preds, correct_class = 0, 0
    tot_noobj, correct_noobj = 0, 0
    tot_obj, correct_obj = 0, 0
    for iteration, (images, targets) in enumerate(val_loader):
        images = images.to(config.DEVICE)
        y0, y1, y2 = (
            targets[0].to(config.DEVICE),
            targets[1].to(config.DEVICE),
            targets[2].to(config.DEVICE),
        )
        with torch.no_grad():
            out = model(images)
            loss = (
                loss_fn(out[0], y0, scaled_anchors[0])
                + loss_fn(out[1], y1, scaled_anchors[1])
                + loss_fn(out[2], y2, scaled_anchors[2])
            )
        val_losses.append(loss.item())
        val_mean_loss = sum(val_losses) / len(val_losses)
        pmgressbar_val.set_postfix(val_loss=val_mean_loss)
        pmgressbar_val.update()

    pmgressbar_val.close()
    print("一个epoch的验证集的验证结束. ")

    if conf_thresh != 0:
        print("计算每个类别细分的各个准确度. ")

        for i in range(3):
            targets[i] = targets[i].to(config.DEVICE)
            obj = targets[i][..., 0] == 1
            noobj = targets[i][..., 0] == 0

            correct_class += torch.sum(
                torch.argmax(out[i][..., 5:][obj], dim=-1) == targets[i][..., 5][obj]
            )
            tot_class_preds += torch.sum(obj)

            obj_preds = torch.sigmoid(out[i][..., 0]) > conf_thresh
            correct_obj += torch.sum(obj_preds[obj] == targets[i][..., 0][obj])
            tot_obj += torch.sum(obj)
            correct_noobj += torch.sum(obj_preds[noobj] == targets[i][..., 0][noobj])
            tot_noobj += torch.sum(noobj)

        print(f"Class accuracy is: {(correct_class/(tot_class_preds+1e-16))*100:2f}%")
        print(f"No obj accuracy is: {(correct_noobj/(tot_noobj+1e-16))*100:2f}%")
        print(f"Obj accuracy is: {(correct_obj/(tot_obj+1e-16))*100:2f}%")

    loss_history.append_loss(cur_epoch, train_losses, val_losses)

    if config.SAVE_MODEL:
        if (cur_epoch + 1) % config.WEIGHT_SAVE_PERIOD == 0 or cur_epoch + 1 == config.NUM_EPOCHS:
            filename = os.path.join(config.SAVE_DIR, "checkpoint/ep%03d-train_loss%.3f-val_loss%.3f.pth"% (cur_epoch, train_losses, val_losses))
            save_checkpoint(model=model, optimizer=optimizer, filename=filename)

        elif len(loss_history.val_loss)  1 or (val_losses)  min(loss_history.val_loss):
            print('Save current best model to best_epoch_weights.pth')
            filename = "best_epoch_weights.pth"
            save_checkpoint(model=model, optimizer=optimizer, filename=filename)

        else:
            filename = "last_epoch_weights.pth"
            save_checkpoint(model=model, optimizer=optimizer, filename=filename)

上面只是设置了一个epoch的训练过程,我们有很多个epoch,不仅如此,我们还需要设置我们的优化器,初始化损失函数,使用 Dateloader,生成数据可迭代对象,具体代码如下:


if __name__ == "__main__":

    if torch.cuda.is_available():
        print("在 GPU 上面训练. ")
    else:
        print("在 CPU 上面训练. ")

    model = YOLOv3(config.model_config, num_classes=config.NUM_CLASSES).to(config.DEVICE)
    print("模型加载完毕... ")

    if config.OPTIMIZER_TYPE == "adam":
        optimizer = optim.Adam(
            model.parameters(), lr=config.INIT_LEARNING_RATE, betas=(config.MOMENTUM, 0.999), weight_decay=config.WEIGHT_DECAY
        )
    if config.OPTIMIZER_TYPE == "sgd":
        optimizer = optim.SGD(
            model.parameters(), lr=config.INIT_LEARNING_RATE, momentum=config.MOMENTUM, weight_decay=config.WEIGHT_DECAY, nesterov=True
        )
    print("优化器加载完毕... ")

    loss_fn = YoloLoss()
    print("损失函数加载完毕... ")

    time_str        = datetime.datetime.strftime(datetime.datetime.now(),'%Y_%m_%d_%H_%M_%S')
    log_dir         = os.path.join(config.SAVE_DIR, "loss/loss_" + str(time_str))
    loss_history    = LossHistory(log_dir=log_dir, model=model, input_shape=config.IMAGE_SIZE)
    print("损失函数日志记载函数加载完毕... ")

    scaler = torch.cuda.amp.GradScaler()

    scaled_anchors = (torch.tensor(config.ANCHORS)).to(config.DEVICE)
    print("先验框加载完毕... ")

    train_annotaion_lines, val_annotation_lines = get_anno_lines(train_annotation_path=config.TRAIN_LABEL_DIR, val_annotation_path=config.VAL_LABEL_DIR)

    train_dataset   = Dataset_loader(annotation_lines=train_annotaion_lines, input_shape=config.IMAGE_SIZE, anchors=config.ANCHORS,
                                    transform=train_transforms, train = True, box_mode="midpoint")
    val_dataset     = Dataset_loader(annotation_lines=val_annotation_lines, input_shape=config.IMAGE_SIZE,  anchors=config.ANCHORS,
                                    transform=val_transforms, train = True, box_mode="midpoint")
    train_loader    = DataLoader(train_dataset, config.BATCH_SIZE, config.SHUFFLR, num_workers=config.NUM_WORKERS,
                                pin_memory=config.PIN_MEMORY, drop_last=True)
    val_loader      = DataLoader(val_dataset, config.BATCH_SIZE, config.SHUFFLR, num_workers=config.NUM_WORKERS,
                                pin_memory=config.PIN_MEMORY, drop_last=False)
    print("数据集迭代器加载完毕...")

    checkpoint_file = os.path.join(config.SAVE_DIR, config.LOAD_WEIGHT_NAME)
    if config.LOAD_MODEL:
        load_checkpoint(
            config.SAVE_DIR, model, optimizer, config.LEARNING_RATE
        )

    print("进入 epochs, 开始训练... ")

    for epoch in range( config.NUM_EPOCHS ):

        print("Epoch:" + str(epoch) + " / " + str(config.NUM_EPOCHS) + "->")

        epoch_train(train_loader, val_loader, model, optimizer, loss_fn, scaler, scaled_anchors, epoch, config.NUM_EPOCHS, loss_history)
        if epoch > 0 and epoch % 10 == 0:

            pred_boxes, true_boxes = get_evaluation_bboxes(
                val_loader,
                model,
                iou_threshold=config.NMS_IOU_THRESH,
                anchors=config.ANCHORS,
                threshold=config.CONF_THRESHOLD,
            )
            mapval = mean_average_precision(
                pred_boxes,
                true_boxes,
                iou_threshold=config.MAP_IOU_THRESH,
                box_format="midpoint",
                num_classes=config.NUM_CLASSES,
            )
            print(f"MAP: {mapval.item()}")
            model.train()

        lr_scheduler_func = get_lr_scheduler(config.LEARNING_RATE_DECAY_TYPE, config.INIT_LEARNING_RATE, config.MIN_LEARNING_RATE, config.NUM_EPOCHS)
        set_optimizer_lr(optimizer, lr_scheduler_func, epoch)

写完这些,我们就可以开始训练我们的神经网络。并其可以得到相应的训练权重。

到这里我们已经从头到尾,几乎写了所有的代码,但是还是需要很多的代码没有展现出来,并且由于 yolov3 已经出现很久了,有很多大神的代码很好很好,可以对比着大神们的代码进行理解更好。

6、编写预测代码与单机多卡训练(待完成)

7、一些知识点普及链接:

单机多卡相关知识点:

1、什么是 DDP, DP

大神链接:https://zhuanlan.zhihu.com/p/356967195

2、什么是自动混合精度训练MAP

大神链接:https://www.cnblogs.com/jimchen1218/p/14315008.html

3、PyTorch分布式训练

大神链接:https://zhuanlan.zhihu.com/p/360405558

4、理解 pytorch 框架下的 Dateset 和 dataloader 机制的大神链接:

大神链接:https://blog.csdn.net/zyq12345678/article/details/90268668

Original: https://blog.csdn.net/To_be_little/article/details/125943204
Author: 浅冲一下
Title: 手撕目标检测之第一篇:目标检测的总体流程

原创文章受到原创版权保护。转载请注明出处:https://www.johngo689.com/680327/

转载文章受原作者版权保护。转载请注明原作者出处!

(0)

大家都在看

亲爱的 Coder【最近整理,可免费获取】👉 最新必读书单  | 👏 面试题下载  | 🌎 免费的AI知识星球