寫給程式設計師的機器學習入門 (十二) - 臉部關鍵點檢測

q303248153發表於2021-03-10

在前幾篇文章中我們看到了怎樣檢測圖片上的物體,例如人臉,那麼把實現人臉識別的時候是不是可以把圖片中的人臉擷取出來再交給識別人臉的模型呢?下面的流程是可行的,但因為人臉的範圍不夠準確,擷取出來的人臉並不在圖片的正中心,對於識別人臉的模型來說,資料質量不夠好就會導致識別的效果打折。

這一篇文章會介紹如何使用機器學習檢測臉部關鍵點 (眼睛鼻子嘴巴的位置),檢測圖片上的人臉以後,再檢測臉部關鍵點,然後基於臉部關鍵點來調整人臉範圍,再根據調整後的人臉範圍擷取人臉並交給後面的模型,即可提升資料質量改善識別效果。

臉部關鍵點檢測模型

臉部關鍵點檢測模型其實就是普通的 CNN 模型,在第八篇文章中已經介紹過?,第八篇文章中,輸入是圖片,輸出是分類 (例如動物的分類,或者驗證碼中的字母分類)。而這一篇文章輸入同樣是圖片,輸出則是各個臉部關鍵點的座標:

我們會讓模型輸出五個關鍵點 (左眼中心,右眼中心,鼻尖,嘴巴左邊角,嘴巴右邊角) 的 x 座標與 y 座標,合計一共 10 個輸出。

模型輸出的座標值範圍會落在 -1 ~ 1 之間,這是把圖片左上角視為 -1,-1,右下角視為 1,1 以後正規化的座標值。不使用絕對值的原因是機器學習模型並不適合處理較大的值,並且使用相對座標可以讓處理不同大小圖片的邏輯更加簡單。你可能會問為什麼不像前一篇介紹的 YOLO 一樣,讓座標值範圍落在 0 ~ 1 之間,這是因為下面會使用仿射變換來增加人臉樣本,而仿射變換要求相對座標在 -1 ~ 1 之間,讓座標值範圍落在 -1 ~ 1 之間可以省掉轉換的步驟。

訓練使用的資料集

準備資料集是機器學習中最頭疼的部分,一般來說我們需要上百度搜尋人臉的圖片,然後一張一張的擷取,再手動標記各個器官的位置,但這樣太苦累了?。這篇還是像之前的文章一樣,從網上找一個現成的資料集來訓練,偷個懶?。

使用的資料集:

https://www.kaggle.com/drgilermo/face-images-with-marked-landmark-points

下載回來以後可以看到以下的檔案:

face_images.npz
facial_keypoints.csv

face_images.npz 是使用 zip 壓縮後的 numpy 資料轉儲檔案,把檔名改為 face_images.zip 以後再解壓縮即可得到 face_images.npy 檔案。

之後再執行 python 命令列,輸入以下程式碼載入資料內容:

>>> import numpy
>>> data = numpy.load("face_images.npy")
>>> data.shape
(96, 96, 7049)

可以看到資料包含了 7049 張 96x96 的黑白人臉圖片。

再輸入以下程式碼儲存第一章圖片:

>>> import torch
>>> data = torch.from_numpy(data).float()
>>> data.shape
torch.Size([96, 96, 7049])
# 把通道放在最前面
>>> data = data.permute(2, 0, 1)
>>> data.shape
torch.Size([7049, 96, 96])
# 提取第一張圖片的資料並儲存
>>> from PIL import Image
>>> img = Image.fromarray(data[0].numpy()).convert("RGB")
>>> img.save("1.png")

這就是提取出來的圖片:

對應以下的座標,座標的值可以在 facial_keypoints.csv 中找到:

left_eye_center_x,left_eye_center_y,right_eye_center_x,right_eye_center_y,left_eye_inner_corner_x,left_eye_inner_corner_y,left_eye_outer_corner_x,left_eye_outer_corner_y,right_eye_inner_corner_x,right_eye_inner_corner_y,right_eye_outer_corner_x,right_eye_outer_corner_y,left_eyebrow_inner_end_x,left_eyebrow_inner_end_y,left_eyebrow_outer_end_x,left_eyebrow_outer_end_y,right_eyebrow_inner_end_x,right_eyebrow_inner_end_y,right_eyebrow_outer_end_x,right_eyebrow_outer_end_y,nose_tip_x,nose_tip_y,mouth_left_corner_x,mouth_left_corner_y,mouth_right_corner_x,mouth_right_corner_y,mouth_center_top_lip_x,mouth_center_top_lip_y,mouth_center_bottom_lip_x,mouth_center_bottom_lip_y
66.0335639098,39.0022736842,30.2270075188,36.4216781955,59.582075188,39.6474225564,73.1303458647,39.9699969925,36.3565714286,37.3894015038,23.4528721805,37.3894015038,56.9532631579,29.0336481203,80.2271278195,32.2281383459,40.2276090226,29.0023218045,16.3563789474,29.6474706767,44.4205714286,57.0668030075,61.1953082707,79.9701654135,28.6144962406,77.3889924812,43.3126015038,72.9354586466,43.1307067669,84.4857744361

各個座標對應 csv 中的欄位如下:

  • 左眼中心點的 x 座標: left_eye_center_x
  • 左眼中心點的 y 座標: left_eye_center_y
  • 右眼中心點的 x 座標: right_eye_center_x
  • 右眼中心點的 y 座標: right_eye_center_y
  • 鼻尖的 x 座標: nose_tip_x
  • 鼻尖的 y 座標: nose_tip_y
  • 嘴巴左邊角的 x 座標: mouth_left_corner_x
  • 嘴巴左邊角的 y 座標: mouth_left_corner_y
  • 嘴巴右邊角的 x 座標: mouth_right_corner_x
  • 嘴巴右邊角的 y 座標: mouth_right_corner_y

csv 中還有更多的座標但我們只使用這些?。

接下來定義一個在圖片上標記關鍵點的函式:

from PIL import ImageDraw

DefaultPointColors = ["#FF0000", "#FF00FF", "#00FF00", "#00FFFF", "#FFFF00"]
def draw_points(img, points, colors = None, radius=1):
    """在圖片上描畫關鍵點"""
    draw = ImageDraw.Draw(img)
    colors = colors or DefaultPointColors
    for index, point in enumerate(points):
        x, y = point
        color = colors[index] if index < len(colors) else colors[0]
        draw.ellipse((x-radius, y-radius, x+radius, y+radius), fill=color, width=1)

再使用這個函式標記圖片即可得到:

使用仿射變換增加人臉樣本

仔細觀察 csv 中的座標值,你可能會發現裡面的座標大多都是很接近的,例如左眼中心點的 x 座標大部分都落在 65 ~ 75 之間。這是因為資料中的人臉圖片都是經過處理的,佔比和位置比較標準。如果我們直接拿這個資料集來訓練,那麼模型只會輸出學習過的區間的值,這是再拿一張佔比和位置不標準的人臉圖片給模型,模型就會輸出錯誤的座標。

解決這個問題我們可以隨機旋轉移動縮放人臉以增加資料量,在第十篇文章我們學到怎樣用仿射變換來提取圖片中的某個區域並縮放到固定的大小,仿射變換還可以用來實現旋轉移動和縮放,批量計算時的效率非常高。

首先我們需要以下的變數:

  • 弧度,範圍是 -π ~ π,對應 -180°~ 180°
  • 縮放比例,1 代表 100%
  • 橫向移動量:範圍是 -1 ~ 1,把圖片中心視為 0,左邊視為 -1,右邊視為 1
  • 縱向移動量:範圍是 -1 ~ 1,把圖片中心視為 0,左邊視為 -1,右邊視為 1

根據這些變數生成仿射變換引數的公式如下:

需要注意的是仿射變換引數用於轉換 目標座標來源座標,在處理圖片的時候可以根據目標畫素找到來源畫素,然後設定來源畫素的值到目標畫素的值實現各種變形操作。上述的引數只能用於處理圖片,如果我們想計算變形以後的圖片對應的座標,我們還需要一個轉換 來源座標目標座標 的仿射變換引數,計算相反的仿射變換引數的公式如下:

翻譯到程式碼如下:

def generate_transform_theta(angle, scale, x_offset, y_offset, inverse=False):
    """
    計算變形引數
    angle: 範圍 -math.pi ~ math.pi
    scale: 1 代表 100%
    x_offset: 範圍 -1 ~ 1
    y_offset: 範圍 -1 ~ 1
    inverse: 是否計算相反的變形引數 (預設計算把目標座標轉換為來源座標的引數)
    """
    cos_a = math.cos(angle)
    sin_a = math.sin(angle)
    if inverse:
        return (
            ( cos_a * scale, sin_a * scale, -x_offset * cos_a * scale - y_offset * sin_a * scale),
            (-sin_a * scale, cos_a * scale, -y_offset * cos_a * scale + x_offset * sin_a * scale))
    else:
        return (
            (cos_a / scale, -sin_a / scale, x_offset),
            (sin_a / scale,  cos_a / scale, y_offset))

變形後的人臉樣本如下,背景新增了隨機顏色讓模型更難作弊,具體程式碼參考後面的 prepare 函式吧?:

完整程式碼

完整程式碼的時間到了?,結構跟前面的文章一樣,分為 prepare, train, eval 三步。

import os
import sys
import torch
import gzip
import itertools
import random
import numpy
import math
import json
import pandas
import torchvision
from PIL import Image, ImageDraw
from torch import nn
from matplotlib import pyplot
from collections import defaultdict

# 圖片大小
IMAGE_SIZE = (96, 96)
# 訓練使用的資料集路徑
DATASET_PATH = "./dataset/face-images-with-marked-landmark-points/face_images.npy"
DATASET_CSV_PATH = "./dataset/face-images-with-marked-landmark-points/facial_keypoints.csv"
# 針對各張圖片隨機變形的數量
RANDOM_TRANSFORM_SAMPLES = 10

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

class FaceLandmarkModel(nn.Module):
    """
    檢測臉部關鍵點的模型 (基於 ResNet-18)
    針對圖片輸出:
    - 左眼中心點的 x 座標
    - 左眼中心點的 y 座標
    - 右眼中心點的 x 座標
    - 右眼中心點的 y 座標
    - 鼻尖的 x 座標
    - 鼻尖的 y 座標
    - 嘴巴左邊角的 x 座標
    - 嘴巴左邊角的 y 座標
    - 嘴巴右邊角的 x 座標
    - 嘴巴右邊角的 y 座標
    以上座標均在 0 ~ 1 的範圍內,表示相對圖片長寬的位置
    """

    def __init__(self):
        super().__init__()
        # Resnet 的實現
        self.resnet = torchvision.models.resnet18(num_classes=256)
        # 支援黑白圖片
        self.resnet.conv1 = nn.Conv2d(1, 64, kernel_size=7, stride=2, padding=3, bias=False)
        # 最終輸出關鍵點的線性模型
        # 因為 torchvision 的 resnet 最終會使用一個 Linear,這裡省略掉第一個 Lienar
        self.linear = nn.Sequential(
            nn.ReLU(inplace=True),
            nn.Linear(256, 128),
            nn.ReLU(inplace=True),
            nn.Linear(128, 10))

    def forward(self, x):
        tmp = self.resnet(x)
        y = self.linear(tmp)
        return y

    def detect_landmarks(self, images):
        """檢測給出圖片的關鍵點"""
        tensor_in = torch.stack([ image_to_tensor(resize_image(img)) for img in images ])
        tensor_out = self.forward(tensor_in.to(device))
        tensor_out = tensor_out.reshape(len(images), -1, 2)
        # 轉換 -1 ~ 1 的座標回絕對座標
        size = torch.tensor(IMAGE_SIZE, dtype=torch.float).to(device)
        tensor_out = (tensor_out + 1) / 2 * size
        result = []
        for image, points in zip(images, tensor_out):
            points_mapped = []
            for point in points:
                points_mapped.append(map_point_to_original_image(point.tolist(), *image.size))
            result.append(points_mapped)
        return result

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):
    """縮放圖片,比例不一致時填充"""
    sw, sh = img.size
    sw_new, sh_new, pad_w, pad_h = calc_resize_parameters(sw, sh)
    img_new = Image.new("RGB", (sw_new, sh_new))
    img_new.paste(img, (pad_w, pad_h))
    img_new = img_new.resize(IMAGE_SIZE)
    return img_new

