天天看點

深度學習從入門到精通——MTCNN人臉偵測算法MTCNN

這裡寫目錄标題

      • 先看效果
  • MTCNN
      • 主體思想
        • 級聯網絡
        • 圖像金字塔
        • IOU算法
          • iou 公式
        • nms 算法
        • 資料生成celeba
      • 資料代碼
      • 訓練代碼
      • 偵測代碼
      • 總結

先看效果

深度學習從入門到精通——MTCNN人臉偵測算法MTCNN
深度學習從入門到精通——MTCNN人臉偵測算法MTCNN
深度學習從入門到精通——MTCNN人臉偵測算法MTCNN
深度學習從入門到精通——MTCNN人臉偵測算法MTCNN

MTCNN

從2016年,MTCNN算法出來之後,屬實在工業上火了一把,最近嘗試着把論文代碼複現了一下。

主體思想

級聯網絡

**

深度學習從入門到精通——MTCNN人臉偵測算法MTCNN

這篇論文屬于一篇多任務級聯卷積神經網絡,如圖,利用P、R、O 三個網絡來進行檢測。

算法步驟:

  1. 将傳入p網絡中,預選一些框,生成特征圖
  2. 将p網絡中的預選框傳入R網絡中進行進一步篩選
  3. 将R網絡中的預選框傳入O網絡中進行進一步篩選
  4. O網絡進行最終判定,輸出人臉框。
    深度學習從入門到精通——MTCNN人臉偵測算法MTCNN

上述步驟在圖像金字塔的總架構下實作,為實作不同尺度下的人臉偵測,采用圖像金字塔算法,将不同尺度的人臉傳入網絡中,雖然這樣有可能讓模型準确率更高,但是這樣也導緻模型運算的次數增加。

P網絡:

由于P網絡未知一張圖檔含有多少人臉,是以使用全卷積網絡接收任意大小的圖檔,将通道數作為輸出特征數,使用圖像金字塔縮放圖檔(最短邊長大于12),并設卷積核步長為1不放過任何一個可能性。

采用12x12的大核決定網絡偵測到的最小人臉。以固定大小框切取同樣大小圖檔(輸出固定)。

使用卷積網絡實作切割12x12圖檔,通過多層小卷積核神經網絡的特征提取等價于大核單層神經網絡(感受野相同),抽象能力更強,特征提取更精細,參數更少,網絡運作更快。使用偏移量的大小作為訓練損失,使得網絡計算更友善;。

對不同任務設定不同的損失函數,增強學習的針對性。增加人臉五官特征任務,增加網絡學習任務的複雜性,清晰網絡學習的方向。每增加一個損失,會提升神經網絡特征提取的能力,促進網絡模型優化。

R網絡:

通過多層小卷積核神經網絡的特征提取等價于大核單層神經網絡(感受野相同)。

由于輸入為24 x 24固定大小的圖檔,輸出則可使用全連接配接提取特征(在此處使用卷積核提取特征相同)。

O網絡:

通過多層小卷積核神經網絡的特征提取等價于大核單層神經網絡(感受野相同)。

由于輸入為48 x 28固定大小的圖檔,輸出則可使用全連接配接提取特征(在此處使用卷積核提取特征相同)。

圖像金字塔

一張圖檔上會存在着多個目标,且目标的大小不一樣,模型對于一些過小或者過大的目标都無法檢測,需要強調一些多尺度資訊。MTCNN中對于圖像的縮放因子為0.703左右。
           

另外不得不提的兩個算法,IOU 和NMS.

IOU算法

MTCNN制作資料集樣本要求:

IOU範圍 樣本類型 标簽

0 ~ 0.3 非人臉 置信度為0(不參與計算偏移量損失)

0.3 ~ 0.4 地标(緩沖區,防止誤判)

0.4 ~ 0.65 Part人臉 置信度為2(不參與計算置信度損失)

0.65 ~ 1 人臉 置信度為1

IoU是一種測量在特定資料集中檢測相應物體準确度的一個标準。IoU是一個簡單的測量标準,隻要是在輸出中得出一個預測範圍(bounding boxex)的任務都可以用IoU來進行測量。為了可以使IoU用于測量任意大小形狀的物體檢測,我們需要:人為在訓練集圖像中标出要檢測物體的大概範圍。也就是先驗框的說法。

也就是說,這個标準用于測量真實和預測之間的相關度,相關度越高,該值越高。

在FasterRcnn也提出,相近的框近似于線性變換關系,利用于此,學習先驗框與實際框之間的映射關系,比直接學習目标的框更為容易。

iou 公式
深度學習從入門到精通——MTCNN人臉偵測算法MTCNN
深度學習從入門到精通——MTCNN人臉偵測算法MTCNN

反應兩個框之間的交并比關系,IOU越大,代表重合程度更大.

def iou(box,boxes,isMin=False):

    box_area = (box[3]-box[1])*(box[2]-box[0])
    boxes_area = (boxes[:,3]-boxes[:,1])*(boxes[:,2]-boxes[:,0])


    xx1 = np.maximum(box[0],boxes[:,0])
    yy1 = np.maximum(box[1],boxes[:,1])
    xx2 = np.minimum(box[2],boxes[:,2])
    yy2 = np.minimum(box[3],boxes[:,3])

    w = np.maximum(0, (xx2-xx1))
    h = np.maximum(0,(yy2-yy1))

    area =w*h

    if isMin:
        return np.true_divide(area,np.minimum(box_area,boxes_area))
    else:
        return np.true_divide(area,box_area+boxes_area-area)
           

nms 算法

深度學習從入門到精通——MTCNN人臉偵測算法MTCNN

-直接問題就是為了解決多個輸出框的問題

  • 選取最大置信度的框
  • 剩餘的框與最大置信度框進行iou 交并比計算
  • 把iou大于某個門檻值的框進行過濾,這樣可以去除掉大量的重複框

    代碼實作如下:

def nms(boxes, thresh=0.3, isMin = False):
    #框的長度為0時(防止程式有缺陷報錯)
    if boxes.shape[0] == 0:
        return np.array([])

    #框的長度不為0時
    #根據置信度排序:[x1,y1,x2,y2,C]
    _boxes = boxes[(-boxes[:, 4]).argsort()] # #根據置信度“由大到小”,預設有小到大(加符号可反向排序)
    #建立空清單,存放保留剩餘的框
    r_boxes = []
    # 用1st個框,與其餘的框進行比較,當長度小于等于1時停止(比len(_boxes)-1次)
    while _boxes.shape[0] > 1: #shape[0]等價于shape(0),代表0軸上框的個數(維數)
        #取出第1個框
        a_box = _boxes[0]
        #取出剩餘的框
        b_boxes = _boxes[1:]

        #将1st個框加入清單
        r_boxes.append(a_box) ##每循環一次往,添加一個框
        # print(iou(a_box, b_boxes))

        #比較IOU,将符合門檻值條件的的框保留下來
        index = np.where(iou(a_box, b_boxes,isMin) < thresh) #将門檻值小于0.3的建議框保留下來,傳回保留框的索引
        _boxes = b_boxes[index] #循環控制條件;取出門檻值小于0.3的建議框

    if _boxes.shape[0] > 0: ##最後一次,結果隻用1st個符合或隻有一個符合,若框的個數大于1;★此處_boxes調用的是whilex循環裡的,此判斷條件放在循環裡和外都可以(隻有在函數類外才可産生局部作用于)
        r_boxes.append(_boxes[0]) #将此框添加到清單中
    #stack組裝為矩陣::将清單中的資料在0軸上堆疊(行方向)
    return np.stack(r_boxes)
           

上述講完,基本知識已經完畢,剩下實操代碼:

網絡模型代碼實作:

這裡我實作了修改:

  1. 采用了BN,友善訓練模型更快的收斂。
  2. 将每個模型中的最大池化全部轉化為卷積步長=2的方式(資料量基本足夠,是以不用擔心過拟合,但是這樣會導緻計算量的增大),改了效果确實好了不少。
from torch import nn
import torch

class PNet(nn.Module):
    def __init__(self):
        super(PNet,self).__init__()
        self.name = "pNet"
        self.pre_layer = nn.Sequential(
            nn.Conv2d(in_channels=3,out_channels=10,kernel_size=(3,3),stride=(1,1),padding=(1,1)), # conv1
            nn.PReLU(),
            # prelu1
            nn.Conv2d(in_channels=10,out_channels=10,kernel_size=(3,3),stride=(2,2)),    
            nn.Conv2d(10,16,kernel_size=(3,3),stride=(1,1)), # conv2
            nn.PReLU(),                              # prelu2
            nn.Conv2d(16,32,kernel_size=(3,3),stride=(1,1)), # conv3
            nn.PReLU()                               # prelu3
        )

        self.conv4_1 = nn.Conv2d(32,1,kernel_size=(1,1),stride=(1,1))
        self.conv4_2 = nn.Conv2d(32,4,kernel_size=(1,1),stride=(1,1))



    def forward(self, x):
        x = self.pre_layer(x)
        cond = torch.sigmoid(self.conv4_1(x)) # 置信度用sigmoid激活(用BCEloos時先要用sigmoid激活)
        offset = self.conv4_2(x)         # 偏移量不需要激活,原樣輸出
        return cond,offset

           

R網絡

