主頁 >  其他 > 寫給程式員的機器學習入門 (九) - 物件識別 RCNN 與 Fast-RCNN

寫給程式員的機器學習入門 (九) - 物件識別 RCNN 與 Fast-RCNN

2020-12-03 06:23:01 其他

因為這幾個月飯店生意恢復,加上研究 Faster-RCNN 用掉了很多時間,就沒有更新博客了??,這篇開始會介紹物件識別的模型與實作方法,首先會介紹最簡單的 RCNN 與 Fast-RCNN 模型,下一篇會介紹 Faster-RCNN 模型,再下一篇會介紹 YOLO 模型,

圖片分類與物件識別

在前面的文章中我們看到了如何使用 CNN 模型識別圖片里面的物體是什么型別,或者識別圖片中固定的文字 (即驗證碼),因為模型會把整個圖片當作輸入并輸出固定的結果,所以圖片中只能有一個主要的物體或者固定數量的文字,

如果圖片包含了多個物體,我們想識別有哪些物體,各個物體在什么位置,那么只用 CNN 模型是無法實作的,我們需要可以找出圖片哪些區域包含物體并且判斷每個區域包含什么物體的模型,這樣的模型稱為物件識別模型 (Object Detection Model),最早期的物件識別模型是 RCNN 模型,后來又發展出 Fast-RCNN (SPPnet),Faster-RCNN ,和 YOLO 等模型,因為物件識別需要處理的資料量多,速度會比較慢 (例如 RCNN 檢測單張圖片包含的物體可能需要幾十秒),而物件識別通常又要求實時性 (例如來源是攝像頭提供的視頻),所以如何提升物件識別的速度是一個主要的命題,后面發展出的 Faster-RCNN 與 YOLO 都可以在一秒鐘檢測幾十張圖片,

物件識別的應用范圍比較廣,例如人臉識別,車牌識別,自動駕駛等等都用到了物件識別的技術,物件識別是當今機器學習領域的一個前沿,2017 年研發出來的 Mask-RCNN 模型還可以檢測物件的輪廓,

因為看上去越神奇的東西實作起來越難,物件識別模型相對于之前介紹的模型難度會高很多,請做好心理準備??,

物件識別模型需要的訓練資料

在介紹具體的模型之前,我們首先看看物件識別模型需要什么樣的訓練資料:

物件識別模型需要給每個圖片標記有哪些區域,與每個區域對應的標簽,也就是訓練資料需要是串列形式的,區域的格式通常有兩種,(x, y, w, h) => 左上角的坐標與長寬,與 (x1, y1, x2, y2) => 左上角與右下角的坐標,這兩種格式可以互相轉換,處理的時候只需要注意是哪種格式即可,標簽除了需要識別的各個分類之外,還需要有一個特殊的非物件 (背景) 標簽,表示這個區域不包含任何可以識別的物件,因為非物件區域通常可以自動生成,所以訓練資料不需要包含非物件區域與標簽,

RCNN

RCNN (Region Based Convolutional Neural Network) 是最早期的物件識別模型,實作比較簡單,可以分為以下步驟:

  • 用某種演算法在圖片中選取 2000 個可能出現物件的區域
  • 截取這 2000 個區域到 2000 個子圖片,然后縮放它們到一個固定的大小
  • 用普通的 CNN 模型分別識別這 2000 個子圖片,得出它們的分類
  • 排除標記為 "非物件" 分類的區域
  • 把剩余的區域作為輸出結果

你可能已經從步驟里看出,RCNN 有幾個大問題??:

  • 結果的精度很大程度取決于選取區域使用的演算法
  • 選取區域使用的演算法是固定的,不參與學習,如果演算法沒有選出某個包含物件區域那么怎么學習都無法識別這個區域出來
  • 慢,賊慢??,識別 1 張圖片實際等于識別 2000 張圖片

后面介紹模型結果會解決這些問題,但首先我們需要理解最簡單的 RCNN 模型,接下來我們細看一下 RCNN 實作中幾個重要的部分吧,

選取可能出現物件的區域

選取可能出現物件的區域的演算法有很多種,例如滑動視窗法 (Sliding Window) 和選擇性搜索法 (Selective Search),滑動視窗法非常簡單,決定一個固定大小的區域,然后按一定距離滑動得出下一個區域即可,滑動視窗法實作簡單但選取出來的區域數量非常龐大并且精度很低,所以通常不會使用這種方法,除非物體大小固定并且出現的位置有一定規律,

選擇性搜索法則比較高級,以下是簡單的說明,摘自 opencv 的文章:

你還可以參考 這篇文章 或 原始論文 了解具體的計算方法,

如果你覺得難以理解可以跳過,因為接下來我們會直接使用 opencv 類別庫中提供的選擇搜索函式,而且選擇搜索法精度也不高,后面介紹的模型將會使用更好的方法,

# 使用 opencv 類別庫中提供的選擇搜索函式的代碼例子
import cv2

img = cv2.imread("圖片路徑")
s = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation()
s.setBaseImage(img)
s.switchToSelectiveSearchFast()
boxes = s.process() # 可能出現物件的所有區域,會按可能性排序
candidate_boxes = boxes[:2000] # 選取頭 2000 個區域

按重疊率 (IOU) 判斷每個區域是否包含物件

使用演算法選取出來的區域與實際區域通常不會完全重疊,只會重疊一部分,在學習的程序中我們需要根據手頭上的真實區域預先判斷選取出來的區域是否包含物件,再告訴模型預測結果是否正確,判斷選取區域是否包含物件會依據重疊率 (IOU - Intersection Over Union),所謂重疊率就是兩個區域重疊的面積占兩個區域合并的面積的比率,如下圖所示,

我們可以規定重疊率大于 70% 的候選區域包含物件,重疊率小于 30% 的區域不包含物件,而重疊率介于 30% ~ 70% 的區域不應該參與學習,這是為了給模型提供比較明確的資料,使得學習效果更好,

計算重疊率的代碼如下,如果兩個區域沒有重疊則重疊率會為 0:

def calc_iou(rect1, rect2):
    """計算兩個區域重疊部分 / 合并部分的比率 (intersection over union)"""
    x1, y1, w1, h1 = rect1
    x2, y2, w2, h2 = rect2
    xi = max(x1, x2)
    yi = max(y1, y2)
    wi = min(x1+w1, x2+w2) - xi
    hi = min(y1+h1, y2+h2) - yi
    if wi > 0 and hi > 0: # 有重疊部分
        area_overlap = wi*hi
        area_all = w1*h1 + w2*h2 - area_overlap
        iou = area_overlap / area_all
    else: # 沒有重疊部分
        iou = 0
    return iou

原始論文

如果你想看 RCNN 的原始論文可以到以下的地址:

https://arxiv.org/pdf/1311.2524.pdf

使用 RCNN 識別圖片中的人臉

好了,到這里你應該大致了解 RCNN 的實作原理,接下來我們試著用 RCNN 學習識別一些圖片,

因為收集圖片和標記圖片非常累人??,為了偷懶這篇我還是使用現成的資料集,以下是包含人臉圖片的資料集,并且帶了各個人臉所在的區域的標記,格式是 (x1, y1, x2, y2),下載需要注冊帳號,但不需要交錢??,

https://www.kaggle.com/vin1234/count-the-number-of-faces-present-in-an-image

下載解壓后可以看到圖片在 train/image_data 下,標記在 bbox_train.csv 中,

例如以下的圖片:

對應 csv 中的以下標記:

Name,width,height,xmin,ymin,xmax,ymax
10001.jpg,612,408,192,199,230,235
10001.jpg,612,408,247,168,291,211
10001.jpg,612,408,321,176,366,222
10001.jpg,612,408,355,183,387,214

資料的意義如下:

  • Name: 檔案名
  • width: 圖片整體寬度
  • height: 圖片整體高度
  • xmin: 人臉區域的左上角的 x 坐標
  • ymin: 人臉區域的左上角的 y 坐標
  • xmax: 人臉區域的右下角的 x 坐標
  • ymax: 人臉區域的右下角的 y 坐標

使用 RCNN 學習與識別這些圖片中的人臉區域的代碼如下:

import os
import sys
import torch
import gzip
import itertools
import random
import numpy
import pandas
import torchvision
import cv2
from torch import nn
from matplotlib import pyplot
from collections import defaultdict

# 各個區域縮放到的圖片大小
REGION_IMAGE_SIZE = (32, 32)
# 分析目標的圖片所在的檔案夾
IMAGE_DIR = "./784145_1347673_bundle_archive/train/image_data"
# 定義各個圖片中人臉區域的 CSV 檔案
BOX_CSV_PATH = "./784145_1347673_bundle_archive/train/bbox_train.csv"

# 用于啟用 GPU 支持
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class MyModel(nn.Module):
    """識別是否人臉 (ResNet-18)"""
    def __init__(self):
        super().__init__()
        # Resnet 的實作
        # 輸出兩個分類 [非人臉, 人臉]
        self.resnet = torchvision.models.resnet18(num_classes=2)

    def forward(self, x):
        # 應用 ResNet
        y = self.resnet(x)
        return y