def image_to_tensor(img):
    """縮放並轉換圖片物件到 tensor 物件 (黑白)"""
    img = img.convert("L") # 轉換到黑白圖片並縮放
    arr = numpy.asarray(img)
    t = torch.from_numpy(arr)
    t = t.unsqueeze(0) # 新增通道
    t = t / 255.0 # 正規化數值使得範圍在 0 ~ 1
    return t

def map_point_to_original_image(point, sw, sh):
    """把縮放後的座標轉換到縮放前的座標"""
    x, y = point
    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)
    x = min(max(0, x), sw - 1)
    y = min(max(0, y), sh - 1)
    return x, y

DefaultPointColors = ["#FF0000", "#FF00FF", "#00FF00", "#00FFFF", "#FFFF00"]
def draw_points(img, points, colors = None, radius=1):
    """在圖片上描畫關鍵點"""
    draw = ImageDraw.Draw(img)
    colors = colors or DefaultPointColors
    for index, point in enumerate(points):
        x, y = point
        color = colors[index] if index < len(colors) else colors[0]
        draw.ellipse((x-radius, y-radius, x+radius, y+radius), fill=color, width=1)

def generate_transform_theta(angle, scale, x_offset, y_offset, inverse=False):
    """
    計算變形引數
    angle: 範圍 -math.pi ~ math.pi
    scale: 1 代表 100%
    x_offset: 範圍 -1 ~ 1
    y_offset: 範圍 -1 ~ 1
    inverse: 是否計算相反的變形引數 (預設計算把目標座標轉換為來源座標的引數)
    """
    cos_a = math.cos(angle)
    sin_a = math.sin(angle)
    if inverse:
        return (
            ( cos_a * scale, sin_a * scale, -x_offset * cos_a * scale - y_offset * sin_a * scale),
            (-sin_a * scale, cos_a * scale, -y_offset * cos_a * scale + x_offset * sin_a * scale))
    else:
        return (
            (cos_a / scale, -sin_a / scale, x_offset),
            (sin_a / scale,  cos_a / scale, y_offset))