# R網路
class RNet(nn.Module):
    def __init__(self):
        super(RNet,self).__init__()
        self.name = "RNet"
        self.pre_layer = nn.Sequential(
            nn.Conv2d(in_channels=3, out_channels=28, kernel_size=(3,3),stride=(1,1),padding=(1,1)), # conv1
            nn.BatchNorm2d(28),
            nn.PReLU(),                                                                  # prelu1
            nn.Conv2d(in_channels=28, out_channels=28, kernel_size=(3, 3), stride=(2, 2)), 
            nn.BatchNorm2d(28),
            nn.PReLU(),
            # pool1
            nn.Conv2d(28, 48, kernel_size=(3,3),stride=(1,1)),  # conv2
            nn.BatchNorm2d(48),
            nn.PReLU(),                                  # prelu2
            nn.Conv2d(in_channels=48, out_channels=48, kernel_size=(3, 3), stride=(2, 2)), 
            nn.BatchNorm2d(48),
            nn.PReLU(),
            nn.Conv2d(48, 64, kernel_size=(2,2), stride=(1,1)),          # conv3
            nn.BatchNorm2d(64),
            nn.PReLU()                                           # prelu3
        )
        self.conv4 = nn.Linear(64*3*3,128) # conv4
        self.prelu4 = nn.PReLU()           # prelu4
        #detetion
        self.conv5_1 = nn.Linear(128,1)
        #bounding box regression
        self.conv5_2 = nn.Linear(128, 4)

    def forward(self, x):
        #backend
        x = self.pre_layer(x)
        x = x.view(x.size(0),-1)
        x = self.conv4(x)
        x = self.prelu4(x)
        #detection
        label = torch.sigmoid(self.conv5_1(x)) # 置信度
        offset = self.conv5_2(x) # 偏移量
        return label,offset


           

資料轉化代碼:這裡采用的是論文中的資料celeba 和widerface

先把celeba的資料中的box和landmarks集中一起

landmarks_path = r"F:\CelebA\Anno\list_landmarks_align_celeba.txt"
    bbox_path = r"F:\CelebA\Anno\list_bbox_celeba.txt"

    save_path = "anno.txt"
    with open(landmarks_path,"r") as f:
        landmarks = f.readlines()
    with open(bbox_path,"r") as f:
        bbox = f.readlines()
    with open(save_path,"w") as f:
        for i,(line1,line2) in enumerate(zip(bbox,landmarks)):
            if i<1:
                f.write(line1)
            elif i==1:
                strs = line1.strip()+" "+ line2
                f.write(strs)
                # f.write(line2)
            else:
                strs = line1.strip().split()+line2.strip().split()[1:]
                strs = " ".join(strs)+"\n"
                f.write(strs)

           

O網路的實作:

# O網路
class ONet(nn.Module):
    def __init__(self):
        super(ONet,self).__init__()
        self.name = "oNet"
        # backend
        self.pre_layer = nn.Sequential(
            nn.Conv2d(3, 32, kernel_size=(3,3), stride=(1,1),padding=(1,1)),  # conv1
            nn.BatchNorm2d(32),
            nn.PReLU(),                                          # prelu1
            nn.Conv2d(in_channels=32, out_channels=32, kernel_size=(3, 3), stride=(2, 2)),  
            nn.BatchNorm2d(32),
            nn.PReLU(),
            nn.Conv2d(32, 64,kernel_size=(3,3), stride=(1,1)),  # conv2
            nn.BatchNorm2d(64),
            nn.PReLU(),  # prelu2
            nn.Conv2d(in_channels=64, out_channels=64, kernel_size=(3, 3), stride=(2, 2)),  
            nn.BatchNorm2d(64),
            nn.PReLU(),  # prelu2
            nn.Conv2d(64, 64, kernel_size=(3,3), stride=(1,1)),        # conv3
            nn.BatchNorm2d(64),
            nn.PReLU(),  # prelu3
            nn.Conv2d(in_channels=64, out_channels=64, kernel_size=(2, 2), stride=(2, 2)),  
            nn.BatchNorm2d(64),
            nn.PReLU(),  # prelu3
            nn.Conv2d(64, 128,kernel_size=(2,2), stride=(1,1)),  # conv4
            nn.PReLU(),  # prelu4
        )
        self.conv5 = nn.Linear(128 * 3 * 3, 256)  # conv5
        self.prelu5 = nn.PReLU()                 # prelu5
        # detection
        self.conv6_1 = nn.Linear(256, 1)
        # bounding box regression
        self.conv6_2 = nn.Linear(256, 4)

    def forward(self, x):
        # backend
        x = self.pre_layer(x)
        x = x.reshape(x.size(0), -1)
        x = self.conv5(x)
        x = self.prelu5(x)
        # detection
        label = torch.sigmoid(self.conv6_1(x)) # 置信度
        offset = self.conv6_2(x)          # 偏移量

        return label, offset


if __name__ == '__main__':

    x = torch.randn(2,3,12,12)
    x2 = torch.randn(2,3,24,24)
    x3 = torch.randn(2,3,48,48)
    model1 = PNet()
    print(model1(x)[0].shape)
    print(model1(x)[1].shape)
    model2 = RNet()
    print(model2(x2)[0].shape)
    print(model2(x2)[1].shape)
    model3 = ONet()
    print(model3(x3)[0].shape)
    print(model3(x3)[1].shape)
    print(model1)
    print(model2)
    print(model3)
    print(list(model1.pre_layer[0].weight))

           

資料生成celeba

import os
import traceback

import numpy as np
from PIL import Image, ImageDraw

from tools import utils


