天天看點

基于TensorFlow2.0的YOLOV2訓練過程目錄 1. 環境配置2. 訓練資料集準備3. 訓練資料集預處理4. 真實标簽格式處理5. 模型搭建與權重初始化6. 損失計算7. 模型訓練與儲存8. 模型驗證

目錄

 1. 環境配置

1.1 Anaconda安裝

1.2 Pycharm安裝

1.3 TensorFlow安裝

2. 訓練資料集準備

2.1 資料集标注

3. 訓練資料集預處理

3.1 解析标簽檔案XML

3.2 讀取圖檔

4. 真實标簽格式處理

4.1 單張圖檔 

4.2 批量圖檔

5. 模型搭建與權重初始化

5.1 模型搭建

5.2 權重初始化

6. 損失計算

6.1 制作網格坐标

6.2 坐标損失計算

6.3 類别損失計算

6.4 置信度計算

7. 模型訓練與儲存

8. 模型驗證

運作環境:

Python:3.6

TensorFlow: 2.0.0+

cuda: 10.0

cundnn: 7.4

Pycharm: 發行版

 1. 環境配置

1.1 Anaconda安裝

我使用的是Windows系統,當然,使用Ubuntu也可以,沒有什麼差別。

下載下傳Anaconda3,下載下傳連結:https://pan.baidu.com/s/1xzrb7kqigl5SYigVO2NdWw,提取碼:41tg 

将Anaconda3下載下傳完成後,然後安裝。

1.2 Pycharm安裝

下載下傳Pycharm, 下載下傳連結:https://pan.baidu.com/s/1SOhs72JK9YY6GAFImrwdBQ,提取碼:bqsn

将Pycharm下載下傳完成後,然後安裝

1.3 TensorFlow安裝

1. 建立一個Python虛拟環境,使用Anaconda Prompt 或者 Anaconda Navigator都可以,我使用的是Prompt, ubuntu系統可以使用終端或者Navigator。

conda create -n Tensorflow-GPU python=3.6
           

環境的名字可以任意選擇。

2. 激活環境,在該環境中安裝TensorFlow2.0,我這裡介紹一種簡單的方法。

conda install tensorflow-gpu==2.0.0 #gpu版本

# conda install tensorflow==2.0.0 #cpu版本
           

 通過該指令會将TensorFlow-gpu版本自動安裝成功,包含配套的cuda, cudnn。在ubuntu上一樣的指令,如果安裝失敗,一般都是因為網速的問題,可以考慮将conda的源換為國内源,這裡就不再多贅述,CSDN中有很多部落格介紹。

3. 打開Pycharm配置環境即可。

2. 訓練資料集準備

目标檢測資料集一般是VOC格式的,YOLO與SSD都是這種格式。

2.1 資料集标注

1. 首先将采集好的原圖,全部resize成網絡輸入的尺寸,比如YOLOV2的輸入尺寸是512X512。

# -*- coding: utf-8 -*-
import cv2
import os

def rebuild(path_src, path_dst, width, height):
    """
    :param path_src: 原圖相對位址
    :param path_dst: 儲存圖相對位址
    :return: None
    """
    i = 1
    image_names = os.listdir(path_src)
    for image in image_names:
        if image.endswith('.jpg') or image.endswith('.png'):
            img_path = path_src + image
            save_path = path_dst + image
            img = cv2.imread(img_path)
            resize_img = cv2.resize(img, (width, height))
            cv2.imwrite(save_path, resize_img)
            print("修改第 " + str(i), " 張圖檔:", save_path)
            i = i + 1

if __name__ == "__main__":
    # 原圖相對位址,也可以使用絕對位址
    path_src = "pikachu/"
    # 儲存圖相對位址,也可以使用絕對位址
    path_dst = "pikachu_new/"
    width = 512
    heght = 512
    rebuild(path_src, path_dst, width, heght)
           

 2. 使用labelImg進行目标标注,使用别的标注工具也可以

labelImg安裝方法1:直接下載下傳軟體,然後放在桌面輕按兩下打開即可,不需要安裝

連結:https://pan.baidu.com/s/1_wdd_tChBCrfcicKC-Nxgg 提取碼:tsz7

labelImg安裝方法2:去github下載下傳源碼編譯, github連結:https://github.com/tzutalin/labelImg

3. 訓練資料集預處理

3.1 解析标簽檔案XML

請下載下傳檔案://download.csdn.net/download/qq_37116150/12289197

該檔案包含完整代碼

每張圖檔的标簽資訊全部儲存在.xml(使用labelImg标注圖檔生成的檔案)檔案中,标簽檔案中包含原圖路徑,原圖名,目标位置資訊(左上角坐标,右下角坐标,夠成一個矩形框),類别名,我們需要的是原圖路徑, 目标位置資訊以及類别名,所有我們需要将這些資訊從xml标簽檔案中提取出來。

xml_parse.py, 可将該檔案直接下載下傳下來,由于YOLO整個項目比較大,代碼量比較多,是以分成幾個檔案,一起編寫。

# -*- coding: utf-8 -*-
import os, glob
import numpy as np
import xml.etree.ElementTree as ET

"""

該檔案主要用于解析xml檔案,同時傳回原圖檔的路徑與标簽中目标的位置資訊以及類别資訊

"""
def paras_annotation(img_dir, ann_dir, labels):
    """
    :param img_dir: image path
    :param ann_dir: annotation xml file path
    :param labels: ("class1", "class2",...,), 背景預設為0
    :function: paras annotation info from xml file
    :return:
    """
    imgs_info = []  #存儲所有圖檔資訊的容器清單
    max_boxes = 0   #計算所有圖檔中,目标在一張圖檔中所可能出現的最大數量
    # for each annotation xml file
    for ann in os.listdir(ann_dir):  # 周遊檔案夾中所有的xml檔案, 傳回值是xml的位址
        tree = ET.parse(os.path.join(ann_dir, ann))  #使用xml内置函數讀取xml檔案,并傳回一個可讀取節點的句柄

        img_info = dict()  # 為每一個标簽xml檔案建立一個内容存放容器字典
        boxes_counter = 0  # 計算該标簽檔案中所含有的目标數量
        # 由于每張标簽中,目标存在數量可能大于1, 所有将object内容格式設定為清單,以存放多個object
        img_info['object'] = []
        for elem in tree.iter(): # 周遊xml檔案中所有的節點
            if 'filename' in elem.tag:  # 讀取檔案名,将檔案絕對路徑存儲在字典中
                img_info['filename'] = os.path.join(img_dir, elem.text)
            # 讀取标簽中目标的寬,高, 通道預設為3不進行讀取
            if 'width' in elem.tag:
                img_info['width'] = int(elem.text)
                # assert img_info['width'] == 512  #用于斷言圖檔的寬高為512 512
            if 'height' in elem.tag:
                img_info['height'] = int(elem.text)
                # assert img_info['height'] == 512

            if 'object' in elem.tag or 'part' in elem.tag:  # 讀取目标框的資訊
                # 目标框資訊存儲方式:x1-y1-x2-y2-label
                object_info = [0, 0, 0, 0, 0] # 建立存儲目标框資訊的容器清單
                boxes_counter += 1
                for attr in list(elem):  # 循環讀取子節點
                    if 'name' in attr.tag:  # 目标名
                        label = labels.index(attr.text) + 1 # 傳回索引值 并加1, 因為背景為0
                        object_info[4] = label
                    if 'bndbox' in attr.tag:  # bndbox的資訊
                        for pos in list(attr):
                            if 'xmin' in pos.tag:
                                object_info[0] = int(pos.text)
                            if 'ymin' in pos.tag:
                                object_info[1] = int(pos.text)
                            if 'xmax' in pos.tag:
                                object_info[2] = int(pos.text)
                            if 'ymax' in pos.tag:
                                object_info[3] = int(pos.text)
                # object shape: [n, 5],是一個清單,但包含n個子清單,每個子清單有5個内容
                img_info['object'].append(object_info)

        imgs_info.append(img_info)  # filename, w/h/box_info
        # (N,5)=(max_objects_num, 5)
        if boxes_counter > max_boxes:
            max_boxes = boxes_counter
    # the maximum boxes number is max_boxes
    # 将讀取的object資訊轉化為一個矩陣形式:[b, max_objects_num, 5]
    boxes = np.zeros([len(imgs_info), max_boxes, 5])
    # print(boxes.shape)
    imgs = []  # filename list
    for i, img_info in enumerate(imgs_info):
        # [N,5]
        img_boxes = np.array(img_info['object']) # img_boxes.shape[N, 5]
        # overwrite the N boxes info
        boxes[i, :img_boxes.shape[0]] = img_boxes

        imgs.append(img_info['filename'])  # 檔案名

        # print(img_info['filename'], boxes[i,:5])
    # imgs: list of image path
    # boxes: [b,40,5]
    return imgs, boxes