def save_tensor(tensor, path):
    """保存 tensor 物件到檔案"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """從檔案讀取 tensor 物件"""
    return torch.load(gzip.GzipFile(path, "rb"))

def image_to_tensor(img):
    """轉換 opencv 圖片物件到 tensor 物件"""
    # 注意 opencv 是 BGR,但對訓練沒有影響所以不用轉為 RGB
    img = cv2.resize(img, dsize=REGION_IMAGE_SIZE)
    arr = numpy.asarray(img)
    t = torch.from_numpy(arr)
    t = t.transpose(0, 2) # 轉換維度 H,W,C 到 C,W,H
    t = t / 255.0 # 正規化數值使得范圍在 0 ~ 1
    return t

def calc_iou(rect1, rect2):
    """計算兩個區域重疊部分 / 合并部分的比率 (intersection over union)"""
    x1, y1, w1, h1 = rect1
    x2, y2, w2, h2 = rect2
    xi = max(x1, x2)
    yi = max(y1, y2)
    wi = min(x1+w1, x2+w2) - xi
    hi = min(y1+h1, y2+h2) - yi
    if wi > 0 and hi > 0: # 有重疊部分
        area_overlap = wi*hi
        area_all = w1*h1 + w2*h2 - area_overlap
        iou = area_overlap / area_all
    else: # 沒有重疊部分
        iou = 0
    return iou

def selective_search(img):
    """計算 opencv 圖片中可能出現物件的區域,只回傳頭 2000 個區域"""
    # 演算法參考 https://www.learnopencv.com/selective-search-for-object-detection-cpp-python/
    s = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation()
    s.setBaseImage(img)
    s.switchToSelectiveSearchFast()
    boxes = s.process()
    return boxes[:2000]

def prepare_save_batch(batch, image_tensors, image_labels):
    """準備訓練 - 保存單個批次的資料"""
    # 生成輸入和輸出 tensor 物件
    tensor_in = torch.stack(image_tensors) # 維度: B,C,W,H
    tensor_out = torch.tensor(image_labels, dtype=torch.long) # 維度: B

    # 切分訓練集 (80%),驗證集 (10%) 和測驗集 (10%)
    random_indices = torch.randperm(tensor_in.shape[0])
    training_indices = random_indices[:int(len(random_indices)*0.8)]
    validating_indices = random_indices[int(len(random_indices)*0.8):int(len(random_indices)*0.9):]
    testing_indices = random_indices[int(len(random_indices)*0.9):]
    training_set = (tensor_in[training_indices], tensor_out[training_indices])
    validating_set = (tensor_in[validating_indices], tensor_out[validating_indices])
    testing_set = (tensor_in[testing_indices], tensor_out[testing_indices])

    # 保存到硬碟
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """準備訓練"""
    # 資料集轉換到 tensor 以后會保存在 data 檔案夾下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 加載 csv 檔案,構建圖片到區域串列的索引 { 圖片名: [ 區域, 區域, .. ] }
    box_map = defaultdict(lambda: [])
    df = pandas.read_csv(BOX_CSV_PATH)
    for row in df.values:
        filename, width, height, x1, y1, x2, y2 = row[:7]
        box_map[filename].append((x1, y1, x2-x1, y2-y1))

    # 從圖片里面提取人臉 (正樣本) 和非人臉 (負樣本) 的圖片
    batch_size = 1000
    batch = 0
    image_tensors = []
    image_labels = []
    for filename, true_boxes in box_map.items():
        path = os.path.join(IMAGE_DIR, filename)
        img = cv2.imread(path) # 加載原始圖片
        candidate_boxes = selective_search(img) # 查找候選區域
        positive_samples = 0
        negative_samples = 0
        for candidate_box in candidate_boxes:
            # 如果候選區域和任意一個實際區域重疊率大于 70%,則認為是正樣本
            # 如果候選區域和所有實際區域重疊率都小于 30%,則認為是負樣本
            # 每個圖片最多添加正樣本數量 + 10 個負樣本,需要提供足夠多負樣本避免偽陽性判斷
            iou_list = [ calc_iou(candidate_box, true_box) for true_box in true_boxes ]
            positive_index = next((index for index, iou in enumerate(iou_list) if iou > 0.70), None)
            is_negative = all(iou < 0.30 for iou in iou_list)
            result = None
            if positive_index is not None:
                result = True
                positive_samples += 1
            elif is_negative and negative_samples < positive_samples + 10:
                result = False
                negative_samples += 1
            if result is not None:
                x, y, w, h = candidate_box
                child_img = img[y:y+h, x:x+w].copy()
                # 檢驗計算是否有問題
                # cv2.imwrite(f"{filename}_{x}_{y}_{w}_{h}_{int(result)}.png", child_img)
                image_tensors.append(image_to_tensor(child_img))
                image_labels.append(int(result))
                if len(image_tensors) >= batch_size:
                    # 保存批次
                    prepare_save_batch(batch, image_tensors, image_labels)
                    image_tensors.clear()
                    image_labels.clear()
                    batch += 1
    # 保存剩余的批次
    if len(image_tensors) > 10:
        prepare_save_batch(batch, image_tensors, image_labels)

def train():
    """開始訓練"""
    # 創建模型實體
    model = MyModel().to(device)

    # 創建損失計算器
    loss_function = torch.nn.CrossEntropyLoss()

    # 創建引數調整器
    optimizer = torch.optim.Adam(model.parameters())

    # 記錄訓練集和驗證集的正確率變化
    training_accuracy_history = []
    validating_accuracy_history = []

    # 記錄最高的驗證集正確率
    validating_accuracy_highest = -1
    validating_accuracy_highest_epoch = 0

    # 讀取批次的工具函式
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield [ t.to(device) for t in load_tensor(path) ]

    # 計算正確率的工具函式,正樣本和負樣本的正確率分別計算再平均
    def calc_accuracy(actual, predicted):
        predicted = torch.max(predicted, 1).indices
        acc_positive = ((actual > 0.5) & (predicted > 0.5)).sum().item() / ((actual > 0.5).sum().item() + 0.00001)
        acc_negative = ((actual <= 0.5) & (predicted <= 0.5)).sum().item() / ((actual <= 0.5).sum().item() + 0.00001)
        acc = (acc_positive + acc_negative) / 2
        return acc
 
    # 劃分輸入和輸出的工具函式
    def split_batch_xy(batch, begin=None, end=None):
        # shape = batch_size, channels, width, height
        batch_x = batch[0][begin:end]
        # shape = batch_size, num_labels
        batch_y = batch[1][begin:end]
        return batch_x, batch_y

    # 開始訓練程序
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根據訓練集訓練并修改引數
        model.train()
        training_accuracy_list = []
        for batch_index, batch in enumerate(read_batches("data/training_set")):
            # 切分小批次,有助于泛化模型
            training_batch_accuracy_list = []
            for index in range(0, batch[0].shape[0], 100):
                # 劃分輸入和輸出
                batch_x, batch_y = split_batch_xy(batch, index, index+100)
                # 計算預測值
                predicted = model(batch_x)
                # 計算損失
                loss = loss_function(predicted, batch_y)
                # 從損失自動微分求導函式值
                loss.backward()
                # 使用引數調整器調整引數
                optimizer.step()
                # 清空導函式值
                optimizer.zero_grad()
                # 記錄這一個批次的正確率,torch.no_grad 代表臨時禁用自動微分功能
                with torch.no_grad():
                    training_batch_accuracy_list.append(calc_accuracy(batch_y, predicted))
            # 輸出批次正確率
            training_batch_accuracy = sum(training_batch_accuracy_list) / len(training_batch_accuracy_list)
            training_accuracy_list.append(training_batch_accuracy)
            print(f"epoch: {epoch}, batch: {batch_index}: batch accuracy: {training_batch_accuracy}")
        training_accuracy = sum(training_accuracy_list) / len(training_accuracy_list)
        training_accuracy_history.append(training_accuracy)
        print(f"training accuracy: {training_accuracy}")

        # 檢查驗證集
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_y = split_batch_xy(batch)
            predicted = model(batch_x)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
        validating_accuracy = sum(validating_accuracy_list) / len(validating_accuracy_list)
        validating_accuracy_history.append(validating_accuracy)
        print(f"validating accuracy: {validating_accuracy}")

        # 記錄最高的驗證集正確率與當時的模型狀態,判斷是否在 20 次訓練后仍然沒有重繪記錄
        if validating_accuracy > validating_accuracy_highest:
            validating_accuracy_highest = validating_accuracy
            validating_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest validating accuracy updated")
        elif epoch - validating_accuracy_highest_epoch > 20:
            # 在 20 次訓練后仍然沒有重繪記錄,結束訓練
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用達到最高正確率時的模型狀態
    print(f"highest validating accuracy: {validating_accuracy_highest}",
        f"from epoch {validating_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 檢查測驗集
    testing_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_y = split_batch_xy(batch)
        predicted = model(batch_x)
        testing_accuracy_list.append(calc_accuracy(batch_y, predicted))
    testing_accuracy = sum(testing_accuracy_list) / len(testing_accuracy_list)
    print(f"testing accuracy: {testing_accuracy}")

    # 顯示訓練集和驗證集的正確率變化
    pyplot.plot(training_accuracy_history, label="training")
    pyplot.plot(validating_accuracy_history, label="validing")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用訓練好的模型"""
    # 創建模型實體,加載訓練好的狀態,然后切換到驗證模式
    model = MyModel().to(device)
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 詢問圖片路徑,并顯示所有可能是人臉的區域
    while True:
        try:
            # 選取可能出現物件的區域一覽
            image_path = input("Image path: ")
            if not image_path:
                continue
            img = cv2.imread(image_path)
            candidate_boxes = selective_search(img)
            # 構建輸入
            image_tensors = []
            for candidate_box in candidate_boxes:
                x, y, w, h = candidate_box
                child_img = img[y:y+h, x:x+w].copy()
                image_tensors.append(image_to_tensor(child_img))
            tensor_in = torch.stack(image_tensors).to(device)
            # 預測輸出
            tensor_out = model(tensor_in)
            # 使用 softmax 計算是人臉的概率
            tensor_out = nn.functional.softmax(tensor_out, dim=1)
            tensor_out = tensor_out[:,1].resize(tensor_out.shape[0])
            # 判斷概率大于 99% 的是人臉,添加邊框到圖片并保存
            img_output = img.copy()
            indices = torch.where(tensor_out > 0.99)[0]
            result_boxes = []
            result_boxes_all = []
            for index in indices:
                box = candidate_boxes[index]
                for exists_box in result_boxes_all:
                    # 如果和現存找到的區域重疊度大于 30% 則跳過
                    if calc_iou(exists_box, box) > 0.30:
                        break
                else:
                    result_boxes.append(box)
                result_boxes_all.append(box)
            for box in result_boxes:
                x, y, w, h = box
                print(x, y, w, h)
                cv2.rectangle(img_output, (x, y), (x+w, y+h), (0, 0, 0xff), 1)
            cv2.imwrite("img_output.png", img_output)
            print("saved to img_output.png")
            print()
        except Exception as e:
            print("error:", e)