class GenerateData():

    def __init__(self, anno_src=r"anno.txt",
                 imgs_path=r"F:\CelebA\Img\img_celeba\img_celeba",
                 save_path="D:\DataSet"
                 ):

        self.anno_src = anno_src
        self.imgs_path = imgs_path
        self.save_path = save_path

        if not os.path.exists(self.save_path):
            os.makedirs(self.save_path)

    def run(self, size=12):
        print("gen %i image" % size)  # %i:十進制數占位符
        for face_size in [size]:
            # “樣本圖檔”存儲路徑--image
            positive_image_dir = os.path.join(self.save_path, str(face_size), "positive")  # 三級檔案路徑
            negative_image_dir = os.path.join(self.save_path, str(face_size), "negative")
            part_image_dir = os.path.join(self.save_path, str(face_size), "part")

            print(positive_image_dir, negative_image_dir, part_image_dir)
            for dir_path in [positive_image_dir, negative_image_dir, part_image_dir]:
                if not os.path.exists(dir_path):  # 如果檔案不存在則建立檔案路徑
                    os.makedirs(dir_path)

            # “樣本标簽”存儲路徑--text
            positive_anno_filename = os.path.join(self.save_path, str(face_size), "positive.txt")  # 建立正樣本txt檔案
            negative_anno_filename = os.path.join(self.save_path, str(face_size), "negative.txt")
            part_anno_filename = os.path.join(self.save_path, str(face_size), "part.txt")

            # 計數初始值:給檔案命名
            positive_count = 0  # 計數器初始值
            negative_count = 0
            part_count = 0
            # 凡是檔案操作,最好try一下,防止程式出錯奔潰
            try:
                positive_anno_file = open(positive_anno_filename, "w")  # 以寫入的模式打開txt文檔
                negative_anno_file = open(negative_anno_filename, "w")
                part_anno_file = open(part_anno_filename, "w")
                for i, line in enumerate(open(self.anno_src)):  # 枚舉出所有資訊
                    if i < 2:
                        continue  # i小于2時繼續讀檔案readlines
                    # print(i,line)
                    try:
                        # print(line)
                        strs = line.strip().split(" ")  # strip删除兩邊的空格
                        # print(strs)
                        # print(strs)
                        image_filename = strs[0].strip()
                        # print(image_filename)
                        image_file = os.path.join(self.imgs_path, image_filename)  # 建立檔案絕對路徑

                        with Image.open(image_file) as img:


                            img_w, img_h = img.size
                            x1 = float(strs[1].strip())  # 取2nd個值去除兩邊的空格,再轉車float型
                            y1 = float(strs[2].strip())
                            w = float(strs[3].strip())
                            h = float(strs[4].strip())
                            x2 = float(x1 + w)
                            y2 = float(y1 + h)
                            px1 = float(strs[5].strip())  # 人的五官
                            py1 = float(strs[6].strip())
                            px2 = float(strs[7].strip())
                            py2 = float(strs[8].strip())
                            px3 = float(strs[9].strip())
                            py3 = float(strs[10].strip())
                            px4 = float(strs[11].strip())
                            py4 = float(strs[12].strip())
                            px5 = float(strs[13].strip())
                            py5 = float(strs[14].strip())

                            # 過濾字段,去除不符合條件的坐标
                            if max(w, h) < 40 or x1 < 0 or y1 < 0 or w < 0 or h < 0:
                                continue
                            # 标注不太标準:給人臉框與适當的偏移★
                            x1 = int(x1 + w * 0.12)  # 原來的坐标給與适當的偏移:偏移人臉框的0.15倍
                            y1 = int(y1 + h * 0.1)
                            x2 = int(x1 + w * 0.9)
                            y2 = int(y1 + h * 0.85)
                            w = int(x2 - x1)  # 偏移後框的實際寬度
                            h = int(y2 - y1)
                            boxes = [[x1, y1, x2, y2]]  # 左上角和右下角四個坐标點;二維的框有批次概念

                            # draw = ImageDraw.Draw(img)
                            # draw.rectangle(boxes[0],)
                            # img.show()

                            # 計算出人臉中心點位置:框的中心位置
                            cx = x1 + w / 2
                            cy = y1 + h / 2
                            # 使正樣本和部分樣本數量翻倍以圖檔中心點随機偏移
                            for _ in range(2):  # 每個循環5次,畫五個框框、摳出來
                                # 讓人臉中心點有少許的偏移
                                w_ = np.random.randint(-w * 0.1, w * 0.1)  # 框的橫向偏移範圍:向左、向右移動了20%
                                h_ = np.random.randint(-h * 0.1, h * 0.1)
                                cx_ = cx + w_
                                cy_ = cy + h_

                                # 讓人臉形成正方形(12*12,24*24,48*48),并且讓坐标也有少許的偏離
                                side_len = np.random.randint(int(min(w, h) * 0.8), np.ceil(1.25 * max(w, h)))
                                # 邊長偏移的随機數的範圍;ceil大于等于該值的最小整數(向上取整);原0.8


                                x1_ = np.max(cx_ - side_len / 2, 0)  # 坐标點随機偏移
                                y1_ = np.max(cy_ - side_len / 2, 0)
                                x2_ = x1_ + side_len
                                y2_ = y1_ + side_len

                                crop_box = np.array([x1_, y1_, x2_, y2_])  # 偏移後的新框
                                # draw.rectangle(list(crop_box))


                                # 計算坐标的偏移值
                                offset_x1 = (x1 - x1_) / side_len  # 偏移量△δ=(x1-x1_)/side_len;新框的寬度;
                                offset_y1 = (y1 - y1_) / side_len
                                offset_x2 = (x2 - x2_) / side_len
                                offset_y2 = (y2 - y2_) / side_len

                                offset_px1 = (px1 - x1_) / side_len  # 人的五官特征的偏移值
                                offset_py1 = (py1 - y1_) / side_len
                                offset_px2 = (px2 - x1_) / side_len
                                offset_py2 = (py2 - y1_) / side_len
                                offset_px3 = (px3 - x1_) / side_len
                                offset_py3 = (py3 - y1_) / side_len
                                offset_px4 = (px4 - x1_) / side_len
                                offset_py4 = (py4 - y1_) / side_len
                                offset_px5 = (px5 - x1_) / side_len
                                offset_py5 = (py5 - y1_) / side_len

                                # 剪切下圖檔,并進行大小縮放
                                face_crop = img.crop(crop_box)  # “摳圖”,crop剪下框出的圖像
                                face_resize = face_crop.resize((face_size, face_size),
                                                               Image.ANTIALIAS)  # ★按照人臉尺寸(“像素矩陣大小”)進行縮放:12/24/48;坐标沒放縮

                                iou = utils.iou(crop_box, np.array(boxes))[0]  # 摳出來的框和原來的框計算IOU
                                if iou > 0.65:  # 正樣本;原為0.65
                                    positive_anno_file.write(
                                        "positive/{0}.jpg {1} {2} {3} {4} {5} {6} {7} {8} {9} {10} {11} {12} {13} {14} {15}\n".format(
                                            positive_count, 1, offset_x1, offset_y1,
                                            offset_x2, offset_y2, offset_px1, offset_py1, offset_px2, offset_py2,
                                            offset_px3,
                                            offset_py3, offset_px4, offset_py4, offset_px5, offset_py5))
                                    positive_anno_file.flush()  # flush:将緩存區的資料寫入檔案
                                    face_resize.save(
                                        os.path.join(positive_image_dir, "{0}.jpg".format(positive_count)))  # 儲存
                                    positive_count += 1
                                elif iou > 0.4:  # 部分樣本;原為0.4
                                    part_anno_file.write(
                                        "part/{0}.jpg {1} {2} {3} {4} {5} {6} {7} {8} {9} {10} {11} {12} {13} {14} {15}\n".format(
                                            part_count, 2, offset_x1, offset_y1, offset_x2,
                                            offset_y2, offset_px1, offset_py1, offset_px2, offset_py2, offset_px3,
                                            offset_py3, offset_px4, offset_py4, offset_px5, offset_py5))  # 寫入txt檔案
                                    part_anno_file.flush()
                                    face_resize.save(os.path.join(part_image_dir, "{0}.jpg".format(part_count)))
                                    part_count += 1
                                elif iou < 0.29:  # ★這樣生成的負樣本很少;原為0.3
                                    negative_anno_file.write(
                                        "negative/{0}.jpg {1} 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n".format(negative_count, 0))
                                    negative_anno_file.flush()
                                    face_resize.save(os.path.join(negative_image_dir, "{0}.jpg".format(negative_count)))
                                    negative_count += 1

                            # 生成負樣本
                            _boxes = np.array(boxes)
                            for i in range(2):  # 數量一般和前面保持一樣
                                side_len = np.random.randint(face_size, min(img_w, img_h) / 2)
                                x_ = np.random.randint(0, img_w - side_len)
                                y_ = np.random.randint(0, img_h - side_len)
                                crop_box = np.array([x_, y_, x_ + side_len, y_ + side_len])

                                if np.max(utils.iou(crop_box, _boxes)) < 0.29:  # 在加IOU進行判斷:保留小于0.3的那一部分;原為0.3
                                    face_crop = img.crop(crop_box)  # 摳圖
                                    face_resize = face_crop.resize((face_size, face_size),
                                                                   Image.ANTIALIAS)  # ANTIALIAS:平滑,抗鋸齒

                                    negative_anno_file.write(
                                        "negative/{0}.jpg {1} 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n".format(negative_count, 0))
                                    negative_anno_file.flush()
                                    face_resize.save(os.path.join(negative_image_dir, "{0}.jpg".format(negative_count)))
                                    negative_count += 1

                    except Exception as e:
                        print(e)
                        traceback.print_exc()

            except Exception as e:
                print(e)
                # 關閉寫入檔案
            finally:

                positive_anno_file.close()  # 關閉正樣本txt件

                negative_anno_file.close()

                part_anno_file.close()


if __name__ == '__main__':
    data = GenerateData()
    data.run(size=12)
    data.run(size=24)
    data.run(size=48)
           

widerface 資料代碼

import os
import traceback

import numpy as np
from PIL import Image, ImageDraw

from tools import utils

def gentxt():
    imgs_path = r"F:\widerface\WIDER_train\images"
    bbox_txt = r"F:\widerface\wider_face_split\wider_face_train_bbx_gt.txt"
    with open(bbox_txt,"r") as f:
        data = f.readlines()

    empty_dict = {}
    temp_name = None
    for i,line in enumerate(data):
        # i = i.strip()
        if line.strip().endswith("jpg"):
            line = line.strip()
            empty_dict[line] = []
            temp_name = line
        else:
            line = line.strip()
            if len(line)>10:
                # print(line.split()[:4])
                empty_dict[temp_name].append(line.split()[:4])

    with open("wider_anno.txt","w") as f:
        for key in empty_dict.keys():
            values = empty_dict[key]
            f.write(f"{key} ")
            for value in values:
                # print(value)
                # print(" ".join(value))
                f.write(" ".join(value))
                f.write(" ")
            f.write("\n")
            # exit()
            # f.write(f"{key}",)