# 測試代碼
# if __name__ == "__main__":
#     img_path = "data\\val\\image"  #圖檔路徑
#     annotation_path = "data\\val\\annotation" # 标簽路徑
#     label = ("sugarbeet", "weed")  # 自定義的标簽名字,背景不寫,預設為0
#
#     img, box = paras_annotation(img_path, annotation_path, label)
#     print(img[0])
#     print(box.shape)
#     print(box[0])
           

 paras_annotation傳回值imgs, boxes, 其中imgs是個清單,它包含了每張圖檔的路徑,boxes是一個三維矩陣,它包含了每張圖檔的所有目标位置與類别資訊,是以它的shape是[b, max_boxes, 5],b: 圖檔數量,max_boxes: 所有圖檔中最大目标數,比如圖檔A有3個目标,圖檔B有4個目标,圖檔C有10個目标,則最大目标數就是10;5: x_min, y_min, x_max, y_max, label(在xml中就是name)。

之是以有max_boxes這個參數設定,是為了将所有的标簽檔案的資訊都放在一個矩陣變量中。因為每張圖檔的目标數必然是不一樣的,如果不設定max_boxes這個參數,就無法将所有的标簽檔案資訊合在一個矩陣變量中。如果一個圖檔的目标數不夠max_boxes怎麼辦,例如圖檔A有3個目标,max_boxes是10,則假設圖檔A有10個目标,隻是将後7個目标的資料全部置為0,前三個目标的資料指派于它原本的數值,這也是開始為什麼用np.zeros()初始化boxes。

3.2 讀取圖檔

請下載下傳檔案://download.csdn.net/download/qq_37116150/12289208

該檔案包含完整代碼

我們訓練需要的是圖檔的内容資訊,不是路徑,是以我們需要通過圖檔路徑來讀取圖檔,以獲得圖檔資訊,通過3.1可以獲得所有訓練圖檔的路徑。

def preprocess(img, img_boxes):
    # img: string
    # img_boxes: [40,5]
    x = tf.io.read_file(img)
    x = tf.image.decode_png(x, channels=3)
    x = tf.image.convert_image_dtype(x, tf.float32) # 将資料轉化為 =>[0~ 1]

    return x, img_boxes
           

使用tensorflow自帶的讀取圖檔函數tf.io.read_file來讀取圖檔,不用使用for循環一個一個的讀取圖檔,然後使用tf.image.decode_png将圖檔資訊解碼出來,如果你的訓練圖檔是jpg,則使用tf.image.decode_jpeg來解碼。tf.image.convert_image_dtype(x, tf.float32)可将資料直接歸一化并将資料格式轉化為tf.float32格式。

為了更加友善訓練,我們需要建構一個tensorflow隊列,将解碼出來的圖檔資料與标簽資料一起加載進隊列中,而且通過這種方式,也可以使圖檔資料與标簽資料一一對應,不會出現圖檔與标簽對照絮亂的情況。

def get_datasets(img_dir, ann_dir,label,batch_size=1):
    imgs, boxes = paras_annotation(img_dir, ann_dir, label)
    db = tf.data.Dataset.from_tensor_slices((imgs, boxes))
    db = db.shuffle(1000).map(preprocess).batch(batch_size=batch_size).repeat()
    # db = db.map(preprocess).batch(batch_size=batch_size).repeat()
    return db
           

通過該函數也可以動态的調節訓練資料集批量。

最後就是做資料增強,由于代碼較多,就不再贅述,可下載下傳檔案觀看。

通過3.1,3.2,我們就得到了用于訓練的資料隊列,該隊列中包含圖檔資料,真實标簽資料。

4. 真實标簽格式處理

請下載下傳檔案://download.csdn.net/download/qq_37116150/12289213

該檔案包含完整代碼

4.1 單張圖檔 

 到了這一步,訓練資料預處理算是完成了一小半,後面則是更加重要的訓練資料預處理。首先,我們要明白一個問題,目标檢測和目标分類是不一樣的。目标分類的輸出是一個二維張量[batch, num_classes],目标分類的真實标簽通過熱編碼後也是一個二維張量,所有不需要多做處理,隻做一個one-hot就可以啦。而目标檢測的輸出并不是一個二維張量,比如YOLOV2輸出的就是五維張量                    [batch, 16, 16, 5, 25]。而我們的标簽shape則是[batch, max_boxes, 5],明顯真實标簽shape與網絡預測輸出shape不一緻,無法做比較,損失函數就不能完成,為了完成損失函數或者說是真實标簽與網絡預測輸出作比較,需要修改真實标簽的形狀。在修改真實标簽shape之前,需要了解YOLOV2的損失函數是由幾部分構成的。

YOLOV2損失函數包含三部分:

  1. 坐标損失: x,y,w,h
  2. 類别損失: class,根據自己的标簽設定
  3. 置信度損失: confidence, anchors與真實框的IOU

針對損失函數,需要預先準備四個變量,分别是真實标簽掩碼,五維張量的真實标簽,轉換格式的三維張量真實标簽,隻包含類别的五維張量。請看具體代碼:

def process_true_boxes(gt_boxes, anchors):
    """
    計算一張圖檔的真實标簽資訊
    :param gt_boxes:
    :param anchors:YOLO的預設框anchors
    :return:
    """
    # gt_boxes: [40,5] 一張真實标簽的位置坐标資訊
    # 512//16=32
    # 計算網絡模型從輸入到輸出的縮小比例
    scale = IMGSZ // GRIDSZ  # IMGSZ:圖檔尺寸512,GRIDSZ:輸出尺寸16
    # [5,2] 将anchors轉化為矩陣形式,一行代表一個anchors
    anchors = np.array(anchors).reshape((5, 2))

    # mask for object
    # 用來判斷該方格位置的anchors有沒有目标,每個方格有5個anchors
    detector_mask = np.zeros([GRIDSZ, GRIDSZ, 5, 1])
    # x-y-w-h-l
    # 在輸出方格的尺寸上[16, 16, 5]制作真實标簽, 用于和預測輸出值做比較,計算損失值
    matching_gt_box = np.zeros([GRIDSZ, GRIDSZ, 5, 5])
    # [40,5] x1-y1-x2-y2-l => x-y-w-h-l
    # 制作一個numpy變量,用于存儲一張圖檔真實标簽轉換格式後的資料
    # 将左上角與右下角坐标轉化為中心坐标與寬高的形式
    # [x_min, y_min, x_max, y_max] => [x_center, y_center, w, h]
    gt_boxes_grid = np.zeros(gt_boxes.shape)
    # DB: tensor => numpy 友善計算
    gt_boxes = gt_boxes.numpy()

    for i,box in enumerate(gt_boxes): # [40,5]
        # box: [5], x1-y1-x2-y2-l,逐行讀取
        # 512 => 16
        # 将左上角與右下角坐标轉化為中心坐标與寬高的形式
        # [x_min, y_min, x_max, y_max] => [x_center, y_center, w, h]
        x = ((box[0]+box[2])/2)/scale
        y = ((box[1]+box[3])/2)/scale
        w = (box[2] - box[0]) / scale
        h = (box[3] - box[1]) / scale
        # [40,5] x_center-y_center-w-h-l
        # 将第 i 行的資料賦予計算得到的新資料
        gt_boxes_grid[i] = np.array([x,y,w,h,box[4]])

        if w*h > 0: # valid box
            # 用于篩選有效資料,當w, h為0時,表明該行沒有目标,為無效的填充資料0
            # x,y: 7.3, 6.8 都是縮放後的中心坐标
            best_anchor = 0
            best_iou = 0
            for j in range(5):
                # 計算真實目标框有5個anchros的交并比,選出做好的一個anchors
                interct = np.minimum(w, anchors[j,0]) * np.minimum(h, anchors[j,1])
                union = w*h + (anchors[j,0]*anchors[j,1]) - interct
                iou = interct / union

                if iou > best_iou: # best iou 篩選最大的iou,即最好的anchors
                    best_anchor = j # 将更加優秀的anchors的索引指派與之前定義好的變量
                    best_iou = iou # 記錄最好的iou
            # found the best anchors
            if best_iou>0: #用于判斷是否有anchors與真實目标産生交并
               # 向下取整,即是将中心點坐标轉化為左上角坐标, 用于後續計算指派
               x_coord = np.floor(x).astype(np.int32)
               y_coord = np.floor(y).astype(np.int32)
               # [b,h,w,5,1]
               # 将最好的一個anchors指派1,别的anchors預設為0
               # 圖像坐标系的坐标與數組的坐标互為轉置:[x,y] => [y, x]
               detector_mask[y_coord, x_coord, best_anchor] = 1
               # [b,h,w,5,x-y-w-h-l]
               # 将最好的一個anchors指派真實标簽的資訊[x_center, y_center, w, h, label],别的anchors預設為0
               matching_gt_box[y_coord, x_coord, best_anchor] = \
                   np.array([x,y,w,h,box[4]])

    # [40,5] => [16,16,5,5]
    # matching_gt_box:[16,16,5,5],用于計算損失值
    # detector_mask:[16,16,5,1],掩碼,判斷哪個anchors有目标
    # gt_boxes_grid:[40,5],一張圖檔中目标的位置資訊,轉化後的格式
    return matching_gt_box, detector_mask, gt_boxes_grid
           