def main():
    """主函式"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 給亂數生成器分配一個初始值,使得每次運行都可以生成相同的亂數
    # 這是為了讓程序可重現,你也可以選擇不這樣做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根據命令列引數選擇操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

和之前文章給出的代碼例子一樣,這份代碼也分為了 prepare, train, eval 三個部分,其中 prepare 部分負責選取區域,提取正樣本 (包含人臉的區域) 和負樣本 (不包含人臉的區域) 的子圖片;train 使用普通的 resnet 模型學習子圖片;eval 針對給出的圖片選取區域并識別所有區域中是否包含人臉,

除了選取區域和提取子圖片的處理以外,基本上和之前介紹的 CNN 模型一樣吧??,

執行以下命令以后:

python3 example.py prepare
python3 example.py train

的最終輸出如下:

epoch: 101, batch: 106: batch accuracy: 0.9999996838862198
epoch: 101, batch: 107: batch accuracy: 0.999218446914751
epoch: 101, batch: 108: batch accuracy: 0.9999996211125055
training accuracy: 0.999441394076678
validating accuracy: 0.9687856357743619
stop training because highest validating accuracy not updated in 20 epoches
highest validating accuracy: 0.9766918253771755 from epoch 80
testing accuracy: 0.9729761086851993

訓練集和驗證集的正確率變化如下:

正確率看起來很高,但這只是針對選取后的區域判斷的正確率,因為選取演算法效果比較一般并且樣本數量比較少,所以最終效果不能說令人滿意??,

執行以下命令,再輸入圖片路徑可以使用學習好的模型識別圖片:

python3 example.py eval

以下是部分識別結果:

精度一般般??,

Fast-RCNN

RCNN 慢的原因主要是因為識別幾千個子圖片的計算量非常龐大,特別是這幾千個子圖片的范圍很多是重合的,導致了很多重復的計算,Fast-RCNN 著重改善了這一部分,首先會針對整張圖片生成一個與圖片長寬相同 (或者等比例縮放) 的特征資料,然后再根據可能包含物件的區域截取特征資料,然后再根據截取后的子特征資料識別分類,RCNN 與 Fast-RCNN 的區別如下圖所示:

遺憾的是 Fast-RCNN 只是改善了速度,并不會改善正確率,但下面介紹的例子會引入一個比較重要的處理,即調整區域范圍,它可以讓模型給出的區域更接近實際的區域,

以下是 Fast-RCNN 模型中的一些處理細節,

縮放來源圖片

在 RCNN 中,傳給 CNN 模型的圖片是經過縮放的子圖片,而在 Fast-RCNN 中我們需要傳原圖片給 CNN 模型,那么原圖片也需要進行縮放,縮放使用的方法是填充法,如下圖所示:

縮放圖片使用的代碼如下 (opencv 版):

IMAGE_SIZE = (128, 88)

def calc_resize_parameters(sw, sh):
    """計算縮放圖片的引數"""
    sw_new, sh_new = sw, sh
    dw, dh = IMAGE_SIZE
    pad_w, pad_h = 0, 0
    if sw / sh < dw / dh:
        sw_new = int(dw / dh * sh)
        pad_w = (sw_new - sw) // 2 # 填充左右
    else:
        sh_new = int(dh / dw * sw)
        pad_h = (sh_new - sh) // 2 # 填充上下
    return sw_new, sh_new, pad_w, pad_h

def resize_image(img):
    """縮放 opencv 圖片,比例不一致時填充"""
    sh, sw, _ = img.shape
    sw_new, sh_new, pad_w, pad_h = calc_resize_parameters(sw, sh)
    img = cv2.copyMakeBorder(img, pad_h, pad_h, pad_w, pad_w, cv2.BORDER_CONSTANT, (0, 0, 0))
    img = cv2.resize(img, dsize=IMAGE_SIZE)
    return img

縮放圖片后區域的坐標也需要轉換,轉換的代碼如下 (都是枯燥的代碼??):

IMAGE_SIZE = (128, 88)

def map_box_to_resized_image(box, sw, sh):
    """把原始區域轉換到縮放后的圖片對應的區域"""
    x, y, w, h = box
    sw_new, sh_new, pad_w, pad_h = calc_resize_parameters(sw, sh)
    scale = IMAGE_SIZE[0] / sw_new
    x = int((x + pad_w) * scale)
    y = int((y + pad_h) * scale)
    w = int(w * scale)
    h = int(h * scale)
    if x + w > IMAGE_SIZE[0] or y + h > IMAGE_SIZE[1] or w == 0 or h == 0:
        return 0, 0, 0, 0
    return x, y, w, h

def map_box_to_original_image(box, sw, sh):
    """把縮放后圖片對應的區域轉換到縮放前的原始區域"""
    x, y, w, h = box
    sw_new, sh_new, pad_w, pad_h = calc_resize_parameters(sw, sh)
    scale = IMAGE_SIZE[0] / sw_new
    x = int(x / scale - pad_w)
    y = int(y / scale - pad_h)
    w = int(w / scale)
    h = int(h / scale)
    if x + w > sw or y + h > sh or x < 0 or y < 0 or w == 0 or h == 0:
        return 0, 0, 0, 0
    return x, y, w, h

計算區域特征

在前面的文章中我們已經了解過,CNN 模型可以分為卷積層,池化層和全連接層,卷積層,池化層用于抽取圖片中各個區域的特征,全連接層用于把特征扁平化并交給線性模型處理,在 Fast-RCNN 中,我們不需要使用整張圖片的特征,只需要使用部磁區域的特征,所以 Fast-RCNN 使用的 CNN 模型只需要卷積層和池化層 (部分模型池化層可以省略),卷積層輸出的通道數量通常會比圖片原有的通道數量多,并且長寬會按原來圖片的長寬等比例縮小,例如原圖的大小是 3,256,256 的時候,經過處理可能會輸出 512,32,32,代表每個 8x8 像素的區域都對應 512 個特征,

這篇給出的 Fast-RCN 代碼為了易于理解,會讓 CNN 模型輸出和原圖一模一樣的大小,這樣抽取區域特征的時候只需要使用 [] 運算子即可,

抽取區域特征 (ROI Pooling)

Fast-RCNN 根據整張圖片生成特征以后,下一步就是抽取區域特征 (Region of interest Pooling) 了,抽取區域特征簡單的來說就是根據區域在圖片中的位置,截區域中該位置的資料,然后再縮放到相同大小,如下圖所示:

抽取區域特征的層又稱為 ROI 層,

如果特征的長寬和圖片的長寬相同,那么截取特征只需要簡單的 [] 操作,但如果特征的長寬比圖片的長寬要小,那么就需要使用近鄰插值法 (Nearest Neighbor Interpolation) 或者雙線插值法 (Bilinear Interpolation) 進行截取,使用雙線插值法進行截取的 ROI 層又稱作 ROI Align,截取以后的縮放可以使用 MaxPool,近鄰插值法或雙線插值法等演算法,

想更好的理解 ROI Align 與雙線插值法可以參考這篇文章,

調整區域范圍

在前面已經提到過,使用選擇搜索法等演算法選取出來的區域與物件實際所在的區域可能有一定偏差,這個偏差是可以通過模型來調整的,舉個簡單的例子,如果區域內有臉的左半部分,那么模型在經過學習后應該可以判斷出區域應該向右擴展一些,

區域調整可以分為四個引數:

  • 對左上角 x 坐標的調整
  • 對左上角 y 坐標的調整
  • 對長度的調整
  • 對寬度的調整

因為坐標和長寬的值大小不一定,例如同樣是臉的左半部分,出現在圖片的左上角和圖片的右下角就會讓 x y 坐標不一樣,如果遠近不同那么長寬也會不一樣,我們需要把調整量作標準化,標準化的公式如下:

  • x1, y1, w1, h1 = 候選區域
  • x2, y2, w2, h2 = 真實區域
  • x 偏移 = (x2 - x1) / w1
  • y 偏移 = (y2 - y1) / h1
  • w 偏移 = log(w2 / w1)
  • h 偏移 = log(h2 / h1)

經過標準化后,偏移的值就會作為比例而不是絕對值,不會受具體坐標和長寬的影響,此外,公式中使用 log 是為了減少偏移的增幅,使得偏移比較大的時候模型仍然可以達到比較好的學習效果,

計算區域調整偏移和根據偏移調整區域的代碼如下:

def calc_box_offset(candidate_box, true_box):
    """計算候選區域與實際區域的偏移值"""
    x1, y1, w1, h1 = candidate_box
    x2, y2, w2, h2 = true_box
    x_offset = (x2 - x1) / w1
    y_offset = (y2 - y1) / h1
    w_offset = math.log(w2 / w1)
    h_offset = math.log(h2 / h1)
    return (x_offset, y_offset, w_offset, h_offset)

def adjust_box_by_offset(candidate_box, offset):
    """根據偏移值調整候選區域"""
    x1, y1, w1, h1 = candidate_box
    x_offset, y_offset, w_offset, h_offset = offset
    x2 = w1 * x_offset + x1
    y2 = h1 * y_offset + y1
    w2 = math.exp(w_offset) * w1
    h2 = math.exp(h_offset) * h1
    return (x2, y2, w2, h2)

計算損失

Fast-RCNN 模型會針對各個區域輸出兩個結果,第一個是區域對應的標簽 (人臉,非人臉),第二個是上面提到的區域偏移,調整引數的時候也需要同時根據這兩個結果調整,實作同時調整多個結果可以把損失相加起來再計算各個引數的導函式值:

各個區域的特征 = ROI層(CNN模型(圖片資料))

計算標簽的線性模型(各個區域的特征) - 真實標簽 = 標簽損失
計算偏移的線性模型(各個區域的特征) - 真實偏移 = 偏移損失

損失 = 標簽損失 + 偏移損失

有一個需要注意的地方是,在這個例子里計算標簽損失需要分別根據正負樣本計算,否則模型在經過調整以后只會輸出負結果,這是因為線性模型計算抽取出來的特征時有可能輸出正 (人臉),也有可能輸出負 (非人臉),而 ROI 層抽取的特征很多是重合的,也就是來源相同,當負樣本比正樣本要多的時候,結果的方向就會更偏向于負,這樣每次調整引數的時候都會向輸出負的方向調整,如果把損失分開計算,那么不重合的特征可以分別向輸出正負的方向調整,從而達到學習的效果,

此外,偏移損失只應該根據正樣本計算,負樣本沒有必要學習偏移,

最終的損失計算處理如下:

各個區域的特征 = ROI層(CNN模型(圖片資料))

計算標簽的線性模型(各個區域的特征)[正樣本] - 真實標簽[正樣本] = 正樣本標簽損失
計算標簽的線性模型(各個區域的特征)[負樣本] - 真實標簽[負樣本] = 負樣本標簽損失
計算偏移的線性模型(各個區域的特征)[正樣本] - 真實偏移[正樣本] = 正樣本偏移損失

損失 = 正樣本標簽損失 + 負樣本標簽損失 + 正樣本偏移損失

合并結果區域

因為選取區域的演算法本來就會回傳很多重合的區域,可能會有有好幾個區域同時和真實區域重疊率大于一定值 (70%),導致這幾個區域都會被認為是包含物件的區域:

模型經過學習后,針對圖片預測得出結果時也有可能回傳這樣的重合區域,合并這樣的區域有幾種方法:

  • 使用最左,最右,最上,或者最下的區域
  • 使用第一個區域 (區域選取演算法會按出現物件的可能性排序)
  • 結合所有重合的區域 (如果區域調整效果不行,則可能出現結果區域比真實區域大很多的問題)

上面給出的 RCNN 代碼例子已經使用第二個方法合并結果區域,下面給出的例子也會使用同樣的方法,但下一篇文章的 Faster-RCNN 則會使用第三個方法,因為 Faster-RCNN 的區域調整效果相對比較好,

原始論文

如果你想看 Fast-RCNN 的原始論文可以到以下的地址:

https://arxiv.org/pdf/1504.08083.pdf

使用 Fast-RCNN 識別圖片中的人臉

代碼時間到了??,這份代碼會使用 Fast-RCNN 模型來圖片中的人臉,使用的資料集和前面的例子一樣,

import os
import sys
import torch
import gzip
import itertools
import random
import numpy
import math
import pandas
import cv2
from torch import nn
from matplotlib import pyplot
from collections import defaultdict

# 縮放圖片的大小
IMAGE_SIZE = (256, 256)
# 分析目標的圖片所在的檔案夾
IMAGE_DIR = "./784145_1347673_bundle_archive/train/image_data"
# 定義各個圖片中人臉區域的 CSV 檔案
BOX_CSV_PATH = "./784145_1347673_bundle_archive/train/bbox_train.csv"

# 用于啟用 GPU 支持
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")

class BasicBlock(nn.Module):
    """ResNet 使用的基礎塊"""
    expansion = 1 # 定義這個塊的實際出通道是 channels_out 的幾倍,這里的實作固定是一倍
    def __init__(self, channels_in, channels_out, stride):
        super().__init__()
        # 生成 3x3 的卷積層
        # 處理間隔 stride = 1 時,輸出的長寬會等于輸入的長寬,例如 (32-3+2)//1+1 == 32
        # 處理間隔 stride = 2 時,輸出的長寬會等于輸入的長寬的一半,例如 (32-3+2)//2+1 == 16
        # 此外 resnet 的 3x3 卷積層不使用偏移值 bias
        self.conv1 = nn.Sequential(
            nn.Conv2d(channels_in, channels_out, kernel_size=3, stride=stride, padding=1, bias=False),
            nn.BatchNorm2d(channels_out))
        # 再定義一個讓輸出和輸入維度相同的 3x3 卷積層
        self.conv2 = nn.Sequential(
            nn.Conv2d(channels_out, channels_out, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(channels_out))
        # 讓原始輸入和輸出相加的時候,需要維度一致,如果維度不一致則需要整合
        self.identity = nn.Sequential()
        if stride != 1 or channels_in != channels_out * self.expansion:
            self.identity = nn.Sequential(
                nn.Conv2d(channels_in, channels_out * self.expansion, kernel_size=1, stride=stride, bias=False),
                nn.BatchNorm2d(channels_out * self.expansion))

    def forward(self, x):
        # x => conv1 => relu => conv2 => + => relu
        # |                              ^
        # |==============================|
        tmp = self.conv1(x)
        tmp = nn.functional.relu(tmp, inplace=True)
        tmp = self.conv2(tmp)
        tmp += self.identity(x)
        y = nn.functional.relu(tmp, inplace=True)
        return y

class MyModel(nn.Module):
    """Fast-RCNN (基于 ResNet-18 的變種)"""
    def __init__(self):
        super().__init__()
        # 記錄上一層的出通道數量
        self.previous_channels_out = 4
        # 把 3 通道轉換到 4 通道,長寬不變
        self.conv1 = nn.Sequential(
            nn.Conv2d(3, self.previous_channels_out, kernel_size=3, stride=1, padding=1, bias=False),
            nn.BatchNorm2d(self.previous_channels_out))
        # 抽取圖片各個區域特征的 ResNet (除去 AvgPool 和全連接層)
        # 和原始的 Resnet 不一樣的是輸出的長寬和輸入的長寬會相等,以便 ROI 層按區域抽取R征
        # 此外,為了可以讓模型跑在 4GB 顯存上,這里減少了模型的通道數量
        self.layer1 = self._make_layer(BasicBlock, channels_out=4, num_blocks=2, stride=1)
        self.layer2 = self._make_layer(BasicBlock, channels_out=4, num_blocks=2, stride=1)
        self.layer3 = self._make_layer(BasicBlock, channels_out=8, num_blocks=2, stride=1)
        self.layer4 = self._make_layer(BasicBlock, channels_out=8, num_blocks=2, stride=1)
        # ROI 層抽取各個子區域特征后轉換到固定大小
        self.roi_pool = nn.AdaptiveMaxPool2d((5, 5))
        # 輸出兩個分類 [非人臉, 人臉]
        self.fc_labels_model = nn.Sequential(
            nn.Linear(8*5*5, 32),
            nn.ReLU(),
            nn.Dropout(0.1),
            nn.Linear(32, 2))
        # 計算區域偏移,分別輸出 x, y, w, h 的偏移
        self.fc_offsets_model = nn.Sequential(
            nn.Linear(8*5*5, 128),
            nn.ReLU(),
            nn.Dropout(0.1),
            nn.Linear(128, 4))

    def _make_layer(self, block_type, channels_out, num_blocks, stride):
        blocks = []
        # 添加第一個塊
        blocks.append(block_type(self.previous_channels_out, channels_out, stride))
        self.previous_channels_out = channels_out * block_type.expansion
        # 添加剩余的塊,剩余的塊固定處理間隔為 1,不會改變長寬
        for _ in range(num_blocks-1):
            blocks.append(block_type(self.previous_channels_out, self.previous_channels_out, 1))
            self.previous_channels_out *= block_type.expansion
        return nn.Sequential(*blocks)

    def _roi_pooling(self, feature_mapping, roi_boxes):
        result = []
        for box in roi_boxes:
            image_index, x, y, w, h = map(int, box.tolist())
            feature_sub_region = feature_mapping[image_index][:,x:x+w,y:y+h]
            fixed_features = self.roi_pool(feature_sub_region).reshape(-1) # 順道扁平化
            result.append(fixed_features)
        return torch.stack(result)

    def forward(self, x):
        images_tensor = x[0]
        candidate_boxes_tensor = x[1]
        # 轉換出通道
        tmp = self.conv1(images_tensor)
        tmp = nn.functional.relu(tmp)
        # 應用 ResNet 的各個層
        # 結果維度是 B,32,W,H
        tmp = self.layer1(tmp)
        tmp = self.layer2(tmp)
        tmp = self.layer3(tmp)
        tmp = self.layer4(tmp)
        # 使用 ROI 層抽取各個子區域的特征并轉換到固定大小
        # 結果維度是 B,32*9*9
        tmp = self._roi_pooling(tmp, candidate_boxes_tensor)
        # 根據抽取出來的子區域特征分別計算分類 (是否人臉) 和區域偏移
        labels = self.fc_labels_model(tmp)
        offsets = self.fc_offsets_model(tmp)
        y = (labels, offsets)
        return y

def save_tensor(tensor, path):
    """保存 tensor 物件到檔案"""
    torch.save(tensor, gzip.GzipFile(path, "wb"))

def load_tensor(path):
    """從檔案讀取 tensor 物件"""
    return torch.load(gzip.GzipFile(path, "rb"))

def calc_resize_parameters(sw, sh):
    """計算縮放圖片的引數"""
    sw_new, sh_new = sw, sh
    dw, dh = IMAGE_SIZE
    pad_w, pad_h = 0, 0
    if sw / sh < dw / dh:
        sw_new = int(dw / dh * sh)
        pad_w = (sw_new - sw) // 2 # 填充左右
    else:
        sh_new = int(dh / dw * sw)
        pad_h = (sh_new - sh) // 2 # 填充上下
    return sw_new, sh_new, pad_w, pad_h

def resize_image(img):
    """縮放 opencv 圖片,比例不一致時填充"""
    sh, sw, _ = img.shape
    sw_new, sh_new, pad_w, pad_h = calc_resize_parameters(sw, sh)
    img = cv2.copyMakeBorder(img, pad_h, pad_h, pad_w, pad_w, cv2.BORDER_CONSTANT, (0, 0, 0))
    img = cv2.resize(img, dsize=IMAGE_SIZE)
    return img

def image_to_tensor(img):
    """轉換 opencv 圖片物件到 tensor 物件"""
    # 注意 opencv 是 BGR,但對訓練沒有影響所以不用轉為 RGB
    arr = numpy.asarray(img)
    t = torch.from_numpy(arr)
    t = t.transpose(0, 2) # 轉換維度 H,W,C 到 C,W,H
    t = t / 255.0 # 正規化數值使得范圍在 0 ~ 1
    return t

def map_box_to_resized_image(box, sw, sh):
    """把原始區域轉換到縮放后的圖片對應的區域"""
    x, y, w, h = box
    sw_new, sh_new, pad_w, pad_h = calc_resize_parameters(sw, sh)
    scale = IMAGE_SIZE[0] / sw_new
    x = int((x + pad_w) * scale)
    y = int((y + pad_h) * scale)
    w = int(w * scale)
    h = int(h * scale)
    if x + w > IMAGE_SIZE[0] or y + h > IMAGE_SIZE[1] or w == 0 or h == 0:
        return 0, 0, 0, 0
    return x, y, w, h

def map_box_to_original_image(box, sw, sh):
    """把縮放后圖片對應的區域轉換到縮放前的原始區域"""
    x, y, w, h = box
    sw_new, sh_new, pad_w, pad_h = calc_resize_parameters(sw, sh)
    scale = IMAGE_SIZE[0] / sw_new
    x = int(x / scale - pad_w)
    y = int(y / scale - pad_h)
    w = int(w / scale)
    h = int(h / scale)
    if x + w > sw or y + h > sh or x < 0 or y < 0 or w == 0 or h == 0:
        return 0, 0, 0, 0
    return x, y, w, h

def calc_iou(rect1, rect2):
    """計算兩個區域重疊部分 / 合并部分的比率 (intersection over union)"""
    x1, y1, w1, h1 = rect1
    x2, y2, w2, h2 = rect2
    xi = max(x1, x2)
    yi = max(y1, y2)
    wi = min(x1+w1, x2+w2) - xi
    hi = min(y1+h1, y2+h2) - yi
    if wi > 0 and hi > 0: # 有重疊部分
        area_overlap = wi*hi
        area_all = w1*h1 + w2*h2 - area_overlap
        iou = area_overlap / area_all
    else: # 沒有重疊部分
        iou = 0
    return iou

def calc_box_offset(candidate_box, true_box):
    """計算候選區域與實際區域的偏移值"""
    # 這里計算出來的偏移值基于比例,而不受具體位置和大小影響
    # w h 使用 log 是為了減少過大的值的影響
    x1, y1, w1, h1 = candidate_box
    x2, y2, w2, h2 = true_box
    x_offset = (x2 - x1) / w1
    y_offset = (y2 - y1) / h1
    w_offset = math.log(w2 / w1)
    h_offset = math.log(h2 / h1)
    return (x_offset, y_offset, w_offset, h_offset)

def adjust_box_by_offset(candidate_box, offset):
    """根據偏移值調整候選區域"""
    x1, y1, w1, h1 = candidate_box
    x_offset, y_offset, w_offset, h_offset = offset
    x2 = w1 * x_offset + x1
    y2 = h1 * y_offset + y1
    w2 = math.exp(w_offset) * w1
    h2 = math.exp(h_offset) * h1
    return (x2, y2, w2, h2)

def selective_search(img):
    """計算 opencv 圖片中可能出現物件的區域,只回傳頭 2000 個區域"""
    # 演算法參考 https://www.learnopencv.com/selective-search-for-object-detection-cpp-python/
    s = cv2.ximgproc.segmentation.createSelectiveSearchSegmentation()
    s.setBaseImage(img)
    s.switchToSelectiveSearchFast()
    boxes = s.process()
    return boxes[:2000]

def prepare_save_batch(batch, image_tensors, image_candidate_boxes, image_labels, image_box_offsets):
    """準備訓練 - 保存單個批次的資料"""
    # 按索引值串列生成輸入和輸出 tensor 物件的函式
    def split_dataset(indices):
        image_in = []
        candidate_boxes_in = []
        labels_out = []
        offsets_out = []
        for new_image_index, original_image_index in enumerate(indices):
            image_in.append(image_tensors[original_image_index])
            for box, label, offset in zip(image_candidate_boxes, image_labels, image_box_offsets):
                box_image_index, x, y, w, h = box
                if box_image_index == original_image_index:
                    candidate_boxes_in.append((new_image_index, x, y, w, h))
                    labels_out.append(label)
                    offsets_out.append(offset)
        # 檢查計算是否有問題
        # for box, label in zip(candidate_boxes_in, labels_out):
        #    image_index, x, y, w, h = box
        #    child_img = image_in[image_index][:, x:x+w, y:y+h].transpose(0, 2) * 255
        #    cv2.imwrite(f"{image_index}_{x}_{y}_{w}_{h}_{label}.png", child_img.numpy())
        tensor_image_in = torch.stack(image_in) # 維度: B,C,W,H
        tensor_candidate_boxes_in = torch.tensor(candidate_boxes_in, dtype=torch.float) # 維度: N,5 (index, x, y, w, h)
        tensor_labels_out = torch.tensor(labels_out, dtype=torch.long) # 維度: N
        tensor_box_offsets_out = torch.tensor(offsets_out, dtype=torch.float) # 維度: N,4 (x_offset, y_offset, ..)
        return (tensor_image_in, tensor_candidate_boxes_in), (tensor_labels_out, tensor_box_offsets_out)

    # 切分訓練集 (80%),驗證集 (10%) 和測驗集 (10%)
    random_indices = torch.randperm(len(image_tensors))
    training_indices = random_indices[:int(len(random_indices)*0.8)]
    validating_indices = random_indices[int(len(random_indices)*0.8):int(len(random_indices)*0.9):]
    testing_indices = random_indices[int(len(random_indices)*0.9):]
    training_set = split_dataset(training_indices)
    validating_set = split_dataset(validating_indices)
    testing_set = split_dataset(testing_indices)

    # 保存到硬碟
    save_tensor(training_set, f"data/training_set.{batch}.pt")
    save_tensor(validating_set, f"data/validating_set.{batch}.pt")
    save_tensor(testing_set, f"data/testing_set.{batch}.pt")
    print(f"batch {batch} saved")

def prepare():
    """準備訓練"""
    # 資料集轉換到 tensor 以后會保存在 data 檔案夾下
    if not os.path.isdir("data"):
        os.makedirs("data")

    # 加載 csv 檔案,構建圖片到區域串列的索引 { 圖片名: [ 區域, 區域, .. ] }
    box_map = defaultdict(lambda: [])
    df = pandas.read_csv(BOX_CSV_PATH)
    for row in df.values:
        filename, width, height, x1, y1, x2, y2 = row[:7]
        box_map[filename].append((x1, y1, x2-x1, y2-y1))

    # 從圖片里面提取人臉 (正樣本) 和非人臉 (負樣本) 的圖片
    batch_size = 50
    max_samples = 10
    batch = 0
    image_tensors = [] # 圖片串列
    image_candidate_boxes = [] # 各個圖片的候選區域串列
    image_labels = [] # 各個圖片的候選區域對應的標簽 (1 人臉 0 非人臉)
    image_box_offsets = [] # 各個圖片的候選區域與真實區域的偏移值
    for filename, true_boxes in box_map.items():
        path = os.path.join(IMAGE_DIR, filename)
        img_original = cv2.imread(path) # 加載原始圖片
        sh, sw, _ = img_original.shape # 原始圖片大小
        img = resize_image(img_original) # 縮放圖片
        candidate_boxes = selective_search(img) # 查找候選區域
        true_boxes = [ map_box_to_resized_image(b, sw, sh) for b in true_boxes ] # 縮放實際區域
        image_index = len(image_tensors) # 圖片在批次中的索引值
        image_tensors.append(image_to_tensor(img.copy()))
        positive_samples = 0
        negative_samples = 0
        for candidate_box in candidate_boxes:
            # 如果候選區域和任意一個實際區域重疊率大于 70%,則認為是正樣本
            # 如果候選區域和所有實際區域重疊率都小于 30%,則認為是負樣本
            # 每個圖片最多添加正樣本數量 + 10 個負樣本,需要提供足夠多負樣本避免偽陽性判斷
            iou_list = [ calc_iou(candidate_box, true_box) for true_box in true_boxes ]
            positive_index = next((index for index, iou in enumerate(iou_list) if iou > 0.70), None)
            is_negative = all(iou < 0.30 for iou in iou_list)
            result = None
            if positive_index is not None:
                result = True
                positive_samples += 1
            elif is_negative and negative_samples < positive_samples + 10:
                result = False
                negative_samples += 1
            if result is not None:
                x, y, w, h = candidate_box
                # 檢驗計算是否有問題
                # child_img = img[y:y+h, x:x+w].copy()
                # cv2.imwrite(f"{filename}_{x}_{y}_{w}_{h}_{int(result)}.png", child_img)
                image_candidate_boxes.append((image_index, x, y, w, h))
                image_labels.append(int(result))
                if positive_index is not None:
                    image_box_offsets.append(calc_box_offset(
                        candidate_box, true_boxes[positive_index])) # 正樣本添加偏移值
                else:
                    image_box_offsets.append((0, 0, 0, 0)) # 負樣本無偏移
            if positive_samples >= max_samples:
                break
        # 保存批次
        if len(image_tensors) >= batch_size:
            prepare_save_batch(batch, image_tensors, image_candidate_boxes, image_labels, image_box_offsets)
            image_tensors.clear()
            image_candidate_boxes.clear()
            image_labels.clear()
            image_box_offsets.clear()
            batch += 1
    # 保存剩余的批次
    if len(image_tensors) > 10:
        prepare_save_batch(batch, image_tensors, image_candidate_boxes, image_labels, image_box_offsets)

def train():
    """開始訓練"""
    # 創建模型實體
    model = MyModel().to(device)

    # 創建多任務損失計算器
    celoss = torch.nn.CrossEntropyLoss()
    mseloss = torch.nn.MSELoss()
    def loss_function(predicted, actual):
        # 標簽損失必須根據正負樣本分別計算,否則會導致預測結果總是為負的問題
        positive_indices = actual[0].nonzero(as_tuple=True)[0] # 正樣本的索引值串列
        negative_indices = (actual[0] == 0).nonzero(as_tuple=True)[0] # 負樣本的索引值串列
        loss1 = celoss(predicted[0][positive_indices], actual[0][positive_indices]) # 正樣本標簽的損失
        loss2 = celoss(predicted[0][negative_indices], actual[0][negative_indices]) # 負樣本標簽的損失
        loss3 = mseloss(predicted[1][positive_indices], actual[1][positive_indices]) # 偏移值的損失,僅針對正樣本計算
        return loss1 + loss2 + loss3

    # 創建引數調整器
    optimizer = torch.optim.Adam(model.parameters())

    # 記錄訓練集和驗證集的正確率變化
    training_label_accuracy_history = []
    training_offset_accuracy_history = []
    validating_label_accuracy_history = []
    validating_offset_accuracy_history = []

    # 記錄最高的驗證集正確率
    validating_label_accuracy_highest = -1
    validating_label_accuracy_highest_epoch = 0
    validating_offset_accuracy_highest = -1
    validating_offset_accuracy_highest_epoch = 0

    # 讀取批次的工具函式
    def read_batches(base_path):
        for batch in itertools.count():
            path = f"{base_path}.{batch}.pt"
            if not os.path.isfile(path):
                break
            yield [ [ tt.to(device) for tt in t ] for t in load_tensor(path) ]

    # 計算正確率的工具函式
    def calc_accuracy(actual, predicted):
        # 標簽正確率,正樣本和負樣本的正確率分別計算再平均
        predicted_i = torch.max(predicted[0], 1).indices
        acc_positive = ((actual[0] > 0.5) & (predicted_i > 0.5)).sum().item() / ((actual[0] > 0.5).sum().item() + 0.00001)
        acc_negative = ((actual[0] <= 0.5) & (predicted_i <= 0.5)).sum().item() / ((actual[0] <= 0.5).sum().item() + 0.00001)
        acc_label = (acc_positive + acc_negative) / 2
        # print(acc_positive, acc_negative)
        # 偏移值正確率
        valid_indices = actual[1].nonzero(as_tuple=True)[0]
        if valid_indices.shape[0] == 0:
            acc_offset = 1
        else:
            acc_offset = (1 - (predicted[1][valid_indices] - actual[1][valid_indices]).abs().mean()).item()
            acc_offset = max(acc_offset, 0)
        return acc_label, acc_offset

    # 開始訓練程序
    for epoch in range(1, 10000):
        print(f"epoch: {epoch}")

        # 根據訓練集訓練并修改引數
        model.train()
        training_label_accuracy_list = []
        training_offset_accuracy_list = []
        for batch_index, batch in enumerate(read_batches("data/training_set")):
            # 劃分輸入和輸出
            batch_x, batch_y = batch
            # 計算預測值
            predicted = model(batch_x)
            # 計算損失
            loss = loss_function(predicted, batch_y)
            # 從損失自動微分求導函式值
            loss.backward()
            # 使用引數調整器調整引數
            optimizer.step()
            # 清空導函式值
            optimizer.zero_grad()
            # 記錄這一個批次的正確率,torch.no_grad 代表臨時禁用自動微分功能
            with torch.no_grad():
                training_batch_label_accuracy, training_batch_offset_accuracy = calc_accuracy(batch_y, predicted)
            # 輸出批次正確率
            training_label_accuracy_list.append(training_batch_label_accuracy)
            training_offset_accuracy_list.append(training_batch_offset_accuracy)
            print(f"epoch: {epoch}, batch: {batch_index}: " +
                f"batch label accuracy: {training_batch_label_accuracy}, offset accuracy: {training_batch_offset_accuracy}")
        training_label_accuracy = sum(training_label_accuracy_list) / len(training_label_accuracy_list)
        training_offset_accuracy = sum(training_offset_accuracy_list) / len(training_offset_accuracy_list)
        training_label_accuracy_history.append(training_label_accuracy)
        training_offset_accuracy_history.append(training_offset_accuracy)
        print(f"training label accuracy: {training_label_accuracy}, offset accuracy: {training_offset_accuracy}")

        # 檢查驗證集
        model.eval()
        validating_label_accuracy_list = []
        validating_offset_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_y = batch
            predicted = model(batch_x)
            validating_batch_label_accuracy, validating_batch_offset_accuracy = calc_accuracy(batch_y, predicted)
            validating_label_accuracy_list.append(validating_batch_label_accuracy)
            validating_offset_accuracy_list.append(validating_batch_offset_accuracy)
        validating_label_accuracy = sum(validating_label_accuracy_list) / len(validating_label_accuracy_list)
        validating_offset_accuracy = sum(validating_offset_accuracy_list) / len(validating_offset_accuracy_list)
        validating_label_accuracy_history.append(validating_label_accuracy)
        validating_offset_accuracy_history.append(validating_offset_accuracy)
        print(f"validating label accuracy: {validating_label_accuracy}, offset accuracy: {validating_offset_accuracy}")

        # 記錄最高的驗證集正確率與當時的模型狀態,判斷是否在 20 次訓練后仍然沒有重繪記錄
        if validating_label_accuracy > validating_label_accuracy_highest:
            validating_label_accuracy_highest = validating_label_accuracy
            validating_label_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest label validating accuracy updated")
        elif validating_offset_accuracy > validating_offset_accuracy_highest:
            validating_offset_accuracy_highest = validating_offset_accuracy
            validating_offset_accuracy_highest_epoch = epoch
            save_tensor(model.state_dict(), "model.pt")
            print("highest offset validating accuracy updated")
        elif (epoch - validating_label_accuracy_highest_epoch > 20 and
            epoch - validating_offset_accuracy_highest_epoch > 20):
            # 在 20 次訓練后仍然沒有重繪記錄,結束訓練
            print("stop training because highest validating accuracy not updated in 20 epoches")
            break

    # 使用達到最高正確率時的模型狀態
    print(f"highest label validating accuracy: {validating_label_accuracy_highest}",
        f"from epoch {validating_label_accuracy_highest_epoch}")
    print(f"highest offset validating accuracy: {validating_offset_accuracy_highest}",
        f"from epoch {validating_offset_accuracy_highest_epoch}")
    model.load_state_dict(load_tensor("model.pt"))

    # 檢查測驗集
    testing_label_accuracy_list = []
    testing_offset_accuracy_list = []
    for batch in read_batches("data/testing_set"):
        batch_x, batch_y = batch
        predicted = model(batch_x)
        testing_batch_label_accuracy, testing_batch_offset_accuracy = calc_accuracy(batch_y, predicted)
        testing_label_accuracy_list.append(testing_batch_label_accuracy)
        testing_offset_accuracy_list.append(testing_batch_offset_accuracy)
    testing_label_accuracy = sum(testing_label_accuracy_list) / len(testing_label_accuracy_list)
    testing_offset_accuracy = sum(testing_offset_accuracy_list) / len(testing_offset_accuracy_list)
    print(f"testing label accuracy: {testing_label_accuracy}, offset accuracy: {testing_offset_accuracy}")

    # 顯示訓練集和驗證集的正確率變化
    pyplot.plot(training_label_accuracy_history, label="training_label_accuracy")
    pyplot.plot(training_offset_accuracy_history, label="training_offset_accuracy")
    pyplot.plot(validating_label_accuracy_history, label="validing_label_accuracy")
    pyplot.plot(validating_offset_accuracy_history, label="validing_offset_accuracy")
    pyplot.ylim(0, 1)
    pyplot.legend()
    pyplot.show()

def eval_model():
    """使用訓練好的模型"""
    # 創建模型實體,加載訓練好的狀態,然后切換到驗證模式
    model = MyModel().to(device)
    model.load_state_dict(load_tensor("model.pt"))
    model.eval()

    # 詢問圖片路徑,并顯示所有可能是人臉的區域
    while True:
        try:
            # 選取可能出現物件的區域一覽
            image_path = input("Image path: ")
            if not image_path:
                continue
            img_original = cv2.imread(image_path) # 加載原始圖片
            sh, sw, _ = img_original.shape # 原始圖片大小
            img = resize_image(img_original) # 縮放圖片
            candidate_boxes = selective_search(img) # 查找候選區域
            # 構建輸入
            image_tensor = image_to_tensor(img).unsqueeze(dim=0).to(device) # 維度: 1,C,W,H
            candidate_boxes_tensor = torch.tensor(
                [ (0, x, y, w, h) for x, y, w, h in candidate_boxes ],
                dtype=torch.float).to(device) # 維度: N,5
            tensor_in = (image_tensor, candidate_boxes_tensor)
            # 預測輸出
            labels, offsets = model(tensor_in)
            labels = nn.functional.softmax(labels, dim=1)
            labels = labels[:,1].resize(labels.shape[0])
            # 判斷概率大于 90% 的是人臉,按偏移值調整區域,添加邊框到圖片并保存
            img_output = img_original.copy()
            for box, label, offset in zip(candidate_boxes, labels, offsets):
                if label.item() <= 0.99:
                    continue
                box = adjust_box_by_offset(box, offset.tolist())
                x, y, w, h = map_box_to_original_image(box, sw, sh)
                if w == 0 or h == 0:
                    continue
                print(x, y, w, h)
                cv2.rectangle(img_output, (x, y), (x+w, y+h), (0, 0, 0xff), 1)
            cv2.imwrite("img_output.png", img_output)
            print("saved to img_output.png")
            print()
        except Exception as e:
            print("error:", e)

def main():
    """主函式"""
    if len(sys.argv) < 2:
        print(f"Please run: {sys.argv[0]} prepare|train|eval")
        exit()

    # 給亂數生成器分配一個初始值,使得每次運行都可以生成相同的亂數
    # 這是為了讓程序可重現,你也可以選擇不這樣做
    random.seed(0)
    torch.random.manual_seed(0)

    # 根據命令列引數選擇操作
    operation = sys.argv[1]
    if operation == "prepare":
        prepare()
    elif operation == "train":
        train()
    elif operation == "eval":
        eval_model()
    else:
        raise ValueError(f"Unsupported operation: {operation}")

if __name__ == "__main__":
    main()

執行以下命令以后:

python3 example.py prepare
python3 example.py train

在 31 輪訓練以后的輸出如下 (因為訓練時間實在長,這里偷懶了??):

epoch: 31, batch: 112: batch label accuracy: 0.9805490565092065, offset accuracy: 0.9293316006660461
epoch: 31, batch: 113: batch label accuracy: 0.9776784565994586, offset accuracy: 0.9191392660140991
epoch: 31, batch: 114: batch label accuracy: 0.9469732184008024, offset accuracy: 0.9101274609565735
training label accuracy: 0.9707166603858259, offset accuracy: 0.9191886570142663
validating label accuracy: 0.9306134214845806, offset accuracy: 0.9205827381299889
highest offset validating accuracy updated

執行以下命令,再輸入圖片路徑可以使用學習好的模型識別圖片:

python3 example.py eval

以下是部分識別結果:

調整區域前

調整區域后

調整區域前

調整區域后

精度和 RCNN 差不多,甚至有些降低了 (為了支持 4G 顯存縮放圖片了),不過識別速度有很大的提升,在同一個環境下,Fast-RCNN 處理單張圖片只需要 0.4~0.5 秒,而 RCNN 則需要 2 秒左右,

寫在最后

這篇介紹的 RCNN 與 Fast-RCNN 只是用于入門物件識別的,實用價值并不大 (速度慢,識別精度低),下一篇介紹的 Faster-RCNN 則是可以用于生產的模型,但復雜程度也會高一個等級??,

此外,這篇文章和下一篇文章的代碼實作和論文中的實作、網上的其他實作不完全一樣,這是因為我的機器顯存較低,并且我想用盡量少的代碼來實作相同的原理,使得代碼更容易理解 (網上很多實作都是分一堆檔案,甚至把部分邏輯使用 c/c++ 擴展實作,性能上有好處但是初學者看了會頭大),

轉載請註明出處,本文鏈接:https://www.uj5u.com/qita/228773.html

標籤:其他

上一篇:FaceBoxes: 高精度的CPU實時人臉檢測器

下一篇:RefineDet

標籤雲
其他(157675) Python(38076) JavaScript(25376) Java(17977) C(15215) 區塊鏈(8255) C#(7972) AI(7469) 爪哇(7425) MySQL(7132) html(6777) 基礎類(6313) sql(6102) 熊猫(6058) PHP(5869) 数组(5741) R(5409) Linux(5327) 反应(5209) 腳本語言(PerlPython)(5129) 非技術區(4971) Android(4554) 数据框(4311) css(4259) 节点.js(4032) C語言(3288) json(3245) 列表(3129) 扑(3119) C++語言(3117) 安卓(2998) 打字稿(2995) VBA(2789) Java相關(2746) 疑難問題(2699) 细绳(2522) 單片機工控(2479) iOS(2429) ASP.NET(2402) MongoDB(2323) 麻木的(2285) 正则表达式(2254) 字典(2211) 循环(2198) 迅速(2185) 擅长(2169) 镖(2155) 功能(1967) .NET技术(1958) Web開發(1951) python-3.x(1918) HtmlCss(1915) 弹簧靴(1913) C++(1909) xml(1889) PostgreSQL(1872) .NETCore(1853) 谷歌表格(1846) Unity3D(1843) for循环(1842)

熱門瀏覽
  • 網閘典型架構簡述

    網閘架構一般分為兩種:三主機的三系統架構網閘和雙主機的2+1架構網閘。 三主機架構分別為內端機、外端機和仲裁機。三機無論從軟體和硬體上均各自獨立。首先從硬體上來看,三機都用各自獨立的主板、記憶體及存盤設備。從軟體上來看,三機有各自獨立的作業系統。這樣能達到完全的三機獨立。對于“2+1”系統,“2”分為 ......

    uj5u.com 2020-09-10 02:00:44 more
  • 如何從xshell上傳檔案到centos linux虛擬機里

    如何從xshell上傳檔案到centos linux虛擬機里及:虛擬機CentOs下執行 yum -y install lrzsz命令,出現錯誤:鏡像無法找到軟體包 前言 一、安裝lrzsz步驟 二、上傳檔案 三、遇到的問題及解決方案 總結 前言 提示:其實很簡單,往虛擬機上安裝一個上傳檔案的工具 ......

    uj5u.com 2020-09-10 02:00:47 more
  • 一、SQLMAP入門

    一、SQLMAP入門 1、判斷是否存在注入 sqlmap.py -u 網址/id=1 id=1不可缺少。當注入點后面的引數大于兩個時。需要加雙引號, sqlmap.py -u "網址/id=1&uid=1" 2、判斷文本中的請求是否存在注入 從文本中加載http請求,SQLMAP可以從一個文本檔案中 ......

    uj5u.com 2020-09-10 02:00:50 more
  • Metasploit 簡單使用教程

    metasploit 簡單使用教程 浩先生, 2020-08-28 16:18:25 分類專欄: kail 網路安全 linux 文章標簽: linux資訊安全 編輯 著作權 metasploit 使用教程 前言 一、Metasploit是什么? 二、準備作業 三、具體步驟 前言 Msfconsole ......

    uj5u.com 2020-09-10 02:00:53 more
  • 游戲逆向之驅動層與用戶層通訊

    驅動層代碼: #pragma once #include <ntifs.h> #define add_code CTL_CODE(FILE_DEVICE_UNKNOWN,0x800,METHOD_BUFFERED,FILE_ANY_ACCESS) /* 更多游戲逆向視頻www.yxfzedu.com ......

    uj5u.com 2020-09-10 02:00:56 more
  • 北斗電力時鐘(北斗授時服務器)讓網路資料更精準

    北斗電力時鐘(北斗授時服務器)讓網路資料更精準 北斗電力時鐘(北斗授時服務器)讓網路資料更精準 京準電子科技官微——ahjzsz 近幾年,資訊技術的得了快速發展,互聯網在逐漸普及,其在人們生活和生產中都得到了廣泛應用,并且取得了不錯的應用效果。計算機網路資訊在電力系統中的應用,一方面使電力系統的運行 ......

    uj5u.com 2020-09-10 02:01:03 more
  • 【CTF】CTFHub 技能樹 彩蛋 writeup

    ?碎碎念 CTFHub:https://www.ctfhub.com/ 筆者入門CTF時時剛開始刷的是bugku的舊平臺,后來才有了CTFHub。 感覺不論是網頁UI設計,還是題目質量,賽事跟蹤,工具軟體都做得很不錯。 而且因為獨到的金幣制度的確讓人有一種想去刷題賺金幣的感覺。 個人還是非常喜歡這個 ......

    uj5u.com 2020-09-10 02:04:05 more
  • 02windows基礎操作

    我學到了一下幾點 Windows系統目錄結構與滲透的作用 常見Windows的服務詳解 Windows埠詳解 常用的Windows注冊表詳解 hacker DOS命令詳解(net user / type /md /rd/ dir /cd /net use copy、批處理 等) 利用dos命令制作 ......

    uj5u.com 2020-09-10 02:04:18 more
  • 03.Linux基礎操作

    我學到了以下幾點 01Linux系統介紹02系統安裝,密碼啊破解03Linux常用命令04LAMP 01LINUX windows: win03 8 12 16 19 配置不繁瑣 Linux:redhat,centos(紅帽社區版),Ubuntu server,suse unix:金融機構,證券,銀 ......

    uj5u.com 2020-09-10 02:04:30 more
  • 05HTML

    01HTML介紹 02頭部標簽講解03基礎標簽講解04表單標簽講解 HTML前段語言 js1.了解代碼2.根據代碼 懂得挖掘漏洞 (POST注入/XSS漏洞上傳)3.黑帽seo 白帽seo 客戶網站被黑帽植入劫持代碼如何處理4.熟悉html表單 <html><head><title>TDK標題,描述 ......

    uj5u.com 2020-09-10 02:04:36 more
最新发布
  • 2023年最新微信小程式抓包教程

    01 開門見山 隔一個月發一篇文章,不過分。 首先回顧一下《微信系結手機號資料庫被脫庫事件》,我也是第一時間得知了這個訊息,然后跟蹤了整件事情的經過。下面是這起事件的相關截圖以及近日流出的一萬條資料樣本: 個人認為這件事也沒什么,還不如關注一下之前45億快遞資料查詢渠道疑似在近日復活的訊息。 訊息是 ......

    uj5u.com 2023-04-20 08:48:24 more
  • web3 產品介紹:metamask 錢包 使用最多的瀏覽器插件錢包

    Metamask錢包是一種基于區塊鏈技術的數字貨幣錢包,它允許用戶在安全、便捷的環境下管理自己的加密資產。Metamask錢包是以太坊生態系統中最流行的錢包之一,它具有易于使用、安全性高和功能強大等優點。 本文將詳細介紹Metamask錢包的功能和使用方法。 一、 Metamask錢包的功能 數字資 ......

    uj5u.com 2023-04-20 08:47:46 more
  • vulnhub_Earth

    前言 靶機地址->>>vulnhub_Earth 攻擊機ip:192.168.20.121 靶機ip:192.168.20.122 參考文章 https://www.cnblogs.com/Jing-X/archive/2022/04/03/16097695.html https://www.cnb ......

    uj5u.com 2023-04-20 07:46:20 more
  • 從4k到42k,軟體測驗工程師的漲薪史,給我看哭了

    清明節一過,盲猜大家已經無心上班,在數著日子準備過五一,但一想到銀行卡里的余額……瞬間心情就不美麗了。最近,2023年高校畢業生就業調查顯示,本科畢業月平均起薪為5825元。調查一出,便有很多同學表示自己又被平均了。看著這一資料,不免讓人想到前不久中國青年報的一項調查:近六成大學生認為畢業10年內會 ......

    uj5u.com 2023-04-20 07:44:00 more
  • 最新版本 Stable Diffusion 開源 AI 繪畫工具之中文自動提詞篇

    🎈 標簽生成器 由于輸入正向提示詞 prompt 和反向提示詞 negative prompt 都是使用英文,所以對學習母語的我們非常不友好 使用網址:https://tinygeeker.github.io/p/ai-prompt-generator 這個網址是為了讓大家在使用 AI 繪畫的時候 ......

    uj5u.com 2023-04-20 07:43:36 more
  • 漫談前端自動化測驗演進之路及測驗工具分析

    隨著前端技術的不斷發展和應用程式的日益復雜,前端自動化測驗也在不斷演進。隨著 Web 應用程式變得越來越復雜,自動化測驗的需求也越來越高。如今,自動化測驗已經成為 Web 應用程式開發程序中不可或缺的一部分,它們可以幫助開發人員更快地發現和修復錯誤,提高應用程式的性能和可靠性。 ......

    uj5u.com 2023-04-20 07:43:16 more
  • CANN開發實踐:4個DVPP記憶體問題的典型案例解讀

    摘要:由于DVPP媒體資料處理功能對存放輸入、輸出資料的記憶體有更高的要求(例如,記憶體首地址128位元組對齊),因此需呼叫專用的記憶體申請介面,那么本期就分享幾個關于DVPP記憶體問題的典型案例,并給出原因分析及解決方法。 本文分享自華為云社區《FAQ_DVPP記憶體問題案例》,作者:昇騰CANN。 DVPP ......

    uj5u.com 2023-04-20 07:43:03 more
  • msf學習

    msf學習 以kali自帶的msf為例 一、msf核心模塊與功能 msf模塊都放在/usr/share/metasploit-framework/modules目錄下 1、auxiliary 輔助模塊,輔助滲透(埠掃描、登錄密碼爆破、漏洞驗證等) 2、encoders 編碼器模塊,主要包含各種編碼 ......

    uj5u.com 2023-04-20 07:42:59 more
  • Halcon軟體安裝與界面簡介

    1. 下載Halcon17版本到到本地 2. 雙擊安裝包后 3. 步驟如下 1.2 Halcon軟體安裝 界面分為四大塊 1. Halcon的五個助手 1) 影像采集助手:與相機連接,設定相機引數,采集影像 2) 標定助手:九點標定或是其它的標定,生成標定檔案及內參外參,可以將像素單位轉換為長度單位 ......

    uj5u.com 2023-04-20 07:42:17 more
  • 在MacOS下使用Unity3D開發游戲

    第一次發博客,先發一下我的游戲開發環境吧。 去年2月份買了一臺MacBookPro2021 M1pro(以下簡稱mbp),這一年來一直在用mbp開發游戲。我大致分享一下我的開發工具以及使用體驗。 1、Unity 官網鏈接: https://unity.cn/releases 我一般使用的Apple ......

    uj5u.com 2023-04-20 07:40:19 more