class GenerateData():

    def __init__(self, anno_src=r"wider_anno.txt",
                 imgs_path=r"F:\widerface\WIDER_train\images",
                 save_path="D:\DataSet\wider"
                 ):

        self.anno_src = anno_src
        self.imgs_path = imgs_path
        self.save_path = save_path

        if not os.path.exists(self.save_path):
            os.makedirs(self.save_path)

    def run(self, size=12):
        print("gen %i image" % size)  # %i:十進制數占位符
        for face_size in [size]:
            # “樣本圖檔”存儲路徑--image
            positive_image_dir = os.path.join(self.save_path, str(face_size), "positive")  # 三級檔案路徑
            negative_image_dir = os.path.join(self.save_path, str(face_size), "negative")
            part_image_dir = os.path.join(self.save_path, str(face_size), "part")

            print(positive_image_dir, negative_image_dir, part_image_dir)
            for dir_path in [positive_image_dir, negative_image_dir, part_image_dir]:
                if not os.path.exists(dir_path):  # 如果檔案不存在則建立檔案路徑
                    os.makedirs(dir_path)

            # “樣本标簽”存儲路徑--text
            positive_anno_filename = os.path.join(self.save_path, str(face_size), "positive.txt")  # 建立正樣本txt檔案
            negative_anno_filename = os.path.join(self.save_path, str(face_size), "negative.txt")
            part_anno_filename = os.path.join(self.save_path, str(face_size), "part.txt")

            # 計數初始值:給檔案命名
            positive_count = 0  # 計數器初始值
            negative_count = 0
            part_count = 0
            # 凡是檔案操作,最好try一下,防止程式出錯奔潰
            try:
                positive_anno_file = open(positive_anno_filename, "w")  # 以寫入的模式打開txt文檔
                negative_anno_file = open(negative_anno_filename, "w")
                part_anno_file = open(part_anno_filename, "w")
                for i, line in enumerate(open(self.anno_src)):  # 枚舉出所有資訊

                    try:
                        # print(line)
                        strs = line.strip().split(" ")  # strip删除兩邊的空格
                        # print(strs)
                        # print(strs)
                        image_filename = strs[0].strip()
                        # print(image_filename)
                        image_file = os.path.join(self.imgs_path, image_filename)  # 建立檔案絕對路徑
                        values = list(map(float, strs[1:]))
                        all_boxes = []

                        for index in range(0, len(values), 4):
                            all_boxes.append(values[index:index + 4])

                        with Image.open(image_file) as img:
                            for one_box in all_boxes:
                                img_w, img_h = img.size
                                x1 = one_box[0]# float(strs[1].strip())  # 取2nd個值去除兩邊的空格,再轉車float型
                                y1 = one_box[1] # float(strs[2].strip())
                                w =one_box[2]#  float(strs[3].strip())
                                h =one_box[3]# float(strs[4].strip())
                                x2 = float(x1 + w)
                                y2 = float(y1 + h)

                                # draw = ImageDraw.Draw(img)
                                # draw.rectangle([x1,y1,x2,y2])
                                # img.show()
                                # exit()
                                px1 = 0#float(strs[5].strip())  # 人的五官
                                py1 = 0#float(strs[6].strip())
                                px2 =0# float(strs[7].strip())
                                py2 =0# float(strs[8].strip())
                                px3 =0# float(strs[9].strip())
                                py3 =0# float(strs[10].strip())
                                px4 =0# float(strs[11].strip())
                                py4 =0# float(strs[12].strip())
                                px5 =0# float(strs[13].strip())
                                py5 =0# float(strs[14].strip())

                                # 過濾字段,去除不符合條件的坐标
                                if max(w, h) < 40 or x1 < 0 or y1 < 0 or w < 0 or h < 0:
                                    continue

                                # x1 = int(x1 + w)  # 原來的坐标給與适當的偏移:偏移人臉框的0.15倍
                                # y1 = int(y1 + h)
                                # x2 = int(x1 + w)
                                # y2 = int(y1 + h)
                                # w = int(x2 - x1)  # 偏移後框的實際寬度
                                # h = int(y2 - y1)
                                boxes = [[x1, y1, x2, y2]]  # 左上角和右下角四個坐标點;二維的框有批次概念
                                # print(boxes)
                                # # exit()

                                # 計算出人臉中心點位置:框的中心位置
                                cx = x1 + w / 2
                                cy = y1 + h / 2
                                # 使正樣本和部分樣本數量翻倍以圖檔中心點随機偏移
                                for _ in range(1):  # 每個循環5次,畫五個框框、摳出來
                                    # 讓人臉中心點有少許的偏移
                                    # print(-w * 0.2, w * 0.2)
                                    w_ = np.random.randint(-w * 0.2, w * 0.2)  # 框的橫向偏移範圍:向左、向右移動了20%
                                    h_ = np.random.randint(-h * 0.2, h * 0.2)
                                    cx_ = cx + w_
                                    cy_ = cy + h_

                                    # 讓人臉形成正方形(12*12,24*24,48*48),并且讓坐标也有少許的偏離
                                    side_len = np.random.randint(int(min(w, h) * 0.8), np.ceil(1.25 * max(w, h)))
                                    # 邊長偏移的随機數的範圍;ceil大于等于該值的最小整數(向上取整);原0.8


                                    x1_ = np.max(cx_ - side_len / 2, 0)  # 坐标點随機偏移
                                    y1_ = np.max(cy_ - side_len / 2, 0)
                                    x2_ = x1_ + side_len
                                    y2_ = y1_ + side_len

                                    crop_box = np.array([x1_, y1_, x2_, y2_])  # 偏移後的新框
                                    # draw.rectangle(list(crop_box))
                                    # img.show()
                                    # exit()


                                    # 計算坐标的偏移值
                                    offset_x1 = (x1 - x1_) / side_len  # 偏移量△δ=(x1-x1_)/side_len;新框的寬度;
                                    offset_y1 = (y1 - y1_) / side_len
                                    offset_x2 = (x2 - x2_) / side_len
                                    offset_y2 = (y2 - y2_) / side_len

                                    offset_px1 =0#  (px1 - x1_) / side_len  # 人的五官特征的偏移值
                                    offset_py1 =0 #  (py1 - y1_) / side_len
                                    offset_px2 =0 #(px2 - x1_) / side_len
                                    offset_py2 =0# (py2 - y1_) / side_len
                                    offset_px3 =0# (px3 - x1_) / side_len
                                    offset_py3 = 0#(py3 - y1_) / side_len
                                    offset_px4 =0# (px4 - x1_) / side_len
                                    offset_py4 = 0#(py4 - y1_) / side_len
                                    offset_px5 = 0#(px5 - x1_) / side_len
                                    offset_py5 = 0#(py5 - y1_) / side_len

                                    # 剪切下圖檔,并進行大小縮放
                                    face_crop = img.crop(crop_box)  # “摳圖”,crop剪下框出的圖像
                                    face_resize = face_crop.resize((face_size, face_size),
                                                                   Image.ANTIALIAS)  # ★按照人臉尺寸(“像素矩陣大小”)進行縮放:12/24/48;坐标沒放縮

                                    iou = utils.iou(crop_box, np.array(boxes))[0]  # 摳出來的框和原來的框計算IOU
                                    if iou > 0.65:  # 正樣本;原為0.65
                                        positive_anno_file.write(
                                            "positive/{0}.jpg {1} {2} {3} {4} {5} {6} {7} {8} {9} {10} {11} {12} {13} {14} {15}\n".format(
                                                positive_count, 1, offset_x1, offset_y1,
                                                offset_x2, offset_y2, offset_px1, offset_py1, offset_px2, offset_py2,
                                                offset_px3,
                                                offset_py3, offset_px4, offset_py4, offset_px5, offset_py5))
                                        positive_anno_file.flush()  # flush:将緩存區的資料寫入檔案
                                        face_resize.save(
                                            os.path.join(positive_image_dir, "{0}.jpg".format(positive_count)))  # 儲存
                                        positive_count += 1
                                    elif iou > 0.4:  # 部分樣本;原為0.4
                                        part_anno_file.write(
                                            "part/{0}.jpg {1} {2} {3} {4} {5} {6} {7} {8} {9} {10} {11} {12} {13} {14} {15}\n".format(
                                                part_count, 2, offset_x1, offset_y1, offset_x2,
                                                offset_y2, offset_px1, offset_py1, offset_px2, offset_py2, offset_px3,
                                                offset_py3, offset_px4, offset_py4, offset_px5, offset_py5))  # 寫入txt檔案
                                        part_anno_file.flush()
                                        face_resize.save(os.path.join(part_image_dir, "{0}.jpg".format(part_count)))
                                        part_count += 1
                                    elif iou < 0.29:  # ★這樣生成的負樣本很少;原為0.3
                                        negative_anno_file.write(
                                            "negative/{0}.jpg {1} 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n".format(negative_count, 0))
                                        negative_anno_file.flush()
                                        face_resize.save(os.path.join(negative_image_dir, "{0}.jpg".format(negative_count)))
                                        negative_count += 1

                                # # 生成負樣本
                                # _boxes = np.array(boxes)
                                # for i in range(2):  # 數量一般和前面保持一樣
                                #     side_len = np.random.randint(face_size, min(img_w, img_h) / 2)
                                #     x_ = np.random.randint(0, img_w - side_len)
                                #     y_ = np.random.randint(0, img_h - side_len)
                                #     crop_box = np.array([x_, y_, x_ + side_len, y_ + side_len])
                                #
                                #     if np.max(utils.iou(crop_box, _boxes)) < 0.29:  # 在加IOU進行判斷:保留小于0.3的那一部分;原為0.3
                                #         face_crop = img.crop(crop_box)  # 摳圖
                                #         face_resize = face_crop.resize((face_size, face_size),
                                #                                        Image.ANTIALIAS)  # ANTIALIAS:平滑,抗鋸齒
                                #
                                #         negative_anno_file.write(
                                #             "negative/{0}.jpg {1} 0 0 0 0 0 0 0 0 0 0 0 0 0 0\n".format(negative_count, 0))
                                #         negative_anno_file.flush()
                                #         face_resize.save(os.path.join(negative_image_dir, "{0}.jpg".format(negative_count)))
                                #         negative_count += 1

                    except Exception as e:
                        print(e)
                        traceback.print_exc()

            except Exception as e:
                print(e)
                # 關閉寫入檔案
            finally:

                positive_anno_file.close()  # 關閉正樣本txt件

                negative_anno_file.close()

                part_anno_file.close()


if __name__ == '__main__':
    data = GenerateData()
    data.run(size=12)
    data.run(size=24)
    data.run(size=48)


           

資料代碼

  • 采用了資料增強,
  • 在訓練過程中發現,圖像會把一些手或者菜當成人頭,着實可怕,這是因為celeba資料中人物手把頭擋住的問題,且黃皮膚居多,部分人物光暗。是以采用顔色變換增強,對比度,顔色。
  • 在訓練過程中發現,側臉不容易識别,,因為對不少的資料集采用圖像鏡像實作側臉翻轉,效果不錯。解決問題
# 建立資料集
from torch.utils.data import Dataset
import os
import numpy as np
import torch
from PIL import Image
from torchvision import transforms
tf1 = transforms.Compose(
    [transforms.ColorJitter(brightness=0.5),
     transforms.RandomHorizontalFlip(p=0.5)
     ]
)
tf2 = transforms.Compose(
    [transforms.ColorJitter(contrast=0.5),
     transforms.RandomHorizontalFlip(p=0.5)
     ]
)
tf3 = transforms.Compose(
    [transforms.ColorJitter(saturation=0.5),
     transforms.RandomHorizontalFlip(p=0.5)
     ]
)
tf4 = transforms.Compose([
    transforms.RandomHorizontalFlip(p=0.5)
])