1. 在标簽檔案.xml中,目标框的記載方式是[x_min, y_min, x_max, y_max],我們需要将這種格式轉化為[x_center, y_center, w, h]這種格式,因為網絡輸出的格式就是[x_center, y_center, w, h]這種格式,而且anchors也是寬高形式。note:在後文中,x_center, y_center統一使用x,y代替,另外x,y并不是坐标,而是偏置,所有我們後續需要建構一個16x16的坐标網格,w, y則是倍率。​​​​​

x = ((box[0]+box[2])/2)/scale
y = ((box[1]+box[3])/2)/scale
w = (box[2] - box[0]) / scale
h = (box[3] - box[1]) / scale
# [40,5] x_center-y_center-w-h-l
# 将第 i 行的資料賦予計算得到的新資料
gt_boxes_grid[i] = np.array([x,y,w,h,box[4]])
           

gt_boxes_grid就是轉換格式的真實标簽,shape:[max_boxes, 5], 5:[x, y, w, h, label],該變量存儲的是一張圖檔的資訊,後續會擴充為多張圖檔。這個變量是用來計算置信度損失的,将在計算損失函數部分使用。

2. 格式轉換完成後,得到所有真實目标框的中心坐标[x, y],寬高[w, h]。網絡模型的最後輸出shape是16x16,每個網格中有5個anchors。在所有的網格中,計算每個網格中每個anchors(共5個anchors)與中心值落在該網格的目标的IOU,至于IOU如何計算,這裡就不再贅述。根據IOU的值,來判斷該網格中5個anchors哪個anchors與真實目标框比對最好。

if w*h > 0: # valid box
    # 用于篩選有效資料,當w, h為0時,表明該行沒有目标,為無效的填充資料0
    # x,y: 7.3, 6.8 都是縮放後的中心坐标
    best_anchor = 0
    best_iou = 0
    for j in range(5):
        # 計算真實目标框有5個anchros的交并比,選出做好的一個anchors
        interct = np.minimum(w, anchors[j,0]) * np.minimum(h, anchors[j,1])
        union = w*h + (anchors[j,0]*anchors[j,1]) - interct
        iou = interct / union

        if iou > best_iou: # best iou 篩選最大的iou,即最好的anchors
            best_anchor = j # 将更加優秀的anchors的索引指派與之前定義好的變量
            best_iou = iou # 記錄最好的iou
           

因為使用了max_boxes這個參數,是以gt_boxes.shape[max_boxes, 5]的内容并不全是有效資料,前面講過,一張圖檔有幾個目标,就指派幾個目标的資訊于gt_boxes, 當該圖檔的目标數不足max_boxes時,不足部分填充0。是以gt_boxes中為0的部分全是無效資料。通過 if w*h > 0 可以有效篩選掉無效資料,然後使用一個循環将5個anchors中與目标的IOU最大的一個anchors挑選出來,并記錄該anchors的索引序号與IOU。

if best_iou>0: #用于判斷是否有anchors與真實目标産生交并
    # 向下取整,即是将中心點坐标轉化為左上角坐标, 用于後續計算指派
    x_coord = np.floor(x).astype(np.int32)
    y_coord = np.floor(y).astype(np.int32)
    # [b,h,w,5,1]
    # 将最好的一個anchors指派1,别的anchors預設為0
    # 圖像坐标系的坐标與數組的坐标互為轉置:[x,y] => [y, x]
    detector_mask[y_coord, x_coord, best_anchor] = 1
    # [b,h,w,5,x-y-w-h-l]
    # 将最好的一個anchors指派真實标簽的資訊[x_center, y_center, w, h, label],别的anchors預設為0
    matching_gt_box[y_coord, x_coord, best_anchor] = np.array([x,y,w,h,box[4]])
           

因為矩陣中第一維表示行,第二維表示列,比如a[4, 3],a有4行3列;但在圖像坐标系中,橫軸是x, 縱軸是y, 這也就是說y的值是圖像的行數,x的值是圖像的列數。是以在指派中,需要将y寫在第一維,x寫在第二維,即 detector_mask[y_coord, x_coord, best_anchor] = 1。根據之前計算的IOU,可以知道與目标比對最好的anchors的索引序号,然後對該anchors賦予相對應的值。

掩碼detector_mask指派1,表示該網格的某個anchors與落在該網格的目标有很好的比對,即IOU值很大。也可以了解為該網格具有真實目标中心。

matching_gt_box則在比對最好的一個anchors上指派位置資訊與标簽,即[x, y, w, h, label],matching_gt_box這個變量就是用來與網絡預測值做比較用的。

接下來就是多張圖檔處理,這個比較簡單。

4.2 批量圖檔

在訓練過程中,訓練batch_size一般不是1,有可能為2,4, 8, 16等等,是以需要将儲存單張圖檔标簽資訊的變量合成為儲存多張圖檔的變量,使用清單,然後矩陣化即可,至于矩陣化的原因,是因為矩陣容易操作,而且tensorflow中基本都是張量。具體代碼如下:

def ground_truth_generator(db):
    """
    建構一個訓練資料集疊代器,每次疊代的數量由batch決定
    :param db:訓練集隊列,包含訓練集原圖檔資料資訊,标簽位置[x_min, y_min, x_max, y_max, label]資訊
    :return:
    """
    for imgs, imgs_boxes in db:
        # imgs: [b,512,512,3] b的值由之前定義的batch_size來決定
        # imgs_boxes: [b,40,5],不一定是40,要根據實際情況來判斷

        # 建立三個批量資料清單
        # 對應上面函數的單個圖檔資料變量
        batch_matching_gt_box = []
        batch_detector_mask = []
        batch_gt_boxes_grid = []

        # print(imgs_boxes[0,:5])

        b = imgs.shape[0] # 計算一個batch有多少張圖檔
        for i in range(b): # for each image
            matching_gt_box, detector_mask, gt_boxes_grid = \
                process_true_boxes(gt_boxes=imgs_boxes[i], anchors=ANCHORS)
            batch_matching_gt_box.append(matching_gt_box)
            batch_detector_mask.append(detector_mask)
            batch_gt_boxes_grid.append(gt_boxes_grid)
        # 将其轉化為矩陣形式并轉化為tensor,[b, 16,16,5,1]
        detector_mask = tf.cast(np.array(batch_detector_mask), dtype=tf.float32)
        # 将其轉化為矩陣形式并轉化為tensor,[b,16,16,5,5] x_center-y_center-w-h-l
        matching_gt_box = tf.cast(np.array(batch_matching_gt_box), dtype=tf.float32)
        # 将其轉化為矩陣形式并轉化為tensor,[b,40,5] x_center-y_center-w-h-l
        gt_boxes_grid = tf.cast(np.array(batch_gt_boxes_grid), dtype=tf.float32)

        # [b,16,16,5]
        # 将所有的label資訊單獨分出來,用于後續計算分類損失值
        matching_classes = tf.cast(matching_gt_box[...,4], dtype=tf.int32)
        # 将标簽進行獨熱碼編碼 [b,16,16,5,num_classes:3],
        matching_classes_oh = tf.one_hot(matching_classes, depth=num_classes)
        # 将背景标簽去除,背景為0
        # x_center-y_center-w-h-conf-l0-l1-l2 => x_center-y_center-w-h-conf-l1-l2
        # [b,16,16,5,2]
        matching_classes_oh = tf.cast(matching_classes_oh[...,1:], dtype=tf.float32)


        # [b,512,512,3]
        # [b,16,16,5,1]
        # [b,16,16,5,5]
        # [b,16,16,5,2]
        # [b,40,5]
        yield imgs, detector_mask, matching_gt_box, matching_classes_oh,gt_boxes_grid
           

不光将儲存單張圖檔标簽資訊的變量合并為儲存一個batch_size的變量,還需要建立一個類别變量,這個 變量在前面說過,是為了分類損失函數使用的,即用來分類的。

