目錄
運行環境:
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損失函數包含三部分:
- 座標損失: x,y,w,h
-
類別損失: class,根據自己的標籤設定
-
置信度損失: 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
- 計算x,y(這裏的x,y都是中心值,後面不再贅述):預測輸出的值是個偏移量,通過激活函數sigmoid()將其轉變成0~1範圍內的相對位置,最後再與座標系相加,就可以得到該預測值的絕對座標。
- 計算w, h:預測輸出的寬高不需要經過激活函數啦,pred_wh = exp(pred_wh),exp()表示e的幾次方,不需要多做解釋,將處理過的w, h再和anchors相乘,就會得到最後的w, h。
- 計算真實目標數:只計算有目標的anchors的損失值,通過之前計算的掩碼detector_mask可以判斷哪個anchors有真實目標,最後會求個平均值,所有要先將真實目標數計算出來。
- 計算x, y 損失值:使用均方差損失函數,這是計算的所有網格中所有anchors的損失值,由於我們只計算有目標處的anchors的損失值,所以乘以個掩碼detector_mask,就可以得到我們所需要的損失值。
- 計算w, h 損失值:和求解x,y損失值一樣,只是在YOLO原文中提到,要先將w,h的值開根號,再進行均方差計算。最後乘以掩碼,求和,就得到了w,h處的損失值
- 計算座標損失值:最後將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張圖片進行測試,它們兩個的平均損失值分別是:
- 平均分類損失: 0.6857998450597127
- 平均分類損失: 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)就可以解決這種問題。下面舉個例子,希望同學能夠更加理解,畢竟這個概念有點難理解。
小Q | 小Y | 隔壁老王 | anchors3 | anchors4 | |
---|---|---|---|---|---|
真實值 | 0 | 1 | 0 | 0 | 0 |
best_iou | 0.8 | 0.2 | 0.32 | 0.4 | 0.11 |
nonobj_detection | 0 | 1 | 1 | 1 | 1 |
*(1-detector_mask) | 0 | 0 | 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官方通過大量圖片計算出來的,還算挺好的。
算了,就說這些吧