tf = transforms.RandomChoice(
    [
        tf1,tf2,tf3,tf4
    ]
)
# 資料集
class FaceDataset(Dataset):
    def __init__(self,path_1=r"D:\DataSet",path_2="D:\DataSet\wider",size=12,tf=tf):
        super(FaceDataset, self).__init__()
        self.dataset = []
        self.size = size
        for path in [path_1,path_2]:
            self.base_path_1 = path
            self.path = os.path.join(self.base_path_1,str(self.size))
            for txt in ["positive.txt","negative.txt","part.txt"]:
                with open(os.path.join(self.path,txt),"r") as f:
                    data = f.readlines()
                for line in data:
                    line = line.strip().split()
                    img_path = os.path.join(self.path,line[0])
                    benkdata = " ".join(line[1:])
                    self.dataset.append([img_path,benkdata])

        self.tf = tf

    def __len__(self):
        return len(self.dataset) # 資料集長度

    def __getitem__(self, index): # 擷取資料


        img_path,strs = self.dataset[index]
        strs = strs.strip().split(" ") # 取一條資料,去掉前後字元串,再按空格分割
        #标簽:置信度+偏移量
        cond = torch.Tensor([int(strs[0])]) # []莫丢,否則指定的是shape
        offset = torch.Tensor([float(strs[1]),float(strs[2]),float(strs[3]),float(strs[4])])
        #樣本:img_data
        # img_path = os.path.join(self.path,strs[0]) # 圖檔絕對路徑
        img = Image.open(img_path)

        img = self.tf(img)
        # img.show()
        img = np.array(img) / 255. - 0.5
        img_data = torch.tensor(img,dtype=torch.float32)  # 打開-->array-->歸一化去均值化-->轉成tensor
        img_data = img_data.permute(2,0,1) # CWH

        # print(img_data.shape) # WHC
        # a = img_data.permute(2,0,1) #軸變換
        # print(a.shape) #[3, 48, 48]:CWH
        return img_data,cond,offset

# 測試
if __name__ == '__main__':


    dataset = FaceDataset(size=12)
    print(dataset[0])
    print(len(dataset))

           

###網絡訓練

訓練代碼

增加了餘弦退火法的訓練方式,采用smoothL1回歸坐标點,BCELoss 分類損失

p 網絡訓練

# 建立訓練器----以訓練三個網絡
import os
from torch.optim.lr_scheduler import CosineAnnealingWarmRestarts
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
from torch.utils.data import DataLoader
import torch
from torch import nn
import torch.optim as optim
from sampling import FaceDataset # 導入資料集
from models import models
from torch.utils.tensorboard import SummaryWriter
# 建立訓練器

class Trainer:
    def __init__(self, net, save_path, dataset_size, isCuda=True,SummaryWriter_path=r"run"): # 網絡,參數儲存路徑,訓練資料路徑,cuda加速為True
        self.net = net
        self.save_path = save_path

        self.dataset_path = dataset_size
        self.isCuda = isCuda
        # print(self.net.name)
        # self.net.name
        summaryWriter_path = os.path.join(SummaryWriter_path,self.net.name)

        if not os.path.exists(summaryWriter_path):
            os.makedirs(summaryWriter_path)

        length = len(os.listdir(summaryWriter_path))
        path_name = os.path.join(summaryWriter_path, "exp" + str(length))
        os.makedirs(path_name)

        self.summaryWriter = SummaryWriter(path_name)

        if self.isCuda:      # 預設後面有個else
            self.net.cuda() # 給網絡加速

        # 建立損失函數
        # 置信度損失
        self.cls_loss_fn = nn.BCELoss() # ★二分類交叉熵損失函數,是多分類交叉熵(CrossEntropyLoss)的一個特例;用BCELoss前面必須用sigmoid激活,用CrossEntropyLoss前面必須用softmax函數
        # 偏移量損失
        self.offset_loss_fn = nn.SmoothL1Loss()

        # 建立優化器
        self.optimizer = optim.SGD(self.net.parameters(),lr=0.0001,momentum=0.9)

        # 恢複網絡訓練---加載模型參數,繼續訓練
        if os.path.exists(self.save_path): # 如果檔案存在,接着繼續訓練
            net.load_state_dict(torch.load(self.save_path),strict=False)

    # 訓練方法
    def train(self,epochs=1000):

        faceDataset = FaceDataset(size=self.dataset_path) # 資料集
        dataloader = DataLoader(faceDataset, batch_size=256, shuffle=True, num_workers=4,drop_last=True) # 資料加載器
        #num_workers=4:有4個線程在加載資料(加載資料需要時間,以防空置);drop_last:為True時表示,防止批次不足報錯。
        scheduler = CosineAnnealingWarmRestarts(self.optimizer, T_0=5, T_mult=1)

        self.best_loss = 1

        for epoch in range(epochs):
            for i, (img_data_, category_, offset_) in enumerate(dataloader): # 樣本,置信度,偏移量
                if self.isCuda:                    # cuda把資料讀到顯存裡去了(先經過記憶體);沒有cuda在記憶體,有cuda在顯存
                    img_data_ = img_data_.cuda()  # [512, 3, 12, 12]
                    category_ = category_.cuda() # 512, 1]
                    offset_ = offset_.cuda()    # [512, 4]

                # 網絡輸出
                _output_category, _output_offset = self.net(img_data_) # 輸出置信度,偏移量

                # print(_output_category.shape)     # [512, 1, 1, 1]
                # print(_output_offset.shape)       # [512, 4, 1, 1]
                output_category = _output_category.reshape(-1, 1) # [512,1]
                output_offset = _output_offset.reshape(-1, 4)     # [512,4]
                # output_landmark = _output_landmark.view(-1, 10)

                # 計算分類的損失----置信度
                category_mask = torch.lt(category_, 2)  # 對置信度小于2的正樣本(1)和負樣本(0)進行掩碼; ★部分樣本(2)不參與損失計算;符合條件的傳回1,不符合條件的傳回0
                category = torch.masked_select(category_, category_mask)              # 對“标簽”中置信度小于2的選擇掩碼,傳回符合條件的結果
                output_category = torch.masked_select(output_category, category_mask) # 預測的“标簽”進掩碼,傳回符合條件的結果
                cls_loss = self.cls_loss_fn(output_category, category)                # 對置信度做損失

                # 計算bound回歸的損失----偏移量
                offset_mask = torch.gt(category_, 0)  # 對置信度大于0的标簽,進行掩碼;★負樣本不參與計算,負樣本沒偏移量;[512,1]
                offset_index = torch.nonzero(offset_mask)[:, 0]  # 選出非負樣本的索引;[244]
                offset = offset_[offset_index]                   # 标簽裡餓偏移量;[244,4]
                output_offset = output_offset[offset_index]      # 輸出的偏移量;[244,4]
                offset_loss = self.offset_loss_fn(output_offset, offset)  # 偏移量損失

                #總損失
                loss = cls_loss + offset_loss
                # 反向傳播,優化網絡
                self.optimizer.zero_grad() # 清空之前的梯度
                loss.backward()           # 計算梯度
                self.optimizer.step()    # 優化網絡

                #輸出損失:loss-->gpu-->cup(變量)-->tensor-->array
                print("epoch=",epoch ,"loss:", loss.cpu().data.numpy(), " cls_loss:", cls_loss.cpu().data.numpy(), " offset_loss",
                      offset_loss.cpu().data.numpy())


                self.summaryWriter.add_scalars("loss", {"loss": loss.cpu().data.numpy(),
                                                        "cls_loss": cls_loss.cpu().data.numpy(),
                                                        "offser_loss": offset_loss.cpu().data.numpy()},epoch)
                # self.summaryWriter.add_histogram("pre_conv_layer1",self.net.pre_layer[0].weight,epoch)
                # self.summaryWriter.add_histogram("pre_conv_layer2",self.net.pre_layer[3].weight,epoch)
                # self.summaryWriter.add_histogram("pre_conv_layer3",self.net.pre_layer[5].weight,epoch)


                # 儲存
                if i%5==0:
                    if i%500==0:
                        torch.save(self.net.state_dict(), self.save_path)  # state_dict儲存網絡參數,save_path參數儲存路徑
                        print("save success")  # 每輪次儲存一次;最好做一判斷:損失下降時儲存一次
                    if loss.cpu().data.numpy()<self.best_loss:
                        self.best_loss = loss.cpu().data.numpy()
                        torch.save(self.net.state_dict(), self.save_path) # state_dict儲存網絡參數,save_path參數儲存路徑
                        print("save success")# 每輪次儲存一次;最好做一判斷:損失下降時儲存一次

            scheduler.step()

if __name__ == '__main__':
    
    net = models.PNet()
    trainer = Trainer(net, 'pnet.pt', dataset_size=12) # 網絡,儲存參數,訓練資料;建立訓器
    trainer.train()                                                     # 調用訓練器中的train方法
    # net = models.RNet()
    # trainer = Trainer(net, 'rnet.pt', r"D:\DataSet\24") # 網絡,儲存參數,訓練資料;建立訓器
    # trainer.train()
    # net = models.ONet()
    # trainer = Trainer(net, 'onet.pt', r"D:\DataSet\48") # 網絡,儲存參數,訓練資料;建立訓器
    # trainer.train()


           

R網絡訓練

# 建立訓練器----以訓練三個網絡