# [b,16,16,5]
# 将所有的label資訊單獨分出來,用于後續計算分類損失值
matching_classes = tf.cast(matching_gt_box[...,4], dtype=tf.int32)
# 将标簽進行獨熱碼編碼 [b,16,16,5,num_classes:3],
matching_classes_oh = tf.one_hot(matching_classes, depth=num_classes)
# 将背景标簽去除,背景為0
# x_center-y_center-w-h-conf-l0-l1-l2 => x_center-y_center-w-h-conf-l1-l2
# [b,16,16,5,2]
matching_classes_oh = tf.cast(matching_classes_oh[...,1:], dtype=tf.float32)
           

如何将類别單獨分出來,并另存為一個變量,就比較簡單,matching_gt_box的shape為[b, 16, 16, 5, 5],最後一維代表的值為真實目标的坐标(x, y, w, h)和類别(label),所有隻需要取該變量的最後一維的第5個值就可以,如上面代碼所示。得到matching_classes變量後,事情并沒有做完,因為網絡輸出shape為[b, 16, 16, 5, 7] note: 我的訓練集隻有2類,是以7表示x-y-w-h-confidece-label1-label2,不包含背景,類别數可以根據你的類别數修改。但實際類别是3類,即背景-label1-label2,雖然在網絡輸出中不包含背景,但自己需要知道在目标檢測中,背景預設為一類,這也是為什麼在xml解析這一小節中,制作标簽時,預設将标簽數加1,因為背景預設為0。

因為網絡輸出不包含背景,所有我們需要将真實标簽中的背景去除,去除的方法也比較簡單,先将matching_classes熱編碼,另存為matching_classes_oh: [b, 16, 16, 5, 3],在matching_classes_oh的最後一維中的第一個值就是背景類别,隻需要使用切片即可,如代碼所示。最後matching_classes_oh的shape為[b, 16, 16, 5, 2],在最後一維的值形式為:[1, 0]:label1, [0, 1]:label2, [0, 0]:背景,也表示該anchors沒有真實目标,這段紅字後面會詳細解釋。

到此為止,資料預處理才算完成了90%,為了後面訓練友善,将該函數的傳回值做成資料生成器,而不是簡單的return, yield可以有效的節省計算資源,而且後面也不需要再制作資料疊代器iter()啦。

最後就是資料增強,這一部分就不再贅述,比較麻煩,可以下載下傳源碼閱讀。

5. 模型搭建與權重初始化

請下載下傳檔案://download.csdn.net/download/qq_37116150/12289219

請下載下傳權重檔案:https://pan.baidu.com/s/1DZ7BLkh8JUDQ8KZbKVjP1A   提取碼:ugod

該檔案包含完整代碼

權重檔案包含預訓練所需的權重參數

5.1 模型搭建

GRIDSZ = 16 # 最終輸出尺寸
class SpaceToDepth(layers.Layer):

    def __init__(self, block_size, **kwargs):
        self.block_size = block_size
        super(SpaceToDepth, self).__init__(**kwargs)

    def call(self, inputs):
        x = inputs
        batch, height, width, depth = K.int_shape(x)
        batch = -1
        reduced_height = height // self.block_size
        reduced_width = width // self.block_size
        y = K.reshape(x, (batch, reduced_height, self.block_size,
                             reduced_width, self.block_size, depth))
        z = K.permute_dimensions(y, (0, 1, 3, 2, 4, 5))
        t = K.reshape(z, (batch, reduced_height, reduced_width, depth * self.block_size **2))
        return t

    def compute_output_shape(self, input_shape):
        shape =  (input_shape[0], input_shape[1] // self.block_size, input_shape[2] // self.block_size,
                  input_shape[3] * self.block_size **2)
        return tf.TensorShape(shape)