def prepare_save_batch(batch, image_tensors, point_tensors):
    """準備訓練 - 儲存單個批次的資料"""
    # 連線所有資料
    # image_tensor 的維度會變為 數量,1,W,H
    # point_tensor 的維度會變為 數量,10 (10 = 5 個關鍵點的 x y 座標)
    image_tensor = torch.cat(image_tensors, dim=0)
    image_tensor = image_tensor.unsqueeze(1)
    point_tensor = torch.cat(point_tensors, dim=0)
    point_tensor = point_tensor.reshape(point_tensor.shape[0], -1)

    # 切分訓練集 (80%),驗證集 (10%) 和測試集 (10%)
    random_indices = torch.randperm(image_tensor.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 = (image_tensor[training_indices], point_tensor[training_indices])
    validating_set = (image_tensor[validating_indices], point_tensor[validating_indices])
    testing_set = (image_tensor[testing_indices], point_tensor[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")

    # 載入原始資料集
    images_data = torch.from_numpy(numpy.load(DATASET_PATH)).float()
    images_csv = pandas.read_csv(DATASET_CSV_PATH, usecols=(
        "left_eye_center_x",
        "left_eye_center_y",
        "right_eye_center_x",
        "right_eye_center_y",
        "nose_tip_x",
        "nose_tip_y",
        "mouth_left_corner_x",
        "mouth_left_corner_y",
        "mouth_right_corner_x",
        "mouth_right_corner_y"
    ))

    # 原始資料的維度是 (W, H, 圖片數量),需要轉換為 (圖片數量, W, H)
    images_data = images_data.permute(2, 0, 1)

    # 處理原始資料集
    batch = 0
    batch_size = 20 # 實際會新增 batch_size * (1 + RANDOM_TRANSFORM_SAMPLES) 到每個批次
    image_tensors = []
    point_tensors = []
    for image_data, image_row in zip(images_data, images_csv.values):
        # 讀取圖片引數
        w = image_data.shape[0]
        h = image_data.shape[1]
        assert w == IMAGE_SIZE[0]
        assert h == IMAGE_SIZE[1]
        size = torch.tensor((w, h), dtype=torch.float)
        points = torch.tensor(image_row, dtype=torch.float).reshape(-1, 2)
        points_std = points / size * 2 - 1 # 左上角 -1,-1 右下角 1,1
        # 正規化圖片資料
        image_data = image_data / 255
        # 對圖片進行隨機變形
        thetas = []
        thetas_inverse = []
        for _ in range(RANDOM_TRANSFORM_SAMPLES):
            angle = math.pi * random.uniform(-0.1, 0.1)
            scale = random.uniform(0.30, 1.0)
            x_offset = random.uniform(-0.2, 0.2)
            y_offset = random.uniform(-0.2, 0.2)
            thetas.append(generate_transform_theta(angle, scale, x_offset, y_offset))
            thetas_inverse.append(generate_transform_theta(angle, scale, x_offset, y_offset, True))
        thetas_tensor = torch.tensor(thetas, dtype=torch.float)
        thetas_inverse_tensor = torch.tensor(thetas_inverse, dtype=torch.float)
        grid = nn.functional.affine_grid(
            thetas_tensor,
            torch.Size((RANDOM_TRANSFORM_SAMPLES, 1, w, h)),
            align_corners=False)
        images_transformed = nn.functional.grid_sample(
            image_data.repeat(RANDOM_TRANSFORM_SAMPLES, 1, 1)
                .reshape(RANDOM_TRANSFORM_SAMPLES, 1, w, h), grid)
        # 替換黑色的部分到隨機顏色
        random_color = torch.rand(images_transformed.shape)
        zero_indices = images_transformed == 0
        images_transformed[zero_indices] = random_color[zero_indices]
        # 轉換變形後的座標
        points_std_with_one = torch.cat((points_std, torch.ones(points_std.shape[0], 1)), dim=1)
        points_std_transformed = points_std_with_one.matmul(thetas_inverse_tensor.permute(0, 2, 1))
        # 連線原圖片和變形後的圖片,原座標和變形後的座標
        images_cat = torch.cat((image_data.unsqueeze(0), images_transformed.squeeze(1)), dim=0)
        points_std_cat = torch.cat((points_std.unsqueeze(0), points_std_transformed), dim=0)
        points_cat = (points_std_cat + 1) / 2 * size
        # 測試變形後的圖片與引數
        # for index, (img_data, points) in enumerate(zip(images_cat, points_cat)):
        #    img = Image.fromarray((img_data * 255).numpy()).convert("RGB")
        #    draw_points(img, points)
        #    img.save(f"test_{index}.png")
        # 儲存批次
        image_tensors.append(images_cat)
        point_tensors.append(points_std_cat)
        if len(image_tensors) > batch_size:
            prepare_save_batch(batch, image_tensors, point_tensors)
            image_tensors.clear()
            point_tensors.clear()
            batch += 1
    # 儲存剩餘的批次
    if len(image_tensors) > 10:
        prepare_save_batch(batch, image_tensors, point_tensors)

def train():
    """開始訓練"""
    # 建立模型例項
    model = FaceLandmarkModel().to(device)

    # 建立損失計算器
    def loss_function(predicted, actual):
        predicted_flatten = predicted.view(-1)
        actual_flatten = actual.view(-1)
        mask = torch.isnan(actual_flatten).logical_not() # 用於跳過缺損的值
        return nn.functional.mse_loss(predicted_flatten[mask], actual_flatten[mask])

    # 建立引數調整器
    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
            images_tensor, points_tensor = load_tensor(path)
            yield images_tensor.to(device), points_tensor.to(device)

    # 計算正確率的工具函式
    def calc_accuracy(actual, predicted):
        predicted_flatten = predicted.view(-1)
        actual_flatten = actual.view(-1)
        mask = torch.isnan(actual_flatten).logical_not() # 用於跳過缺損的值
        diff = (predicted_flatten[mask] - actual_flatten[mask]).abs()
        return 1 - diff.mean().item()

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

        # 根據訓練集訓練並修改引數
        # 切換模型到訓練模式,將會啟用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.train()
        training_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_accuracy = calc_accuracy(batch_y, predicted)
            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}")

        # 檢查驗證集
        # 切換模型到驗證模式,將會禁用自動微分,批次正規化 (BatchNorm) 與 Dropout
        model.eval()
        validating_accuracy_list = []
        for batch in read_batches("data/validating_set"):
            batch_x, batch_y = batch
            predicted = model(batch_x)
            validating_accuracy_list.append(calc_accuracy(batch_y, predicted))
            # 釋放 predicted 佔用的視訊記憶體避免視訊記憶體不足的錯誤
            predicted = None
        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 = 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 = FaceLandmarkModel().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 = Image.open(image_path)
            # 檢測關鍵點
            points = model.detect_landmarks([img])[0]
            for point in points:
                print(point)
            # 輸出圖片與關鍵點
            draw_points(img, points)
            img.save("img_output.png")
            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()

文章中的 Resnet 模型直接引用了 torchvision 中的實現,結構在第八篇文章已經介紹過,為了支援黑白圖片修改了 conv1 使得入通道數變為 1。

另外一個細節是部分關鍵點的資料是缺損的,例如只有左眼和右眼的座標,但是沒有鼻子和嘴巴的座標,缺損資料在讀取的時候會變為 nan,所以程式碼中計算損失和正確率的時候會排除掉值為 nan 的資料,這樣即使同一資料的部分座標缺損模型仍然可以學習沒有缺損的座標。

把程式碼儲存到 face_landmark.py,然後按以下的資料夾結構放程式碼和資料集:

  • dataset
    • face-images-with-marked-landmark-points
      • face_images.npy
      • facial_keypoints.csv
  • face_landmark.py

再執行以下命令即可開始訓練:

python3 face_landmark.py prepare
python3 face_landmark.py train

最終訓練結果如下:

epoch: 52, batch: 333: batch accuracy: 0.9883924638852477
epoch: 52, batch: 334: batch accuracy: 0.986928996630013
epoch: 52, batch: 335: batch accuracy: 0.9883735300973058
training accuracy: 0.9855450947513982
validating accuracy: 0.973902036840584
stop training because highest validating accuracy not updated in 20 epoches
highest validating accuracy: 0.976565406219812 from epoch 31
testing accuracy: 0.976561392748928

座標的偏差大約是 (1 - 0.976565406219812) * 2 即相對圖片長寬的 4.68% 左右?。

再執行以下命令即可使用訓練好的模型:

python3 face_landmark.py eval

以下是部分識別結果?:

有一定誤差,但是用來調整臉部範圍是足夠了。此外,訓練出來的模型檢測出來的鼻尖座標會稍微偏下,這是因為訓練資料中的大部分都是鼻子比較高的白人?,我們看到新聞裡面說人臉識別模型對於黑人識別率很低也是因為樣本中絕大部分都是白人,資料的偏向會直接影響模型的檢測結果?。

寫在最後

這篇比較簡單,下一篇將會介紹人臉識別模型,到時會像這篇最開始給出的圖片一樣結合三個模型實現。

最後罵一句部落格園的傻逼驗證碼,這種驗證碼只是拿來噁心使用者,對安全沒啥實質性的幫助?。

相關文章