import os
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
from torch.utils.data import DataLoader
import torch
from torch import nn
import torch.optim as optim
from sampling import FaceDataset  # 導入資料集
from models import models
from torch.utils.tensorboard import SummaryWriter


# 建立訓練器
class Trainer:
    def __init__(self, net, save_path, dataset_path, isCuda=True,
                 SummaryWriter_path=r"run"):  # 網絡,參數儲存路徑,訓練資料路徑,cuda加速為True
        self.net = net
        self.save_path = save_path
        self.dataset_path = dataset_path
        self.isCuda = isCuda
        # print(self.net.name)
        # self.net.name
        summaryWriter_path = os.path.join(SummaryWriter_path, self.net.name)

        if not os.path.exists(summaryWriter_path):
            os.makedirs(summaryWriter_path)

        length = len(os.listdir(summaryWriter_path))
        path_name = os.path.join(summaryWriter_path, "exp" + str(length))
        os.makedirs(path_name)

        self.summaryWriter = SummaryWriter(path_name)

        if self.isCuda:  # 預設後面有個else
            self.net.cuda()  # 給網絡加速

        # 建立損失函數
        # 置信度損失
        self.cls_loss_fn = nn.BCELoss()  # ★二分類交叉熵損失函數,是多分類交叉熵(CrossEntropyLoss)的一個特例;用BCELoss前面必須用sigmoid激活,用CrossEntropyLoss前面必須用softmax函數
        # 偏移量損失
        self.offset_loss_fn = nn.SmoothL1Loss()

        # 建立優化器
        self.optimizer = optim.SGD(self.net.parameters(),lr=0.0001,momentum=0.8)

        # 恢複網絡訓練---加載模型參數,繼續訓練
        if os.path.exists(self.save_path):  # 如果檔案存在,接着繼續訓練
            net.load_state_dict(torch.load(self.save_path),strict=False)

    # 訓練方法
    def train(self):
        faceDataset = FaceDataset(size=self.dataset_path)  # 資料集
        dataloader = DataLoader(faceDataset, batch_size=128, shuffle=True, num_workers=2, drop_last=True)  # 資料加載器
        # num_workers=4:有4個線程在加載資料(加載資料需要時間,以防空置);drop_last:為True時表示,防止批次不足報錯。
        self.best_loss = None
        while True:
            for i, (img_data_, category_, offset_) in enumerate(dataloader):  # 樣本,置信度,偏移量
                if self.isCuda:  # cuda把資料讀到顯存裡去了(先經過記憶體);沒有cuda在記憶體,有cuda在顯存
                    img_data_ = img_data_.cuda()  # [512, 3, 12, 12]
                    category_ = category_.cuda()  # 512, 1]
                    offset_ = offset_.cuda()  # [512, 4]

                # 網絡輸出
                _output_category, _output_offset = self.net(img_data_)  # 輸出置信度,偏移量

                # print(_output_category.shape)     # [512, 1, 1, 1]
                # print(_output_offset.shape)       # [512, 4, 1, 1]
                output_category = _output_category.view(-1, 1)  # [512,1]
                output_offset = _output_offset.view(-1, 4)  # [512,4]
                # output_landmark = _output_landmark.view(-1, 10)

                # 計算分類的損失----置信度
                category_mask = torch.lt(category_, 2)  # 對置信度小于2的正樣本(1)和負樣本(0)進行掩碼; ★部分樣本(2)不參與損失計算;符合條件的傳回1,不符合條件的傳回0
                category = torch.masked_select(category_, category_mask)  # 對“标簽”中置信度小于2的選擇掩碼,傳回符合條件的結果
                output_category = torch.masked_select(output_category, category_mask)  # 預測的“标簽”進掩碼,傳回符合條件的結果
                cls_loss = self.cls_loss_fn(output_category, category)  # 對置信度做損失

                # 計算bound回歸的損失----偏移量
                offset_mask = torch.gt(category_, 0)  # 對置信度大于0的标簽,進行掩碼;★負樣本不參與計算,負樣本沒偏移量;[512,1]
                offset_index = torch.nonzero(offset_mask)[:, 0]  # 選出非負樣本的索引;[244]
                offset = offset_[offset_index]  # 标簽裡餓偏移量;[244,4]
                output_offset = output_offset[offset_index]  # 輸出的偏移量;[244,4]
                offset_loss = self.offset_loss_fn(output_offset, offset)  # 偏移量損失

                # 總損失
                loss = 0.5*cls_loss + offset_loss

                if i == 0:
                    self.best_loss = loss.cpu().data.numpy()
                # 反向傳播,優化網絡
                self.optimizer.zero_grad()  # 清空之前的梯度
                loss.backward()  # 計算梯度
                self.optimizer.step()  # 優化網絡

                # 輸出損失:loss-->gpu-->cup(變量)-->tensor-->array
                print("i=", i, "loss:", loss.cpu().data.numpy(), " cls_loss:", cls_loss.cpu().data.numpy(),
                      " offset_loss",
                      offset_loss.cpu().data.numpy())

                self.summaryWriter.add_scalars("loss", {"loss": loss.cpu().data.numpy(),
                                                        "cls_loss": cls_loss.cpu().data.numpy(),
                                                        "offser_loss": offset_loss.cpu().data.numpy()},i)
                # self.summaryWriter.add_histogram("pre_conv_layer1", self.net.pre_layer[0].weight,i)
                # self.summaryWriter.add_histogram("pre_conv_layer2", self.net.pre_layer[3].weight,i)
                # self.summaryWriter.add_histogram("pre_conv_layer3", self.net.pre_layer[6].weight,i)
                # 儲存
                if (i + 1) % 100 == 0 or self.best_loss>loss.cpu().data.numpy():
                    self.best_loss = loss.cpu().data.numpy()
                    torch.save(self.net.state_dict(), self.save_path)  # state_dict儲存網絡參數,save_path參數儲存路徑
                    print("save success")  # 每輪次儲存一次;最好做一判斷:損失下降時儲存一次


if __name__ == '__main__':

    net = models.RNet()
    trainer = Trainer(net, 'rnet.pt', 24) # 網絡,儲存參數,訓練資料;建立訓器
    trainer.train()


           

O網絡訓練

# 建立訓練器----以訓練三個網絡


import os
os.environ['KMP_DUPLICATE_LIB_OK'] = 'TRUE'
from torch.utils.data import DataLoader
import torch
from torch import nn
import torch.optim as optim
from sampling import FaceDataset  # 導入資料集
from models import models
from torch.utils.tensorboard import SummaryWriter


# 建立訓練器
class Trainer:
    def __init__(self, net, save_path, dataset_path, isCuda=True,
                 SummaryWriter_path=r"run"):  # 網絡,參數儲存路徑,訓練資料路徑,cuda加速為True
        self.net = net
        self.save_path = save_path
        self.dataset_path = dataset_path
        self.isCuda = isCuda
        # print(self.net.name)
        # self.net.name
        summaryWriter_path = os.path.join(SummaryWriter_path, self.net.name)

        if not os.path.exists(summaryWriter_path):
            os.makedirs(summaryWriter_path)

        length = len(os.listdir(summaryWriter_path))
        path_name = os.path.join(summaryWriter_path, "exp" + str(length))
        os.makedirs(path_name)

        self.summaryWriter = SummaryWriter(path_name)

        if self.isCuda:  # 預設後面有個else
            self.net.cuda()  # 給網絡加速

        # 建立損失函數
        # 置信度損失
        self.cls_loss_fn = nn.BCELoss()  # ★二分類交叉熵損失函數,是多分類交叉熵(CrossEntropyLoss)的一個特例;用BCELoss前面必須用sigmoid激活,用CrossEntropyLoss前面必須用softmax函數
        # 偏移量損失
        self.offset_loss_fn = nn.SmoothL1Loss()

        # 建立優化器
        self.optimizer = optim.SGD(self.net.parameters(),lr=0.0001,momentum=0.8)

        # 恢複網絡訓練---加載模型參數,繼續訓練
        if os.path.exists(self.save_path):  # 如果檔案存在,接着繼續訓練
            net.load_state_dict(torch.load(self.save_path),strict=False)

    # 訓練方法
    def train(self):
        faceDataset = FaceDataset(size=self.dataset_path)  # 資料集
        dataloader = DataLoader(faceDataset, batch_size=128, shuffle=True, num_workers=2, drop_last=True)  # 資料加載器
        # num_workers=4:有4個線程在加載資料(加載資料需要時間,以防空置);drop_last:為True時表示,防止批次不足報錯。
        self.best_loss = None
        while True:
            for i, (img_data_, category_, offset_) in enumerate(dataloader):  # 樣本,置信度,偏移量
                if self.isCuda:  # cuda把資料讀到顯存裡去了(先經過記憶體);沒有cuda在記憶體,有cuda在顯存
                    img_data_ = img_data_.cuda()  # [512, 3, 12, 12]
                    category_ = category_.cuda()  # 512, 1]
                    offset_ = offset_.cuda()  # [512, 4]

                # 網絡輸出
                _output_category, _output_offset = self.net(img_data_)  # 輸出置信度,偏移量

                # print(_output_category.shape)     # [512, 1, 1, 1]
                # print(_output_offset.shape)       # [512, 4, 1, 1]
                output_category = _output_category.view(-1, 1)  # [512,1]
                output_offset = _output_offset.view(-1, 4)  # [512,4]
                # output_landmark = _output_landmark.view(-1, 10)

                # 計算分類的損失----置信度
                category_mask = torch.lt(category_, 2)  # 對置信度小于2的正樣本(1)和負樣本(0)進行掩碼; ★部分樣本(2)不參與損失計算;符合條件的傳回1,不符合條件的傳回0
                category = torch.masked_select(category_, category_mask)  # 對“标簽”中置信度小于2的選擇掩碼,傳回符合條件的結果
                output_category = torch.masked_select(output_category, category_mask)  # 預測的“标簽”進掩碼,傳回符合條件的結果
                cls_loss = self.cls_loss_fn(output_category, category)  # 對置信度做損失

                # 計算bound回歸的損失----偏移量
                offset_mask = torch.gt(category_, 0)  # 對置信度大于0的标簽,進行掩碼;★負樣本不參與計算,負樣本沒偏移量;[512,1]
                offset_index = torch.nonzero(offset_mask)[:, 0]  # 選出非負樣本的索引;[244]
                offset = offset_[offset_index]  # 标簽裡餓偏移量;[244,4]
                output_offset = output_offset[offset_index]  # 輸出的偏移量;[244,4]
                offset_loss = self.offset_loss_fn(output_offset, offset)  # 偏移量損失

                # 總損失
                loss = 0.5*cls_loss + offset_loss

                if i == 0:
                    self.best_loss = loss.cpu().data.numpy()
                # 反向傳播,優化網絡
                self.optimizer.zero_grad()  # 清空之前的梯度
                loss.backward()  # 計算梯度
                self.optimizer.step()  # 優化網絡

                # 輸出損失:loss-->gpu-->cup(變量)-->tensor-->array
                print("i=", i, "loss:", loss.cpu().data.numpy(), " cls_loss:", cls_loss.cpu().data.numpy(),
                      " offset_loss",
                      offset_loss.cpu().data.numpy())

                self.summaryWriter.add_scalars("loss", {"loss": loss.cpu().data.numpy(),
                                                        "cls_loss": cls_loss.cpu().data.numpy(),
                                                        "offser_loss": offset_loss.cpu().data.numpy()},i)
                # self.summaryWriter.add_histogram("pre_conv_layer1", self.net.pre_layer[0].weight,i)
                # self.summaryWriter.add_histogram("pre_conv_layer2", self.net.pre_layer[3].weight,i)
                # self.summaryWriter.add_histogram("pre_conv_layer3", self.net.pre_layer[6].weight,i)
                # 儲存
                if (i + 1) % 100 == 0 or self.best_loss>loss.cpu().data.numpy():
                    self.best_loss = loss.cpu().data.numpy()
                    torch.save(self.net.state_dict(), self.save_path)  # state_dict儲存網絡參數,save_path參數儲存路徑
                    print("save success")  # 每輪次儲存一次;最好做一判斷:損失下降時儲存一次