# input_image = layers.Input((512,512, 3), dtype='float32')
input_image = tf.keras.Input(shape=(512, 512, 3))
# unit1
# [512, 512, 3] => [512, 512, 32]
x = layers.Conv2D(32, (3,3), strides=(1,1),padding='same', name='conv_1', use_bias=False)(input_image)
x = layers.BatchNormalization(name='norm_1')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [512, 512, 32] => [256, 256, 32]
x = layers.MaxPooling2D(pool_size=(2,2))(x)
# unit2
# [256, 256, 32] => [256, 256, 64]
x = layers.Conv2D(64, (3,3), strides=(1,1), padding='same', name='conv_2',use_bias=False)(x)
x = layers.BatchNormalization(name='norm_2')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [256, 256, 64] => [128, 128, 64]
x = layers.MaxPooling2D(pool_size=(2,2))(x)
# Layer 3
# [128, 128, 64] => [128, 128, 128]
x = layers.Conv2D(128, (3,3), strides=(1,1), padding='same', name='conv_3', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_3')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 4
# [128, 128, 128] => [128, 128, 64]
x = layers.Conv2D(64, (1,1), strides=(1,1), padding='same', name='conv_4', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_4')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 5
# [128, 128, 64] => [128, 128, 128]
x = layers.Conv2D(128, (3,3), strides=(1,1), padding='same', name='conv_5', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_5')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [128, 128, 128] => [64, 64, 128]
x = layers.MaxPooling2D(pool_size=(2, 2))(x)
# Layer 6
# [64, 64, 128] => [64, 64, 256]
x = layers.Conv2D(256, (3,3), strides=(1,1), padding='same', name='conv_6', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_6')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 7
# [64, 64, 256] => [64, 64, 128]
x = layers.Conv2D(128, (1,1), strides=(1,1), padding='same', name='conv_7', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_7')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 8
# [64, 64, 128] = [64, 64, 256]
x = layers.Conv2D(256, (3,3), strides=(1,1), padding='same', name='conv_8', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_8')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# [64, 64, 256] => [32, 32, 256]
x = layers.MaxPooling2D(pool_size=(2, 2))(x)
# Layer 9
# [32, 32, 256] => [32, 32, 512]
x = layers.Conv2D(512, (3, 3), strides=(1, 1), padding='same', name='conv_9', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_9')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
# Layer 10
# [32, 32, 512] => [32, 32, 256]
x = layers.Conv2D(256, (1, 1), strides=(1, 1), padding='same', name='conv_10', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_10')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 11
# [32, 32, 256] => [32, 32, 512]
x = layers.Conv2D(512, (3, 3), strides=(1, 1), padding='same', name='conv_11', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_11')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 12
# [32, 32, 512] => [32, 32, 256]
x = layers.Conv2D(256, (1, 1), strides=(1, 1), padding='same', name='conv_12', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_12')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 13
# [32, 32, 256] => [32, 32, 512]
x = layers.Conv2D(512, (3, 3), strides=(1, 1), padding='same', name='conv_13', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_13')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# for skip connection:後續拼接操作
skip_x = x  # [b,32,32,512]
# [32, 32, 512] => [16, 16, 512]
x = layers.MaxPooling2D(pool_size=(2, 2))(x)

# Layer 14
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_14', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_14')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 15
# [16, 16, 1024] => [16, 16, 512]
x = layers.Conv2D(512, (1, 1), strides=(1, 1), padding='same', name='conv_15', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_15')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 16
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_16', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_16')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 17
# [16, 16, 1024] => [16, 16, 512]
x = layers.Conv2D(512, (1, 1), strides=(1, 1), padding='same', name='conv_17', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_17')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 18
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_18', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_18')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 19
# [16, 16, 1024] => [16, 16, 512]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_19', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_19')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 20
# [16, 16, 512] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_20', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_20')(x)
x = layers.LeakyReLU(alpha=0.1)(x)

# Layer 21
# [32, 32, 512] => [32, 32, 64]
skip_x = layers.Conv2D(64, (1, 1), strides=(1, 1), padding='same', name='conv_21', use_bias=False)(skip_x)
skip_x = layers.BatchNormalization(name='norm_21')(skip_x)
skip_x = layers.LeakyReLU(alpha=0.1)(skip_x)

# [32, 32, 64] => [16, 16, 64*2*2]
skip_x = SpaceToDepth(block_size=2)(skip_x)

# concat
# [16,16,1024], [16,16,256] => [16,16,1280]
x = tf.concat([skip_x, x], axis=-1)

# Layer 22
# [16,16,1280] => [16, 16, 1024]
x = layers.Conv2D(1024, (3, 3), strides=(1, 1), padding='same', name='conv_22', use_bias=False)(x)
x = layers.BatchNormalization(name='norm_22')(x)
x = layers.LeakyReLU(alpha=0.1)(x)
x = layers.Dropout(0.5)(x)  # add dropout
# [16,16,5,7] => [16,16,35]

# [16, 16, 1024] => [16, 16, 35]
x = layers.Conv2D(5 * 7, (1, 1), strides=(1, 1), padding='same', name='conv_23')(x)

# [16, 16, 35] => [16, 16, 5, 7]
output = layers.Reshape((GRIDSZ, GRIDSZ, 5, 7))(x)
# create model
model = tf.keras.models.Model(input_image, output)
           

網絡模型基于darknet-19改進的,輸入是[512, 512, 3], 輸出是[16, 16, 5, 7]。在網絡模型的第21層,是一個拼接操作,拼接的是13層和20層的輸出,其中13層的輸出shape:[32, 32, 512], 20層的輸出shape:[16, 16, 1024],是以需要将13層的輸出reshape成[16,16]。建立一個自定義層類,在該類中實作13層shape的改變。其實不建立自定義層類也可以實作,不知道為什麼龍龍老師有這個操作,可能會使代碼更加規範。

我寫了一個簡單的函數,也可以實作層shape改變,通過model.summary()列印出的内容,和使用自定義層列印出的内容一緻,感興趣的同學可以嘗試一哈,代碼如下:

def compute_shape(skip_x, scale):
    """
    :function 修改層shape
    :param skip_x: 要修改的層
    :param scale: 需要減少或增加的倍率
    :return: 修改後的層
    """
    print(skip_x.shape)
    skip_reshape_1 = tf.reshape(skip_x, shape=[-1, 16, 2, 16, 2, 64])
    print(skip_reshape_1.shape)
    skip_reshape_2 = tf.transpose(skip_reshape_1, perm=[0, 1, 3, 2, 4, 5])
    print(skip_reshape_2.shape)
    skip_reshape_3 = tf.reshape(skip_reshape_2, shape=[-1, 16, 16, scale * scale * 64])
    print(skip_reshape_3.shape)
    skip_x = skip_reshape_3

    return skip_x
           

5.2 權重初始化

這一部分根據你的訓練集來進行選擇是否使用,如果你是大佬,訓練集很多,那就不用finetuning啦,當然大佬也不會看我的部落格啦。使用finetuning适用于訓練集較少的情況,可以使你的網絡收斂更快。因為使用的主幹網絡是darknet-19,所有就需要使用别人訓練好的darknet-19網絡權重來進行finetuning。網絡權重檔案已經上傳至網盤,請自行下載下傳,科學上網很重要。

代碼就不寫啦,可以自行下載下傳源碼檔案,裡面包含具體的操作,需要提醒的是,倒數第二層即第23層,不使用finetuning, 而是使用正态函數随機初始化權重和偏置。至于為什麼這樣做,因為我們的檢測目标和别人的不一樣,不能所有層都進行fintuning,對于一些淺層卷積層可以finetuning。

ckpt.h5檔案是龍龍老師根據目前網絡已經訓練好的權重參數,如果不想finetuning, 可以直接加載該檔案,但是你的檢測目标和龍龍老師的檢測目标是不一樣的,是以還是需要finetuning或随機初始化。

##--------------------------------------------------------
# 預訓練好的權值,可以偷懶直接加載
# model.load_weights('./model/ckpt.h5')
##-------------------------------------------------
           

6. 損失計算

終于到這一步啦,我已經不想寫啦,累。

還是老樣子,先自行下載下傳完整代碼:

請下載下傳檔案://download.csdn.net/download/qq_37116150/12289229

該檔案包含完整代碼

目标檢測的損失函數和目标分類的損失有很大的不同,目标檢測需要輸出目标的坐标,類别,置信度,既然輸出了這三個值,那訓練的時候,也需要針對這三個參數計算損失值。

這一步其實算是整個目标檢測中最重要和複雜的一部分啦。

6.1 制作網格坐标

由于需要計算坐标損失,而且坐标損失都帶有坐标兩字啦,那就需要在訓練前制作一個坐标系,該坐标系為16x16,即x軸16,y軸16。制作坐标系的代碼如下:

x_grid = tf.tile(tf.range(GRIDSZ), [GRIDSZ])
# [1,16,16,1,1]
# [b,16,16,5,2]
x_grid = tf.reshape(x_grid, (1, GRIDSZ, GRIDSZ, 1, 1))
x_grid = tf.cast(x_grid, tf.float32)
# [1,16_1,16_2,1,1]=>[1,16_2,16_1,1,1]
y_grid = tf.transpose(x_grid, (0, 2, 1, 3, 4))
# [1,16_2,16_1,1,1] => [1, 16, 16, 1, 2]
xy_grid = tf.concat([x_grid, y_grid], axis=-1)
# [1,16,16,1,2]=> [b,16,16,5,2]
xy_grid = tf.tile(xy_grid, [y_pred.shape[0], 1, 1, 5, 1])
           

xy_grid的最後一維存儲的就是坐标值,從[0,0] -> [15, 15] 共有256對坐标值。至于為什麼要建立坐标系,是因為網絡預測輸出的x,y并不是坐标值,而是偏移量,經過激活函數後,還需要加上建立的坐标系才是真正的坐标值。比如網絡預測輸出[0, 1, 1, 0, 0:2] = (0.3, 0.4), 然後加上坐标系,那中心坐标值就是(1.3,1.4),這個值才是絕對坐标值。怕有些同學不懂這個[0, 1, 1, 0, 0:2]矩陣的含義,解釋一哈,0:第1張圖檔,索引都是從0開始;1,1:輸出的16x16網格中的第2行第2列的一個網格,0:該網格中的第一個anchors,0:2,該anchors中的x,y值。 

6.2 坐标損失計算

現在開始損失函數計算。

# [b,16,16,5,7] x-y-w-h-conf-l1-l2
# pred_xy 既不是相對位置,也不是絕對位置,是偏移量
# 通過激活函數轉化為相對位置
pred_xy = tf.sigmoid(y_pred[..., 0:2])
# 加上之前設定好的坐标,變為絕對位置
# [b,16,16,5,2]
pred_xy = pred_xy + xy_grid
# [b,16,16,5,2]
pred_wh = tf.exp(y_pred[..., 2:4])
# [b,16,16,5,2] * [5,2] => [b,16,16,5,2]
# w,h為倍率,要乘上anchors,才是寬高
pred_wh = pred_wh * anchors

# 計算真實目标框的數量,用來做平均
# 由于detector_mask的值為0和1,是以可以不用比較,直接求和即可
n_detector_mask = tf.reduce_sum(tf.cast(detector_mask > 0., tf.float32)) # 方法一
# n_detector_mask = tf.reduce_sum(detector_mask)  # 方法二
# print("真實目标框數量:",float(n_detector_mask))
# [b,16,16,5,1] * [b,16,16,5,2]
# 隻計算有object位置處的損失,沒有的就不計算,所有要乘以掩碼
xy_loss = detector_mask * tf.square(matching_gt_boxes[..., :2] - pred_xy)/(n_detector_mask + 1e-6)
xy_loss = tf.reduce_sum(xy_loss)
wh_loss = detector_mask * tf.square(tf.sqrt(matching_gt_boxes[..., 2:4]) -
                                    tf.sqrt(pred_wh)) / (n_detector_mask + 1e-6)
wh_loss = tf.reduce_sum(wh_loss)

# 1. coordinate loss
coord_loss = xy_loss + wh_loss
           
  1. 計算x,y(這裡的x,y都是中心值,後面不再贅述):預測輸出的值是個偏移量,通過激活函數sigmoid()将其轉變成0~1範圍内的相對位置,最後再與坐标系相加,就可以得到該預測值的絕對坐标。
  2. 計算w, h:預測輸出的寬高不需要經過激活函數啦,pred_wh = exp(pred_wh),exp()表示e的幾次方,不需要多做解釋,将處理過的w, h再和anchors相乘,就會得到最後的w, h。
  3. 計算真實目标數:隻計算有目标的anchors的損失值,通過之前計算的掩碼detector_mask可以判斷哪個anchors有真實目标,最後會求個平均值,所有要先将真實目标數計算出來。
  4. 計算x, y 損失值:使用均方差損失函數,這是計算的所有網格中所有anchors的損失值,由于我們隻計算有目标處的anchors的損失值,是以乘以個掩碼detector_mask,就可以得到我們所需要的損失值。
  5. 計算w, h 損失值:和求解x,y損失值一樣,隻是在YOLO原文中提到,要先将w,h的值開根号,再進行均方差計算。最後乘以掩碼,求和,就得到了w,h處的損失值
  6. 計算坐标損失值:最後将x,y損失值與w,h損失值相加求和,得到最終坐标損失值。

6.3 類别損失計算

坐标損失計算完成後,開始計算分類損失,因為我們的網絡需要分類出目标的類别,是以需要分類損失函數。

分類損失函數使用交叉熵損失函數,這個函數在邏輯回歸中有很好的效果,具體代碼如下:

# 2. class loss
# [b,16,16,5,2]
pred_box_class = y_pred[..., 5:]
# [b,16,16,5,2] => [b,16,16,5]
true_box_class = tf.argmax(matching_classes_oh, axis=-1)
# [b,16,16,5] vs [b,16,16,5,2]
# 使用sparse_categorical_crossentropy函數,可以不将标簽one_hot化
# 計算分類損失,傳回值是每個anchors的交叉熵損失值,總共有[b, 16, 16, 5]個值
class_loss = losses.sparse_categorical_crossentropy(y_true=true_box_class,
                                                    y_pred=pred_box_class,
                                                    from_logits=True)
# 使用categorical_crossentropy,需要将标簽one_hot化,
# 兩種損失函數經測試,差距不大
# class_loss = losses.categorical_crossentropy(y_true=matching_classes_oh,
#                                              y_pred=pred_box_class,
#                                              from_logits=True)
# [b,16,16,5] => [b,16,16,5,1]* [b,16,16,5,1]
# 增加一個次元進行矩陣元素相乘,傳回有目标的損失值
class_loss = tf.expand_dims(class_loss, -1) * detector_mask
# 求個平均值,即每個目标分類的損失值
class_loss = tf.reduce_sum(class_loss) / (n_detector_mask + 1e-6)
           

這個計算方法和目标分類沒有差別,就是真實目标的标簽與網絡預測目标的标簽做比較,使用的函數是交叉熵損失函數。這也是為什麼在前面一節中有個操作,将背景類别去除,因為在目标分類中就沒得背景這個類别,而且背景也無法進行訓練。

有一點需要注意的是,tf.keras.losses.sparse_categorical_crossentropy(y_true, y_pred, from_logits)與tf.keras.losses.categorical_crossentropy(y_true, y_pred, from_logits)是有一點差別的,這兩個都是交叉熵損失函數,但是前面一個的y_true的輸入值是未經過one_hot化的标簽,也就是真實标簽,比如[1, 2, 0, 4, 3, 4],這樣的标簽;後一個交叉熵損失函數的y_true是經過one_hot化的标簽,比如[[0,0,1],[1,0,0],[0,1,0]]。這兩個損失函數計算的結果是差不多的,我使用30張圖檔進行測試,它們兩個的平均損失值分别是:

  1. 平均分類損失: 0.6857998450597127
  2. 平均分類損失: 0.6649527112642925

可以看到,差别不大。

因為之前為了将背景類别去除,已經将标簽one_hot化啦,所有如果使用tf.keras.losses.sparse_categorical_crossentropy(y_true, y_pred, from_logits)損失函數,就需要将one_hot化的标簽去one_hot化,即通過tf.argmax()就可以得到真實标簽。

最後将得到的類别損失函數乘以掩碼,然後求和,和坐标損失一樣,我們同樣隻計算有目标的分類損失值。

現在談一下4.2留下的問題,matching_classes_oh[b, 16, 16, 5, 2],最後一維的值是:[1, 0], [0, 1], [0, 0], 現在問題來了,[0, 0]它表示的是啥?背景?可是我們已經将背景去除了啊,然而它就是背景,它的原型是[1, 0, 0], 将第一列全部去除後,就剩下苦逼的[0, 0]。這個标簽[0, 0]所在的anchors表示該anchors是背景,沒有目标。這個時候,掩碼就顯示出它的威力啦,因為在沒有目标的anchors處,它的值是0,然後用掩碼乘以使用交叉熵損失函數計算的損失值。這樣雖然計算了沒有目标的anchors的損失值,即将[0, 0]也參與計算啦,但是我們乘以了一個掩碼,就消除了沒有目标的anchors的損失值,使其為0,最後求和不影響損失值。

6.4 置信度計算

第三個損失函數啦,堅持就是勝利!!!

先談一談什麼是置信度,置信度就是在這個網格中的每個anchors有目标的機率,比如第2行第2列網格的第2個anchors,我們給它起個名叫小Y,在訓練中,經過網絡預測,網絡說小Y啊,你隻有30%的機率,不可信啊,這個30%機率就是這個anchors小Y的預測置信度。那小Y的真實置信度如何計算呢?對了,還需要解釋一下什麼是預測置信度,什麼是真實置信度,這個真實置信度隻會出現在訓練中,額,損失函數也是訓練中才會有的,哈哈。預測置信度是經過網絡預測的置信度,真實置信度就是真實目标标簽坐标與預測目标标簽的IOU。現在說說如何計算真實置信度,簡單,我們有真實目标的[x, y, w, h],小Y也有[x, y, w, h],隻需要計算這兩個坐标的IOU(交并比)就可以得到小Y的真實置信度,代碼如下:

def compute_iou(x1, y1, w1, h1, x2, y2, w2, h2):
    """
    :function 用于計算預測框與真實目标框的IOU
    :return:
    """
    # x1...:[b,16,16,5]
    # x,y都是中心坐标
    # 計算出左上角與右下角坐标
    xmin1 = x1 - 0.5 * w1
    xmax1 = x1 + 0.5 * w1
    ymin1 = y1 - 0.5 * h1
    ymax1 = y1 + 0.5 * h1

    xmin2 = x2 - 0.5 * w2
    xmax2 = x2 + 0.5 * w2
    ymin2 = y2 - 0.5 * h2
    ymax2 = y2 + 0.5 * h2

    # (xmin1,ymin1,xmax1,ymax1) (xmin2,ymin2,xmax2,ymax2)
    # 交集寬
    interw = np.minimum(xmax1, xmax2) - np.maximum(xmin1, xmin2)
    # 交集高
    interh = np.minimum(ymax1, ymax2) - np.maximum(ymin1, ymin2)
    # 交集
    inter = interw * interh
    # 并集
    union = w1 * h1 + w2 * h2 - inter
    # 交并比,并集加上 1e-6為防止分母為0
    iou = inter / (union + 1e-6)
    # [b,16,16,5]
    return iou
           

IOU計算還算比較簡單,就不再多做解釋,有不懂得同學,可在下方評論,哈哈,還能騙個評論。

現在知道了如何計算小Y的真實置信度,我們不能隻計算小Y同學的置信度啊,别的同學(anchors)也不開心啊,是以為了讓别的同學也開心,将所有的anchors的真實置信度都計算,魯迅說“不患寡之患不均啊”。

# 4.3 object loss
# nonobject_mask
# iou done!
# [b,16,16,5]
x1, y1, w1, h1 = matching_gt_boxes[..., 0], matching_gt_boxes[..., 1], \
                 matching_gt_boxes[..., 2], matching_gt_boxes[..., 3]
# [b,16,16,5]
x2, y2, w2, h2 = pred_xy[..., 0], pred_xy[..., 1], pred_wh[..., 0], pred_wh[..., 1]
# 計算每個真實目标框與預測框的IOU
ious = compute_iou(x1, y1, w1, h1, x2, y2, w2, h2)
# [b,16,16,5,1]
ious = tf.expand_dims(ious, axis=-1)
           

 所有anchors的預測置信度代碼如下:

# [b,16,16,5,1]
pred_conf = tf.sigmoid(y_pred[..., 4:5])
           

要經過預測置信度sigmoid()處理,使置信度值維持在0~1範圍内。

真實置信度ious需要增加一個次元,因為人家預測置信度的次元是5維,真實置信度隻是4維,是以在最後一維增加一維。

預測置信度與真實置信度都已經計算處來了,那就開始計算損失值吧,代碼如下:千說萬說,不如代碼一說

obj_loss = tf.reduce_sum(detector_mask * tf.square(ious - pred_conf)) / (n_detector_mask + 1e-6)
           

 置信度損失也是使用均方差損失函數,然後乘以掩碼,隻計算有真實目标的anchors的損失值。

寫到這裡,有目标的置信度損失值已經計算完成,下一步就是計算沒有目标的anchors的置信度損失。

之是以說置信度損失比較麻煩,是因為在置信度損失這一部分中,不僅需要計算有目标的anchors的置信度損失,還需要計算沒有真實目标的anchors的置信度損失。

沒有真實目标的anchors的置信度損失如何計算呢?它和有目标的anchors的置信度損失計算方式基本相同。

它的計算過程有點複雜,希望同學能夠耐心閱讀。

1. 預測置信度:這個不用說了,再上面就已經談論過,而且它的值,也求解出來了,就是pred_conf,額,要經過sigmoid()處理一下哈,要保持它的值維持在0~1,額,在求解有目标的anchors的置信度的過程中,已經将pred_conf求解出來了,這一步就可以省略啦。

2. IOU組合大比對:它的作用先不提,後面會說,先說說它的求解過程。這一部分也比較複雜,唉,都複雜。這一步是計算網絡輸出的位置坐标[x_min, y_min, x_max, y_max]與真實目标的位置坐标[x_min, y_min, x_max, y_max]的IOU,它們的比對可不是一一對應比對,而是每個網絡輸出的anchors與所有的真實目标anchors相比對, note: anchors與anchors相比對都是anchors中的位置坐标(x_min, y_min, x_max, y_max)比對。比如網絡預測有10個anchors,真實目标有5個,那就有50中比對可能。說這麼多,不如看代碼:

# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_xy = tf.expand_dims(pred_xy, axis=4)
# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_wh = tf.expand_dims(pred_wh, axis=4)
pred_wh_half = pred_wh / 2.
pred_xymin = pred_xy - pred_wh_half
pred_xymax = pred_xy + pred_wh_half

# [b, 40, 5] => [b, 1, 1, 1, 40, 5]
true_boxes_grid = tf.reshape(gt_boxes_grid,
                             [gt_boxes_grid.shape[0], 1, 1, 1,
                              gt_boxes_grid.shape[1],
                              gt_boxes_grid.shape[2]])
true_xy = true_boxes_grid[..., 0:2]
true_wh = true_boxes_grid[..., 2:4]
true_wh_half = true_wh / 2.
true_xymin = true_xy - true_wh_half
true_xymax = true_xy + true_wh_half
# predxymin, predxymax, true_xymin, true_xymax
# [b,16,16,5,1,2] vs [b,1,1,1,40,2]=> [b,16,16,5,40,2]
intersectxymin = tf.maximum(pred_xymin, true_xymin)
# [b,16,16,5,1,2] vs [b,1,1,1,40,2]=> [b,16,16,5,40,2]
intersectxymax = tf.minimum(pred_xymax, true_xymax)
# [b,16,16,5,40,2]
intersect_wh = tf.maximum(intersectxymax - intersectxymin, 0.)
# [b,16,16,5,40] * [b,16,16,5,40]=>[b,16,16,5,40]
# 交集
intersect_area = intersect_wh[..., 0] * intersect_wh[..., 1]
# [b,16,16,5,1]
pred_area = pred_wh[..., 0] * pred_wh[..., 1]
# [b,1,1,1,40]
true_area = true_wh[..., 0] * true_wh[..., 1]
# [b,16,16,5,1]+[b,1,1,1,40]-[b,16,16,5,40]=>[b,16,16,5,40]
# 并集
union_area = pred_area + true_area - intersect_area
# [b,16,16,5,40]
# 交并比
iou_score = intersect_area / union_area
# [b,16,16,5]
# 選出每個anchors的最大交并比
best_iou = tf.reduce_max(iou_score, axis=4)
# [b,16,16,5,1]
best_iou = tf.expand_dims(best_iou, axis=-1)
           

根據代碼來詳細解釋, pred_xy在坐标損失值計算的過程中就已經計算出來啦,先在最後一維的前一維增加1維,具體功能是為了混合大比對,pred_wh同理。将[x, y, w, h] => [x_min, y_min, x_max, y_max],這一步簡單,得到pred_xymin, pred_xymax,網絡輸出坐标格式已經轉換完成。

接下來就是處理真實目标坐标值,存儲真實目标坐标值的變量gt_boxes_grid的shape[b, 40, 5],它的shape和pred_xymin, pred_xymax不比對,就無法進行計算,現在對它變形,開始變形,通過reshape,将它的shape變形為[b, 1, 1, 1, 40, 5],pred_xymin的shape為[b, 16, 16, 5, 1, 2],然後使用和網絡輸出處理相同操作,得到true_xymin, true_xymax。

開始計算IOU啦,将pred_xymin和true_xymin相比較取大值,将pred_xymax和true_xymax相比較取小值,然後将兩者傳回的結果相減,并和0比較,傳回大于0的值。

intersect_wh = tf.maximum(intersectxymax - intersectxymin, 0.)
           

為什麼還要有個maximum()操作呢?是因為,我們将所有的預測anchors與所有的真實anchors中目标坐标想比較,計算IOU,總會有兩個目标框沒有交集的情況出現,如果它們沒有交集,計算的intersectxymax - intersectxymin的值為負,然後使用maximum()和0比較,就将這種情況篩選掉啦。保留的都是有交集的。

然後就是計算IOU啦,簡單操作,沒啥好說的。

# 選出每個anchors的最大交并比
best_iou = tf.reduce_max(iou_score, axis=4)
           

這條代碼,是為了選出每個anchors中最大的IOU交并比,因為每個anchors都會與所有的真實目标值想比對,所有每個anchors中都會有多個IOU,這麼多IOU對我們是沒有用的,我們做混合比對的目的就是選出每個anchors與所有真實目标值的最優比對。

 這裡面包含一個難點,同學如果認真閱讀,應該就能發現。那就是每個預測anchors中的坐标值如何與每個真實anchors中的坐标進行比較的,我前面提到要将pred_xy, pred_wh最後一維的前一維增加1維,gt_boxes_grid汽車人變形,就是這個作用的。

# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_xy = tf.expand_dims(pred_xy, axis=4)
# [b,16,16,5,2] => [b,16,16,5, 1, 2]
pred_wh = tf.expand_dims(pred_wh, axis=4)
           
# [b, 40, 5] => [b, 1, 1, 1, 40, 5]
true_boxes_grid = tf.reshape(gt_boxes_grid, [gt_boxes_grid.shape[0], 1, 1, 1, gt_boxes_grid.shape[1], gt_boxes_grid.shape[2]])
           

兩個不同的矩陣,在不同的次元前增加一維,然後進行互動操作,比如相加,相乘,比較大小等,就可以實作兩兩互相的比對,最後一維就是進行互動的内容。

下面是一個小程式,可以通過這個小程式來了解這個具體原理

import numpy as np
np.random.seed(50)
a = np.random.randint(low=0, high=100,size=(2,3,2) ,dtype=np.int32)
print("a: ",a)
# print(a[0, 0, :])
b = np.random.randint(low=0, high=100,size=(5,2) ,dtype=np.int32)
print("b: ",b)
print("開始一一對應比對,比對次元為第2維,第一個值為x,第二個值為y")
# a[2,3,1,2]
a = np.expand_dims(a, axis=2)
print(a.shape)
# b[1, 1, 5, 2]
b = np.reshape(b, newshape=(1,1,5,2))
print(b.shape)
intersectxymin = np.maximum(a, b)
print(intersectxymin.shape)
print("intersectxymin: ", intersectxymin)
           

3. 無目标的anchors掩碼:在計算有目标的anchors的置信度的過程中,用到了掩碼detector_mask, 隻是這個掩碼是有真實目标的掩碼,即有目标為1,無目标為0。現在需要求解無目标的掩碼nonobj_mask,它的含義是有目标的anchors為0,無目标的anchors為1。有同學可能又會說,部落客,這個好求解,用nonobj_mask = 1 - detector_mask就可以了撒,得到的結果就是沒有目标的掩碼,想想也對撒,此時的nonobj_mask的值含義就是有目标的anchors為0, 無目标的anchors為1。同學你誤我啊,這是不對滴,因為這是基于真實标簽制作的掩碼,計算出來的結果都是基于我們打标注的真實标簽,不會出現誤差。要多考慮一哈,我們現在處于訓練階段,處于計算損失函數這一階段,要向網絡預測值靠,這樣才能通過減小損失,提升網絡檢測精度。上一小節IOU組合大比對計算出了best_iou, 這個值其實也是機率,它的shape為[b, 16, 16, 5],通過這個shape我們就可以明白它是輸出的16x16網格中每個anchors的IOU值,然後将這個IOU與門檻值(自己設定,根據實際情況,我設為0.6)相比較,小于門檻值的,我們都認為該anchors沒有目标,具體代碼如下:

# [b,16,16,5,1]
best_iou = tf.expand_dims(best_iou, axis=-1)
# 設定當IOU小于0.6時,就認為沒有目标
nonobj_detection = tf.cast(best_iou < 0.6, tf.float32)
           

有同學可能又會問,唉,同學你咋這麼多問題呢?這位同學問啥呢?他問部落客best_iou雖然可以了解成機率值或置信度,可是每個anchors,網絡不都會預測一個置信度嗎,比如pre_conf。我們要明白兩個問題,1. 我們處于訓練階段,YOLO又是有監督學習,損失函數如果沒有真實标簽資料參與,就無法有效減小損失函數,快速收斂網絡;2. 我們之前計算的IOU都是網絡預測網格與真實網格一一對應計算的,萬一哪個anchors出軌了咋辦?它和隔壁老王家的anchors中的真實目标有更好的IOU。正是基于這種情況,YOLO作者才會想到,讓它們來個混合大比對,所有的anchors都進行比對計算一次,選出最好的一個,如果這樣你的IOU還比門檻值小,說明你是真沒有目标。

到這一步,所有的工作基本都完成啦,還差最後一個小操作,就是将一些網絡預測錯的網格anchors篩選掉:

# 計算預測框沒有目标的掩碼
nonobj_mask = nonobj_detection * (1 - detector_mask)
           

這條代碼的含義,舉個例子,應該就曉得啦。 

咱還拿小Y(小Y是誰?參照本節開頭)來說,小Y說我是沒有目标的,噓,别告訴它,是網絡騙它的,用網絡預測小Y的位置坐标與所有的真實目标坐标做比對,計算IOU,計算的最大IOU是0.2(大于0.6就認為有目标),可是在真實的對應網格anchors中,是有目标的。這樣就會産生一個問題,小Y到底有沒有目标呢?網絡說你沒有,實際的情況确是有的,我們實事求是,既然人家小Y有目标,那我們就不能說人家沒得,通過乘以(1-detector_mask)就可以解決這種問題。下面舉個例子,希望同學能夠更加了解,畢竟這個概念有點難了解。

小Y沉冤得雪史

小Q 小Y 隔壁老王 anchors3 anchors4
真實值 1
best_iou 0.8 0.2 0.32 0.4 0.11
nonobj_detection 1 1 1 1
*(1-detector_mask) 1 1 1

通過上面的表格,我想大家應該都明白了1-detector_mask的作用啦。

4. 計算無目标的數量:就是将沒有目标的anchors數量統計一哈,比較容易了解

# nonobj counter
n_nonobj = tf.reduce_sum(tf.cast(nonobj_mask > 0., tf.float32))
           

5. 計算無目标位置處的損失值:最後的美人終于出來了,因為要計算無目标位置處的損失值,那就說明在真實标簽中,該位置沒有目标,那應該如何計算它的損失值呢,在前面提到過,網絡輸出值中含有置信度,我們使用這個置信度即可。因為計算的是無目标處的損失值,無目标一旦出現目标,說明就是預測錯誤,是以該置信度越小越好,當然最後要乘以一個無目标掩碼,之前計算過的,然後求和,求平均值。

nonobj_loss = tf.reduce_sum(nonobj_mask * tf.square(-pred_conf)) / (n_nonobj + 1e-6)
           

 通過看小Y沉冤得雪史的表格,可以曉得,小Q的值是錯誤的,這就是網絡的預測誤差,通過上面的nonobj_loss損失函數再加上網絡反向傳播,可使得小Q的值糾正過來,在糾正過程中,網絡也會變得更加收斂。雖然pred_conf隻是網絡預測置信度,但是nonobj_mask有真實參數參入,真實标簽會監督網絡,使損失值越來越小,無目标處的pred_conf越來越小。

到此,所有的損失值已經計算完成,工作到這裡基本已經完成啦,額,還有一個,就是我們追求的是網絡檢測精度,是以,要給有目标的置信度損失權重加大,代碼如下:

loss = coord_loss + class_loss + nonobj_loss + 5 * obj_loss
           

這個loss,就是最終的損失值啦,損失函數到此是真正的建構完成啦。 

7. 模型訓練與儲存

這一步沒有多大難度,就是一些參數調節問題

def train(epoches,train_gen,model):
    optimizer = tf.keras.optimizers.Adam(learning_rate=1e-4, beta_1=0.9,
        beta_2=0.999,epsilon=1e-08)

    for epoch in range(epoches):

        for step in range(30):
            img, detector_mask, matching_true_boxes, matching_classes_oh, true_boxes = next(train_gen)
            with tf.GradientTape() as tape:
                y_pred = model(img, training=True)
                loss, sub_loss = yolo_loss(detector_mask, matching_true_boxes,
                                           matching_classes_oh, true_boxes, y_pred)
            grads = tape.gradient(loss, model.trainable_variables)
            optimizer.apply_gradients(zip(grads, model.trainable_variables))

            print(epoch, step, float(loss), float(sub_loss[0]), float(sub_loss[1]), float(sub_loss[2]))
    # 儲存權重
    model.save_weights('model/YOLO_epoch10.ckpt')
           

8. 模型驗證

最後就是用驗證資料集驗證哈我們訓練的網絡檢測效果如何,代碼如下:

def visualize_result(img_path, model):
    """
    用于結果可視化
    :param img:
    :param model:
    :return:
    """
    model.load_weights("./model/YOLO_epoch10.ckpt")
    # [512,512,3] 0~255, BGR
    img = cv2.imread(img_path)
    img = img[...,::-1]/255.
    img = tf.cast(img, dtype=tf.float32)
    # [1,512,512,3]
    img = tf.expand_dims(img, axis=0)
    # [1,16,16,5,7]
    y_pred = model(img, training=False)

    x_grid = tf.tile(tf.range(GRIDSZ), [GRIDSZ])
    # [1, 16,16,1,1]
    x_grid = tf.reshape(x_grid, (1, GRIDSZ, GRIDSZ, 1, 1))
    x_grid = tf.cast(x_grid, dtype=tf.float32)
    y_grid = tf.transpose(x_grid, (0,2,1,3,4))
    xy_grid = tf.concat([x_grid,y_grid], axis=-1)
    # [1, 16, 16, 5, 2]
    xy_grid = tf.tile(xy_grid, [1, 1, 1, 5, 1])

    anchors = np.array(ANCHORS).reshape(5,2)
    pred_xy = tf.sigmoid(y_pred[...,0:2])
    pred_xy = pred_xy + xy_grid
    # normalize 0~1
    pred_xy = pred_xy / tf.constant([16.,16.])

    pred_wh = tf.exp(y_pred[...,2:4])
    pred_wh = pred_wh * anchors
    pred_wh = pred_wh / tf.constant([16.,16.])

    # [1,16,16,5,1]
    pred_conf = tf.sigmoid(y_pred[...,4:5])
    # l1 l2
    pred_prob = tf.nn.softmax(y_pred[...,5:])

    pred_xy, pred_wh, pred_conf, pred_prob = \
        pred_xy[0], pred_wh[0], pred_conf[0], pred_prob[0]

    boxes_xymin = pred_xy - 0.5 * pred_wh
    boxes_xymax = pred_xy + 0.5 * pred_wh
    # [16,16,5,2+2]
    boxes = tf.concat((boxes_xymin, boxes_xymax),axis=-1)
    # [16,16,5,2]
    box_score = pred_conf * pred_prob
    # [16,16,5]
    box_class = tf.argmax(box_score, axis=-1)
    # [16,16,5]
    box_class_score = tf.reduce_max(box_score, axis=-1)
    # [16,16,5]
    pred_mask = box_class_score > 0.45
    # [16,16,5,4]=> [N,4]
    boxes = tf.boolean_mask(boxes, pred_mask)
    # [16,16,5] => [N]
    scores = tf.boolean_mask(box_class_score, pred_mask)
    # 【16,16,5】=> [N]
    classes = tf.boolean_mask(box_class, pred_mask)

    boxes = boxes * 512.
    # [N] => [n]
    select_idx = tf.image.non_max_suppression(boxes, scores, 40, iou_threshold=0.3)
    boxes = tf.gather(boxes, select_idx)
    scores = tf.gather(scores, select_idx)
    classes = tf.gather(classes, select_idx)

    # plot
    fig, ax = plt.subplots(1, figsize=(10,10))
    ax.imshow(img[0])
    n_boxes = boxes.shape[0]
    ax.set_title('boxes:%d'%n_boxes)
    for i in range(n_boxes):
        x1,y1,x2,y2 = boxes[i]
        w = x2 - x1
        h = y2 - y1
        label = classes[i].numpy()

        if label==0: # sugarweet
            color = (0,1,0)
        else:
            color = (1,0,0)

        rect = patches.Rectangle((x1.numpy(), y1.numpy()), w.numpy(), h.numpy(), linewidth = 3, edgecolor=color,facecolor='none')
        ax.add_patch(rect)
    plt.show()
           

到這裡,整個YOLOV2算是真正完成啦,這篇部落格也算是我最認真寫的吧,花了3天的時間,也許有些部分過于啰嗦,也請見諒,有些部分可能也沒有講清楚,歡迎在評論區評論。

最後就是anchors的計算,它是通過K-means聚類計算出來的,我後續可能會寫篇部落格介紹如何計算anchors的吧。在本文中的anchors是imagenet官方通過大量圖檔計算出來的,還算挺好的。

算了,就說這些吧

繼續閱讀