if __name__ == '__main__':

    net = models.RNet()
    trainer = Trainer(net, 'rnet.pt', 24) # 網絡,儲存參數,訓練資料;建立訓器
    trainer.train()
           

偵測代碼

  • p網絡采用全卷積手法的原因,可以輸入不同尺度的圖檔,利用輸出特征圖進行反算
import os
import time

import cv2

from tools import utils
import numpy as np
import torch
from PIL import Image, ImageDraw
from torchvision import transforms

from models import models


class Detector():
    def __init__(self,
                 pnet_param="pnet.pt",
                 rnet_param="rnet.pt",
                 onet_param="onet.pt",
                 isCuda=True,
                 # 網絡調參
                 p_cls=0.6,  # 原為0.6
                 p_nms=0.5,  # 原為0.5
                 r_cls=0.6,  # 原為0.6
                 r_nms=0.5,  # 原為0.5
                 # R網絡:
                 o_cls=0.99,  # 原為0.97
                 o_nms=0.6,  # 原為0.7
                 ):


        self.isCuda = isCuda
        self.pnet = models.PNet()  # 建立執行個體變量,執行個體化P網絡
        self.rnet = models.RNet()
        self.onet = models.ONet()

        if self.isCuda:
            self.pnet.cuda()
            self.rnet.cuda()
            self.onet.cuda()

        self.pnet.load_state_dict(torch.load(pnet_param))  # 把訓練好的權重加載到P網絡中
        self.rnet.load_state_dict(torch.load(rnet_param))
        self.onet.load_state_dict(torch.load(onet_param))

        self.pnet.eval()  # 訓練網絡裡有BN(批歸一化時),要調用eval方法,使用是不用BN,dropout方法
        self.rnet.eval()
        self.onet.eval()

        self.p_cls = p_cls  # 原為0.6
        self.p_nms = p_nms  # 原為0.5
        self.r_cls = r_cls  # 原為0.6
        self.r_nms = r_nms   # 原為0.5
        # R網絡:
        self.o_cls = o_cls  # 原為0.97
        self.o_nms = o_nms  # 原為0.7

        self.__image_transform = transforms.Compose(
            [
                transforms.ToTensor()
            ]
        )

    def detect(self, image):
        # P網絡檢測-----1st
        start_time = time.time()
        pnet_boxes = self.__pnet_detect(image) # 調用__pnet_detect函數(後面定義)
        if pnet_boxes.shape[0]==0:
            return np.array([])
        end_time = time.time()
        t_pnet = end_time - start_time  # P網絡所占用的時間差
        # print(pnet_boxes.shape)
        # return pnet_boxes                    # p網絡檢測出的框

        start_time = time.time()
        rnet_boxes = self.__rnet_detect(image,pnet_boxes)  # 調用__pnet_detect函數(後面定義)
        if rnet_boxes.shape[0] == 0:
            return np.array([])
        end_time = time.time()
        t_rnet = end_time - start_time  # r網絡所占用的時間差
        # return rnet_boxes                    # r網絡檢測出的框

        onet_boxes = self.__onet_detect(image,rnet_boxes)  # 調用__pnet_detect函數(後面定義)
        if rnet_boxes.shape[0] == 0:
            return np.array([])
        end_time = time.time()
        t_onet = end_time - start_time  # P網絡所占用的時間差
        # return onet_boxes                    # p網絡檢測出的框

        # 三網絡檢測的總時間
        t_sum = t_pnet + t_rnet + t_onet
        print("total:{0} pnet:{1} rnet:{2} onet:{3}".format(t_sum, t_pnet, t_rnet, t_onet))

        return onet_boxes

    def __pnet_detect(self, image):  # ★p網絡全部是卷積,與輸入圖檔大小無關,可輸出任意形狀圖檔
        boxes = []  # 建立空清單,接收符合條件的建議框

        img = image
        w, h = img.size
        min_side_len = min(w, h)  # 擷取圖檔的最小邊長

        scale = 1  # 初始縮放比例(為1時不縮放):得到不同分辨率的圖檔
        while min_side_len > 12:  # 直到縮放到小于等于12時停止
            img_data = self.__image_transform(img)  # 将圖檔數組轉成張量
            if self.isCuda:
                img_data = img_data.cuda()  # 将圖檔tensor傳到cuda裡加速
            img_data.unsqueeze_(0)  # 在“批次”上升維(測試時傳的不止一張圖檔)
            # print("img_data:",img_data.shape) # [1, 3, 416, 500]:C=3,W=416,H=500

            _cls, _offest = self.pnet(img_data)  # ★★傳回多個置信度和偏移量
            # print("_cls",_cls.shape)         # [1, 1, 203, 245]:NCWH:分組卷積的特征圖的通道和尺寸★
            # print("_offest", _offest.shape) # [1, 4, 203, 245]:NCWH

            cls = _cls[0][0].cpu().data  # [203, 245]:分組卷積特征圖的尺寸:W,H
            offest = _offest[0].cpu().data  # [4, 203, 245] # 分組卷積特征圖的通道、尺寸:C,W,H
            idxs = torch.nonzero(torch.gt(cls, self.p_cls))  # ★置信度大于0.6的框索引;把P網絡輸出,看有沒沒框到的人臉,若沒框到人臉,說明網絡沒訓練好;或者置信度給高了、調低
            # print(idxs)
            for idx in idxs:  # 根據索引,依次添加符合條件的框;cls[idx[0], idx[1]]在置信度中取值:idx[0]行索引,idx[1]列索引
                boxes.append(self.__box(idx, offest, cls[idx[0], idx[1]], scale))  # ★調用框反算函數_box(把特征圖上的框,反算到原圖上去),把大于0.6的框留下來;
            scale *= 0.7  # 縮放圖檔:循環控制條件
            _w = int(w * scale)  # 新的寬度
            _h = int(h * scale)

            img = img.resize((_w, _h))  # 根據縮放後的寬和高,對圖檔進行縮放
            min_side_len = min(_w, _h)  # 重新擷取最小寬高

        return utils.nms(np.array(boxes), self.p_nms)  # 傳回框框,原門檻值給p_nms=0.5(iou為0.5),盡可能保留IOU小于0.5的一些框下來,若網絡訓練的好,值可以給低些

        # 特征反算:将回歸量還原到原圖上去,根據特征圖反算的到原圖建議框

    def __box(self, start_index, offset, cls, scale, stride=2, side_len=12):  # p網絡池化步長為2

        _x1 = (start_index[1].float() * stride) / scale  # 索引乘以步長,除以縮放比例;★特征反算時“行索引,索引互換”,原為[0]
        _y1 = (start_index[0].float() * stride) / scale
        _x2 = (start_index[1].float() * stride + side_len) / scale
        _y2 = (start_index[0].float() * stride + side_len) / scale

        ow = _x2 - _x1  # 人臉所在區域建議框的寬和高
        oh = _y2 - _y1

        _offset = offset[:, start_index[0], start_index[1]]  # 根據idxs行索引與列索引,找到對應偏移量△δ:[x1,y1,x2,y2]

        x1 = _x1 + ow * _offset[0]  # 根據偏移量算實際框的位置,x1=x1_+w*△δ;生樣時為:△δ=x1-x1_/w
        y1 = _y1 + oh * _offset[1]
        x2 = _x2 + ow * _offset[2]
        y2 = _y2 + oh * _offset[3]

        return [x1, y1, x2, y2, cls]  # 正式框:傳回4個坐标點和1個偏移量


    def __rnet_detect(self, image, pnet_boxes):

        _img_dataset = []  # 建立空清單,存放摳圖
        _pnet_boxes = utils.convert_to_square(pnet_boxes)  # ★給p網絡輸出的框,找出中心點,沿着最大邊長的兩邊擴充成“正方形”,再摳圖
        for _box in _pnet_boxes:  # ★周遊每個框,每個框傳回框4個坐标點,摳圖,放縮,資料類型轉換,添加清單
            _x1 = int(_box[0])
            _y1 = int(_box[1])
            _x2 = int(_box[2])
            _y2 = int(_box[3])

            img = image.crop((_x1, _y1, _x2, _y2))  # 根據4個坐标點摳圖
            img = img.resize((24, 24))  # 放縮在固尺寸
            img_data = self.__image_transform(img)  # 将圖檔數組轉成張量
            _img_dataset.append(img_data)

        img_dataset = torch.stack(_img_dataset)  # stack堆疊(預設在0軸),此處相當資料類型轉換,見例子2★
        if self.isCuda:
            img_dataset = img_dataset.cuda()  # 給圖檔資料采用cuda加速

        _cls, _offset = self.rnet(img_dataset)  # ★★将24*24的圖檔傳入網絡再進行一次篩選

        cls = _cls.cpu().data.numpy()  # 将gpu上的資料放到cpu上去,在轉成numpy數組
        offset = _offset.cpu().data.numpy()
        # print("r_cls:",cls.shape)  # (11, 1):P網絡生成了11個框
        # print("r_offset:", offset.shape)  # (11, 4)

        boxes = []  # R 網絡要留下來的框,存到boxes裡
        idxs, _ = np.where(
            cls > self.r_cls)  # 原置信度0.6是偏低的,時候很多框并沒有用(可列印出來觀察),可以适當調高些;idxs置信度框大于0.6的索引;★傳回idxs:0軸上索引[0,1],_:1軸上索引[0,0],共同決定元素位置,見例子3
        for idx in idxs:  # 根據索引,周遊符合條件的框;1軸上的索引,恰為符合條件的置信度索引(0軸上索引此處用不到)
            _box = _pnet_boxes[idx]
            _x1 = int(_box[0])
            _y1 = int(_box[1])
            _x2 = int(_box[2])
            _y2 = int(_box[3])

            ow = _x2 - _x1  # 基準框的寬
            oh = _y2 - _y1

            x1 = _x1 + ow * offset[idx][0]  # 實際框的坐标點
            y1 = _y1 + oh * offset[idx][1]
            x2 = _x2 + ow * offset[idx][2]
            y2 = _y2 + oh * offset[idx][3]

            boxes.append([x1, y1, x2, y2, cls[idx][0]])  # 傳回4個坐标點和置信度

        return utils.nms(np.array(boxes), self.r_nms)  # 原r_nms為0.5(0.5要往小調),上面的0.6要往大調;小于0.5的框被保留下來

        # 建立O網絡檢測函數

    def __onet_detect(self, image, rnet_boxes):

        _img_dataset = []  # 建立清單,存放摳圖r
        _rnet_boxes = utils.convert_to_square(rnet_boxes)  # 給r網絡輸出的框,找出中心點,沿着最大邊長的兩邊擴充成“正方形”
        for _box in _rnet_boxes:  # 周遊R網絡篩選出來的框,計算坐标,摳圖,縮放,資料類型轉換,添加清單,堆疊
            _x1 = int(_box[0])
            _y1 = int(_box[1])
            _x2 = int(_box[2])
            _y2 = int(_box[3])

            img = image.crop((_x1, _y1, _x2, _y2))  # 根據坐标點“摳圖”
            img = img.resize((48, 48))
            img_data = self.__image_transform(img)  # 将摳出的圖轉成張量
            _img_dataset.append(img_data)

        img_dataset = torch.stack(_img_dataset)  # 堆疊,此處相當資料格式轉換,見例子2
        if self.isCuda:
            img_dataset = img_dataset.cuda()

        _cls, _offset = self.onet(img_dataset)
        cls = _cls.cpu().data.numpy()  # (1, 1)
        offset = _offset.cpu().data.numpy()  # (1, 4)

        boxes = []  # 存放o網絡的計算結果
        idxs, _ = np.where(
            cls > self.o_cls)  # 原o_cls為0.97是偏低的,最後要達到标準置信度要達到0.99999,這裡可以寫成0.99998,這樣的話出來就全是人臉;留下置信度大于0.97的框;★傳回idxs:0軸上索引[0],_:1軸上索引[0],共同決定元素位置,見例子3
        for idx in idxs:  # 根據索引,周遊符合條件的框;1軸上的索引,恰為符合條件的置信度索引(0軸上索引此處用不到)
            _box = _rnet_boxes[idx]  # 以R網絡做為基準框
            _x1 = int(_box[0])
            _y1 = int(_box[1])
            _x2 = int(_box[2])
            _y2 = int(_box[3])

            ow = _x2 - _x1  # 框的基準寬,框是“方”的,ow=oh
            oh = _y2 - _y1

            x1 = _x1 + ow * offset[idx][0]  # O網絡最終生成的框的坐标;生樣,偏移量△δ=x1-_x1/w*side_len
            y1 = _y1 + oh * offset[idx][1]
            x2 = _x2 + ow * offset[idx][2]
            y2 = _y2 + oh * offset[idx][3]

            boxes.append([x1, y1, x2, y2, cls[idx][0]])  # 傳回4個坐标點和1個置信度

        return utils.nms(np.array(boxes), self.o_nms, isMin=True)  # 用最小面積的IOU;原o_nms(IOU)為小于0.7的框被保留下來


def detect(imgs_path="./test_images", save_path="./result_images",test_video=False):
    if not os.path.exists(save_path):
        os.makedirs(save_path)


    if test_video:
    #  多張圖檔顯示
        detector = Detector()
        cap = cv2.VideoCapture('./test_video/蔡徐坤.mp4')
        num = 0
        while cap.isOpened():
            ret,frame = cap.read()
            if cv2.waitKey(25) & 0xFF == ord('q'):
                break
            image = Image.fromarray(cv2.cvtColor(frame,cv2.COLOR_BGR2RGB))
            print("-------------------")
            boxes = detector.detect(image)
            print("size:", image.size)
            for box in boxes:
                x1 = int(box[0])
                y1 = int(box[1])
                x2 = int(box[2])
                y2 = int(box[3])
                print(x1, y1, x2, y2)
                print("conf:", box[4])
                head = image.crop(image,[x1,y1,x2,y2])
                head.save(f"./蔡徐坤照片/{num}.jpg")
                num+=1
                cv2.rectangle(frame, (x1, y1), (x2, y2), (0,0,255), 2)
            cv2.imshow("res",frame)


        cap.release()
        cv2.destroyAllWindows()

    else:
        for img in os.listdir(imgs_path):
            detector = Detector()
            with Image.open(os.path.join(imgs_path, img)) as image:
                print("-------------------")
                boxes = detector.detect(image)
                print("size:", image.size)
                img_draw = ImageDraw.Draw(image)
                for box in boxes:
                    x1 = int(box[0])
                    y1 = int(box[1])
                    x2 = int(box[2])
                    y2 = int(box[3])
                    print(x1, y1, x2, y2,"conf:", box[4])
                    # print("conf:", box[4])
                    img_draw.rectangle((x1, y1, x2, y2), outline="red")

                image.show()
                image.save(os.path.join(save_path, img))

if __name__ == '__main__':
    detect()


           

總結

人臉偵測中遇到了很多問題:

  1. MTCNN的第一階段,圖像金字塔會反反複複地很多次調用一個很淺層的P-NET網絡,導緻資料會反反複複地從記憶體COPY到顯存,又從顯存COPY到記憶體,而這個複制操作消耗很大,甚至比計算本身耗時。
  2. P網絡運作速度對整個模型的影響較大;R、O網絡摳圖檔案操作耗費時間;for循環串行耗費時間;硬體問題加劇模型耗時

    原因:圖像金字塔需要耗費很多時間,反算沒使用Tensor和矩陣進行計算;摳圖未使用切片完成;一般越進階的語言運作越慢。

    解決方法:根據實際需要調整縮放比;使用tensor和矩陣運算優化代碼。

  3. 調用模型時,記得eval()
  4. 解決問題的最好方式,是增加準确的資料
  5. 這裡别用ReLU,效果不好,負半軸的丢失會造成一定的資訊損失。

初次總結:如有不足,敬請求指教。

相關代碼連結已上傳csdn 0積分 。

下載下傳連結

本人方向目辨別别,有興趣的小夥伴可以一起交流

WX:

深度學習從入門到精通——MTCNN人臉偵測算法MTCNN

繼續閱讀