0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ゼロから始めるArcFace顔認識モデルの構築

Last updated at Posted at 2024-10-23

本記事は、数年前に私が作成したチュートリアル「Build-Your-Own-Face-Model」の日本語翻訳版です。シリーズのブログ記事を1つにまとめ、人顔認識の初学者に少しでも役立つことを願っています。
私の日本語レベルが非常に限られているため、主にChatGPTを使用して翻訳し、一部の不一致な翻訳を修正しました。私にとって、これも日本語を学ぶ一つの方法です。しかし、誤りや不自然な表現が避けられない場合もありますので、ここでお詫び申し上げます。

自分自身の顔認識モデルをトレーニングしよう!

00 プロジェクトの説明

顔認識は、初期のSoftmax Embeddingから始まり、2015年にFacenetが提唱したtriple loss metric learningを経て、additional margin metric learningへと発展しました。このブログシリーズでは、2018年に提案されたArcFaceを実装しています。

依存関係

Python >= 3.6
pytorch >= 1.0
torchvision
imutils
pillow == 6.2.0
tqdm

データ準備

  • WebFaceおよびトレーニング用のクリーンな画像リストをダウンロード
  • テスト用にLFWおよびテストリストをダウンロード
  • WebFaceから不正データを削除するには、utils.py を使用してください

パラメータ設定

config.pyを参照してください。

トレーニング

単一マシンでの複数GPUトレーニングをサポートします。

export CUDA_VISIBLE_DEVICES=0,1
python train.py

テスト

python test.py

01. データ準備

ようこそ!本記事は「Build Your Own Face Recognition Model」シリーズブログの第1回目です。
このセクションでは、トレーニングに必要な顔データ(CASIA-WebFace と LFW)をダウンロードしていきます。

1 >> 始める前に

pip install imutils

imutilsは、画像処理に関連するツールキットであり、パス操作などの機能を利用します。

2 >> CASIA-WebFace のダウンロード

CASIA-WebFace 数据集只支持学术机构的免费使用,因此,个人开发者想使用这个数据集有两个办法:

CASIA-WebFace データセットは学術機関向けに無料で提供されており、個人開発者がこのデータセットを使用するには、次の2つの方法があります:

  • 他の CASIA-WebFace を使用している GitHub プロジェクトを見て、ダウンロードリンクが公開されているかを確認する
  • 国内のリソースサイトを検索する

3 >> CASIA-WebFace のクリーンアップ

CASIA-WebFace は未清掃の状態で約4GBの画像データがありますが、いくつかの不良画像が含まれています。誰かがこれらの画像をクリーンアップし、クリーンな画像リスト(cleaned_list.txt)を整理してくれました。

トレーニングを開始する前に、このクリーンな画像リストに基づいて、CASIA-WebFace から不良データを削除します。

cleaned_list.txtの内容は以下のようになっています:

0000045\001.jpg 0
0000045\002.jpg 0
0000045\003.jpg 0
0000045\004.jpg 0
0000045\005.jpg 0

私はLinuxで作業しているため、\/に置き換え、さらにパスの前にフォルダの位置を追加して絶対パスを形成したいと考えています。utils.py ファイルを作成し、次のコードを追加してください。

import os
import os.path as osp
from imutils import paths


def transform_clean_list(webface_directory, cleaned_list_path):
    """WebFace のクリーンなリスト形式を変換する
    Args:
        webface_directory: WebFace データディレクトリ
        cleaned_list_path: cleaned_list.txt のパス
    Returns:
        cleaned_list: 変換後のデータリスト
    """
    with open(cleaned_list_path, encoding='utf-8') as f:
        cleaned_list = f.readlines()
    cleaned_list = [p.replace('\\', '/') for p in cleaned_list]
    cleaned_list = [osp.join(webface_directory, p) for p in cleaned_list]
    return cleaned_list


if __name__ == '__main__':
    data = '/data/CASIA-WebFace/'
    lst = '/data/cleaned_list.txt'
    cleaned_list = transform_clean_list(data, lst)

現在、リストはこのようになっています。

/data/CASIA-WebFace/0000045/001.jpg 0
/data/CASIA-WebFace/0000045/002.jpg 0
/data/CASIA-WebFace/0000045/003.jpg 0
/data/CASIA-WebFace/0000045/004.jpg 0
/data/CASIA-WebFace/0000045/005.jpg 0

ご覧のとおり、私はデータを /data フォルダの下に配置しています。これは私個人の習慣的なやり方です。
これから、不良画像を削除する準備をします。utils.py に以下のコードを追加してください。

def remove_dirty_image(webface_directory, cleaned_list):
    cleaned_list = set([c.split()[0] for c in cleaned_list])
    for p in paths.list_images(webface_directory):
        if p not in cleaned_list:
            print(f"remove {p}")
            os.remove(p)

imutils.paths を使用して画像をリストアップし、webface_directory 内のすべての画像のパスを取得します。そして、このパスがクリーンリストに含まれているかどうかを確認し、含まれていなければ、その画像を削除します。

utils.pyの最後に1行のコードを追加します。

if __name__ == '__main__':
    data = '/data/CASIA-WebFace/'
    lst = '/data/cleaned_list.txt'
    cleaned_list = transform_clean_list(data, lst)
    remove_dirty_image(data, cleaned_list)  

これで、python3 utils.py を実行すると、不良画像の削除が始まります。これでトレーニングデータの準備が整いました。

4 >> LFWをダウンロード

私は CASIA-WebFace をトレーニングデータとして使用し、トレーニング後のモデルを LFW データセットで実行してモデルの性能を測定します。

ダウンロードリソースには lfw-align-128.tar.gzlfw_test_pair.txt が含まれています。私も同様にこれらを /data フォルダに配置し、画像を解凍します。

cd /data
tar -xvf lfw-align-128.tar.gz
rm lfw-align-128.tar.gz
ls
# lfw-align-128  lfw_test_pair.txt  CASIA-WebFace  cleaned list.txt

現在、テストデータも準備が整いました。

02. モデルアーキテクチャ

ようこそ!本記事は「Build Your Own Face Recognition Model」シリーズブログの第2回目です。
このセクションでは、モデルアーキテクチャ自体にのみ焦点を当てます!

1 >> 始める前に

このチュートリアルでは Pytorch を使用するため、依存関係を先にインストールしてください。

conda install pytorch torchvision -c pytorch

2 >> 構造設計

私はいくつかの GitHub リポジトリの実装を参考にし、自分が好きなスタイルでネットワーク構造を書きました。model フォルダの下に2つのアーキテクチャを提供しますが、ここではそのうちの1つだけを説明します。あなた自身のネットワーク構造を実装することもできます。

まず、model という名前のフォルダを作成し、空のファイル __init__.py を作成します。そして、fmobilenet.py というファイルを作成します。

複雑なネットワーク構造は、いくつかの非常にシンプルなブロック(block)を組み合わせることで構築できます。最初に、このような一連のブロックを作成します。それでは、始めましょう!

2.1 >> ブロックの設計

fmobilenet.py を開き、まず依存関係をインポートします。

import torch
import torch.nn as nn
import torch.nn.functional as F

最初のブロックは Flatten と呼ばれ、その機能はテンソルを平坦化することです。

class Flatten(nn.Module):
    def forward(self, x):
        return x.view(x.shape[0], -1)

Flatten は一連の畳み込み操作の後、全結合層の前で使用されます。深層学習モデルの畳み込み操作は通常4次元空間で行われ、全結合層は2次元空間で行われるため、Flatten は4次元空間を2次元空間に変換することができます。

2つ目のブロックは ConvBn と呼ばれ、畳み込み操作とバッチ正規化(BN)層を組み合わせたものです。

class ConvBn(nn.Module):

    def __init__(self, in_c, out_c, kernel=(1, 1), stride=1, padding=0, groups=1):
        super().__init__()
        self.net = nn.Sequential(
            nn.Conv2d(in_c, out_c, kernel, stride, padding, groups=groups, bias=False),
            nn.BatchNorm2d(out_c)
        )
        
    def forward(self, x):
        return self.net(x)

3つ目のブロックは ConvBnPrelu と呼ばれ、先ほど作成した ConvBn ブロックに PReLu 活性化層を追加しました。

class ConvBnPrelu(nn.Module):

    def __init__(self, in_c, out_c, kernel=(1, 1), stride=1, padding=0, groups=1):
        super().__init__()
        self.net = nn.Sequential(
            ConvBn(in_c, out_c, kernel, stride, padding, groups),
            nn.PReLU(out_c)
        )

    def forward(self, x):
        return self.net(x)

4つ目のブロックは DepthWise で、DepthWise 層は上で定義した ConvBnPreluConvBn を使用し、チャネルごとに畳み込み操作を行うことで効率的な計算を実現しています。注意が必要なのは、中間の ConvBnPrelu ブロックの groups=groups という部分です。

class DepthWise(nn.Module):

    def __init__(self, in_c, out_c, kernel=(3, 3), stride=2, padding=1, groups=1):
        super().__init__()
        self.net = nn.Sequential(
            ConvBnPrelu(in_c, groups, kernel=(1, 1), stride=1, padding=0),
            ConvBnPrelu(groups, groups, kernel=kernel, stride=stride, padding=paddinggroups=groups),
            ConvBn(groups, out_c, kernel=(1, 1), stride=1, padding=0),
        )

    def forward(self, x):
        return self.net(x)

5つ目のブロックは DepthWiseRes で、4つ目のブロックに元の入力を追加しています。これは ResNet 系列の要点です。

class DepthWiseRes(nn.Module):
    """DepthWise with Residual"""

    def __init__(self, in_c, out_c, kernel=(3, 3), stride=2, padding=1, groups=1):
        super().__init__()
        self.net = DepthWise(in_c, out_c, kernel, stride, padding, groups)

    def forward(self, x):
        return self.net(x) + x

6つ目のブロックは MultiDepthWiseRes で、前のブロックとは異なり、追加のパラメータ num_block を受け取り、このパラメータによっていくつの DepthWiseRes をスタックするかを決定します。これらの DepthWiseRes の入力と出力のチャネル数は同じであるため、いくつスタックしてもチャネル数の変化はありません。

class MultiDepthWiseRes(nn.Module):

    def __init__(self, num_block, channels, kernel=(3, 3), stride=1, padding=1, groups=1):
        super().__init__()

        self.net = nn.Sequential(*[
            DepthWiseRes(channels, channels, kernel, stride, padding, groups)
            for _ in range(num_block)
        ])

    def forward(self, x):
        return self.net(x)

これで、6種類のブロックの設計が完了しました。これらのブロックを使用してネットワーク構造を構築していきましょう!

2.2 >> ネットワーク構造

fmobilenet.pyにコードを追加し続けます。

class FaceMobileNet(nn.Module):

    def __init__(self, embedding_size):
        super().__init__()
        self.conv1 = ConvBnPrelu(1, 64, kernel=(3, 3), stride=2, padding=1)
        self.conv2 = ConvBn(64, 64, kernel=(3, 3), stride=1, padding=1, groups=64)
        self.conv3 = DepthWise(64, 64, kernel=(3, 3), stride=2, padding=1, groups=128)
        self.conv4 = MultiDepthWiseRes(num_block=4, channels=64, kernel=3, stride=1, padding=1, groups=128)
        self.conv5 = DepthWise(64, 128, kernel=(3, 3), stride=2, padding=1, groups=256)
        self.conv6 = MultiDepthWiseRes(num_block=6, channels=128, kernel=(3, 3), stride=1, padding=1, groups=256)
        self.conv7 = DepthWise(128, 128, kernel=(3, 3), stride=2, padding=1, groups=512)
        self.conv8 = MultiDepthWiseRes(num_block=2, channels=128, kernel=(3, 3), stride=1, padding=1, groups=256)
        self.conv9 = ConvBnPrelu(128, 512, kernel=(1, 1))
        self.conv10 = ConvBn(512, 512, groups=512, kernel=(7, 7))
        self.flatten = Flatten()
        self.linear = nn.Linear(2048, embedding_size, bias=False)
        self.bn = nn.BatchNorm1d(embedding_size)
        
    def forward(self, x):
        out = self.conv1(x)
        out = self.conv2(out)
        out = self.conv3(out)
        out = self.conv4(out)
        out = self.conv5(out)
        out = self.conv6(out)
        out = self.conv7(out)
        out = self.conv8(out)
        out = self.conv9(out)
        out = self.conv10(out)
        out = self.flatten(out)
        out = self.linear(out)
        out = self.bn(out)
        return out

前のセクションで6つのブロックを定義したので、FaceMobilenet の構造は一目瞭然です。まず、10種類の異なる畳み込みブロックをスタックし、その後 Flatten ブロックで入力を平坦化し、次に全結合層と1次元の BatchNorm 層を接続します。特に注目すべきは、この行のコードです。

class FaceMobilenet(nn.Module):
        # ... emit ...
        self.linear = nn.Linear(2048, embedding_size, bias=False)

入力は 1 x 128 x 128 であり、多層の畳み込みを経て 512 x 2 x 2、つまり 2048 に変わります。入力画像が畳み込み後にどのような次元になるのか分からない場合、または計算が面倒な場合は、ネットワークにダミーデータを渡すことでエラーメッセージがこの次元の値を教えてくれます。

また、ここでの embedding_size は外部から渡され、1つの顔をどのくらいの大きさのベクトルで表すかを示します。例えば、Facenet では128次元のベクトルを使って1つの顔を表現しますが、ここでは512次元を使用しています。

これで、私たちのネットワーク構造は設計完了です!

3 >> テスト

偽データを使ってネットワークをテストすることは非常に重要です。これにより、次元の不一致の問題を発見するのに役立ちます。fmobilenet.py に以下のコードを追加し続けます。

if __name__ == "__main__":
    from PIL import Image
    import numpy as np

    x = Image.open("../samples/009.jpg").convert('L')
    x = x.resize((128, 128))
    x = np.asarray(x, dtype=np.float32)
    x = x[None, None, ...]
    x = torch.from_numpy(x)
    net = FaceMobileNet(512)
    net.eval()
    with torch.no_grad():
        out = net(x)
    print(out.shape)

ここで開く画像のパスは必ず存在する必要があります!保存したら、コマンドラインで実行します。

python3 fmobilenet.py
# => torch.Size([1, 512])

03. 損失関数

ようこそ!本記事は「Build Your Own Face Recognition Model」シリーズブログの第3回目です。

このセクションでは、Focal Loss を設計します!

1 >> 始める前に

まず、Focal Loss の機能について理解しましょう:容易なサンプルが損失に与える寄与度を減らし、モデルが難しいサンプルに注目できるようにします。簡単なサンプルは一般に多数を占め、難しいサンプルは少数であるため、Focal Loss のこの特性により、モデルはより良い特徴を学習できるようになります。

Focal Loss は考え方の一つであり、その実装の形式にこだわらないため、さまざまな Focal Loss の実装があります。

2 >> Focal Lossの実装

model/ フォルダーの下に loss.py というファイルを新たに作成し、以下のコードを記入します:

import torch
import torch.nn as nn

class FocalLoss(nn.Module):

    def __init__(self, gamma=2):
        super().__init__()
        self.gamma = gamma
        self.ce = torch.nn.CrossEntropyLoss()

    def forward(self, input, target):
        logp = self.ce(input, target)
        p = torch.exp(-logp)
        loss = (1 - p) ** self.gamma * logp
        return loss.mean()

forward プロセスに注目します。p は分類が正しい確率と理解できます。簡単なサンプルの場合、p の値は比較的大きくなるため、(1-p)0に近づきます。つまり、これらの簡単に分類できるサンプルは損失への寄与が小さくなります。これが核心的な考え方です。gamma=2 は、Focal Loss を提案した論文 RetinaNet の経験則です。gamma=0 の場合、普通の CrossEntropyLoss に戻ります。

04. 評価関数

ようこそ!本記事は「Build Your Own Face Recognition Model」シリーズブログの第4回目です。

このセクションでは、2つの評価関数、CosFaceArcFace を実装します。

1 >> 始める前に

Facenet 時代の後、研究者たちはトリプレットの選択が面倒すぎると感じ、元々の Softmax Loss を基に改良を加え、SphereFace、CosFace、ArcFace のような 「Additional Margin Metric Loss」 を派生させました。

Additional Margin Metric Loss の本質は、訓練プロセスをより困難にすることで、モデルを磨き上げ、より良い訓練結果を得ることです。例えば、

私たちのデータセットに3人の人物がいると仮定し、モデルがこの3人に対する確率を出力した後、Softmax のアプローチは、ラベルに対応する確率値を3人の中で最大にすることです。以下の例のように。

Softmax: 入力 = '第3の人.jpg' -> モデル -> 確率 [0.2, 0.2, 0.7] -> タスク完了

Softmax は、入力がどの人であれ、その人の確率が最も高ければタスクが完了したと見なされます。

CosFace のような評価関数は、そんなに簡単ではありません。彼らのワークフローは次のようになります:

CosFace: 入力 = '第3の人.jpg' -> モデル -> 確率 [0.2, 0.2, 0.7]
-> トレーニングを強化し第3の人の確率から0.5を引く -> 確率 [0.2, 0.2, 0.2] -> 未完了トレーニングを続ける
CosFace: 入力 = '第3の人.jpg' -> モデル -> 確率 [0.2, 0.2, 0.9]
-> トレーニングを強化し第3の人の確率から0.5を引く -> 確率 [0.2, 0.2, 0.4] -> タスク完了

ですので、CosFace のようなトレーニングが完了すると、異なるクラス間に追加のマージンが生じます。これがいわゆる Additional Margin です!SphereFace、CosFace、ArcFace の違いは、このマージンの位置がどこにあるかだけです!

本来はここで公式の導出が必要ですが、公式の導出については別の記事を作成する価値があります。ネット上には大量のリソースがあるため、ここでは繰り返しません。

2 >> CosFace の実装

model/ ディレクトリに metric.py を作成し、以下のコードを書き込みます。

import math
import torch
import torch.nn as nn
import torch.nn.functional as F


class CosFace(nn.Module):

    def __init__(self, in_features, out_features, s=30.0, m=0.40):
        super().__init__()
        self.in_features = in_features
        self.out_features = out_features
        self.s = s
        self.m = m
        self.weight = nn.Parameter(torch.FloatTensor(out_features, in_features))
        nn.init.xavier_uniform_(self.weight)

    def forward(self, input, label):
        cosine = F.linear(F.normalize(input), F.normalize(self.weight))
        phi = cosine - self.m
        output = cosine * 1.0  # make backward works
        batch_size = len(output)
        output[range(batch_size), label] = phi[range(batch_size), label]
        return output * self.s

簡単に説明すると、s は拡大因子です。入力と重みが L2 正規化されているため、掛け算の結果であるコサイン値も [-1, 1] の範囲に収まります。これにより、逆伝播の勾配が非常に小さくなり、効果的に学習できなくなります。一方、L2 正規化を行わない値の範囲は一般に [-20, 80] になります。したがって、L2 正規化後に拡大が必要です。この部分の詳細は、元の論文を参照するとより明確になります。

要するに、CosFace は以下のことを行います:

  • バックボーンネットワークの出力、つまり埋め込みを L2 正規化します

  • CosFace の距離関数の重みも L2 正規化し、埋め込みとの線形乗算を行うことでコサイン値を得ます

  • 正しいラベルの出力を強化し、確率値を減少させます

  • 強化後のコサインを拡大し、後続の逆伝播が正常に機能するようにします
    上記の forward 関数は以下の処理を行います:

  • input と weight を正規化し、角度のコサインを計算します

  • cosine から予め定義された追加のマージン m を引いて phi を得ます

  • output = cosine * 1.0 は、cosine の値を直接変更することを避け、Pytorch の正常な逆伝播プロセスに影響を与えないようにします

  • 最後の行は、output 内の正しいラベルの確率値を、ステップ2で強化された確率値に置き換えることを意味します

  • 最後に、出力を拡大します

上記のプロセスを理解すれば、ArcFace の理解も簡単です!

3 >> ArcFace の実装

model/metric.py に以下のコードを追加します

class ArcFace(nn.Module):
    
    def __init__(self, embedding_size, class_num, s=30.0, m=0.50):
        super().__init__()
        self.in_features = embedding_size
        self.out_features = class_num
        self.s = s
        self.m = m
        self.weight = nn.Parameter(torch.FloatTensor(class_num, embedding_size))
        nn.init.xavier_uniform_(self.weight)

        self.cos_m = math.cos(m)
        self.sin_m = math.sin(m)
        self.th = math.cos(math.pi - m)
        self.mm = math.sin(math.pi - m) * m

    def forward(self, input, label):
        cosine = F.linear(F.normalize(input), F.normalize(self.weight))
        sine = ((1.0 - cosine.pow(2)).clamp(0, 1)).sqrt()
        phi = cosine * self.cos_m - sine * self.sin_m
        phi = torch.where(cosine > self.th, phi, cosine - self.mm)  # drop to CosFace
        output = cosine * 1.0  # make backward works
        batch_size = len(output)
        output[range(batch_size), label] = phi[range(batch_size), label]
        return output * self.s

ArcFace は多くの要素が追加されているように見えますが、実際にはその距離が境界を越える問題があるためです。Arc は角度を意味し、その追加のマージン m は角度です。一方、CosFace の m はコサイン値です。ArcFace の境界越えは、元の角度に追加の角度を加えた結果、180度を超える場合に発生します。上記のコードの3行目と4行目では、境界越えが発生した場合には、ArcFace の代わりに CosFace を使用するようにしています。そのため、追加の変数や計算過程は、角度空間からコサイン空間への変換を完了させるためのものに過ぎません。

これで、評価関数の実装が完了しました!ArcFace のような論文を学ぶと、これは人間の直感に合った数学であり、何か神秘的なコードではないことに気づき、非常に感銘を受けました!

4 >> モデルの完成

model/フォルダ内の内容はすでに完成しています。以下のファイルが含まれているはずです。

model/
├── __init__.py     
├── fmobilenet.py   
├── loss.py         
└── metric.py       

__init__.py を開き、以下のコードを追加してください。

from .fmobilenet import FaceMobileNet
from .loss import FocalLoss
from .metric import ArcFace, CosFace

05. トレーニング

ようこそ!この記事は「自分自身の顔認識モデルを構築する」シリーズの第5篇です。

このセクションでは、訓練のパイプラインを構築し、前の4セクションの内容を統合します。このセクションは内容が多いので、少し忍耐強く取り組んでください!

1 >> 始める前に

現在、私たちは次のものを持っています:

  • データセット
  • ネットワーク構造
  • 損失関数
  • 評価関数

顔認識ネットワークを訓練するための要素はすべて揃いました。訓練のパイプラインは以下の通りです:

データの読み込み -> データの前処理 -> モデルがデータを取得し埋め込みを出力 -> 埋め込みとラベルが評価関数に入り確率値を出力 -> 損失関数が損失を計算 -> 逆伝播でパラメータを調整 -> モデルが収束するまで

このようなパイプラインでは、各ステップに多くのパラメータを選択できるため、これらのパラメータを管理するための追加の設定ファイルを作成します。

2 >> 設定ファイル

まず、データに関する問題に焦点を当てます。torchvisionが提供する一連の関数を使用してデータの前処理を行います。
config.pyというファイルを作成し、以下のコードを書き込みます。

import torch
import torchvision.transforms as T

class Config:
    # dataset
    train_root = '/data/CASIA-WebFace'
    test_root = "/data/lfw-align-128"
    test_list = "/data/lfw_test_pair.txt"

自分のデータパスを指定しましたが、あなた自身のパスに合わせて設定してください。

3 >> データの前処理

理論的には、データの前処理は別の記事として扱うべきですが、私たちのデータ処理は比較的簡単なので、トレーニングプロセスに統合することにしました。全体の流れも良くなります。

続いて、config.pyにコードを追加します。

class Config: 
    # ... 省略 ...
    input_shape = [1, 128, 128]
    train_transform = T.Compose([
        T.Grayscale(),
        T.RandomHorizontalFlip(),
        T.Resize((144, 144)),
        T.RandomCrop(input_shape[1:]),
        T.ToTensor(),
        T.Normalize(mean=[0.5], std=[0.5]),
    ])
    test_transform = T.Compose([
        T.Grayscale(),
        T.Resize(input_shape[1:]),
        T.ToTensor(),
        T.Normalize(mean=[0.5], std=[0.5]),
    ])

私が設計した入力は1 x 128 x 128で、つまり入力はグレースケール画像で、そのサイズは128 x 128です。また、他の人の入力は3 x 112 x 112です。

私のデータ前処理関数は、トレーニングとテストに分かれています。トレーニング時のデータ前処理のパイプラインは次のようになります。

グレースケール化 -> ランダム水平反転 -> 144 x 144にリサイズ -> ランダムに128 x 128にクロップ -> PyTorchのトレーニングフォーマットに変換 -> データ正規化

テスト時には水平反転を行いたくないので、ランダムクロップもしないため、パイプラインは次のようになります:

グレースケール化 -> 128 x 128にリサイズ -> PyTorchのトレーニングフォーマットに変換 -> データ正規化

これで前処理の操作が整いましたので、データの読み込みと前処理を結びつけましょう!

dataset.pyというファイルを作成し、以下のコードを書き込みます。

from torch.utils.data import DataLoader
from torchvision.datasets import ImageFolder

ここでは、ImageFolderという2つのクラスをインポートしました。ImageFolderは、フォルダーで分類されたデータを便利に処理できるクラスです。たとえば、

dogcat
 |--- cat/  
 |--- dog/

ImageFolderは自動的にデータに0,1,2のようなラベルを付け、画像のパスを返すことができます。以下はその例です:

from torchvision.datasets import ImageFolder
data = ImageFolder('/data/dogcat/')
print(x.imgs)
# [('/data/dogcat/cat/1.jpg', 0),
#  ('/data/dogcat/cat/2.jpg', 0),
#  ('/data/dogcat/dog/1.jpg', 1),
#  ('/data/dogcat/dog/2.jpg', 1)]
print(x.classes)
# ['cat', 'dog']

私たちの顔データもフォルダーで整理されていて、素晴らしいですね!

DataLoaderは、指定したBatchサイズのデータを生成し、データをシャッフルしたり、迅速にロードしたりする機能を提供してくれる非常に便利なクラスです。

dataset.pyに以下のコードを追加していきましょう:

from config import Config as conf

def load_data(conf, training=True):
    if training:
        dataroot = conf.train_root
        transform = conf.train_transform
        batch_size = conf.train_batch_size
    else:
        dataroot = conf.test_root
        transform = conf.test_transform
        batch_size = conf.test_batch_size

    data = ImageFolder(dataroot, transform=transform)
    class_num = len(data.classes)
    loader = DataLoader(data, batch_size=batch_size, shuffle=True, 
        pin_memory=conf.pin_memory, num_workers=conf.num_workers)
    return loader, class_num

このコードは以下のことを行っています:

  • トレーニングフェーズかテストフェーズかを判断し、conf内の異なるパラメータを使用する
  • ImageFolderオブジェクトを生成し、データにtransformを適用してdataを取得する
  • dataDataLoaderに渡し、各イテレーションで返されるbatch_sizeとその他のパラメータを指定する

注意すべきは、config.pyにまだ指定していないいくつかのパラメータがあり、その中のpin_memorynum_workersはデータの読み込みプロセスを加速するために使用できます。

これらのパラメータをconfig.pyに書き込んでいきましょう。

class Config:
    # ... 省略 ...
    train_batch_size = 64
    test_batch_size = 60

    pin_memory = True  # if memory is large, set it True for speed
    num_workers = 4  # dataloader

これで、データの前処理が完了しました!

4 >> トレーニングパラメータ

パイプラインを振り返ってみましょう:

データの読み込み -> データの前処理 -> モデルがデータを取得し埋め込みを出力 -> 埋め込みとラベルが評価関数に渡され確率値を出力 -> 損失関数が損失を計算 -> 逆伝播でパラメータを調整 -> モデルが収束するまで

さて、モデルのパラメータを設定しましょう。config.pyに以下を追加します。

class Config:
    # ... 省略 ...
    backbone = 'fmobile' # [resnet, fmobile]
    metric = 'arcface'  # [cosface, arcface]
    embedding_size = 512
    drop_ratio = 0.5

私たちは、使用するネットワーク構造、評価関数、出力される顔特徴ベクトルのサイズなどのパラメーターを指定しました。

次に、以下のコードを追加します:

class Config:
    # ... 省略 ...
    epoch = 30
    optimizer = 'sgd'  # ['sgd', 'adam']
    lr = 1e-1
    lr_step = 10
    lr_decay = 0.95
    weight_decay = 5e-4
    loss = 'focal_loss' # ['focal_loss', 'cross_entropy']
    device = 'cuda' if torch.cuda.is_available() else 'cpu'
    checkpoints = "checkpoints"

ここでは、学習率、最適化手法、損失関数、デバイスを指定しました。checkpointsは、重みファイルを保存するためのフォルダーです。

5 >> トレーニングパイプライン

設定が整ったので、トレーニングパイプラインのコードを書き始めましょう。
train.pyを作成し、依存関係をインポートします。

import os
import os.path as osp

import torch
import torch.nn as nn
import torch.optim as optim
from tqdm import tqdm

from model import FaceMobileNet
from model.metric import ArcFace, CosFace
from model.loss import FocalLoss
from dataset import load_data
from config import Config as conf

データの設定

# Data Setup
dataloader, class_num = load_data(conf, training=True)
embedding_size = conf.embedding_size
device = conf.device

モデルと距離関数の設定

# Network Setup
net = FaceMobileNet(embedding_size).to(device)

if conf.metric == 'arcface':
    metric = ArcFace(embedding_size, class_num).to(device)
else:
    metric = CosFace(embedding_size, class_num).to(device)

net = nn.DataParallel(net)
metric = nn.DataParallel(metric)

損失関数と最適化手法の設定

# Training Setup
if conf.loss == 'focal_loss':
    criterion = FocalLoss(gamma=2)
else:
    criterion = nn.CrossEntropyLoss()

if conf.optimizer == 'sgd':
    optimizer = optim.SGD([{'params': net.parameters()}, {'params': metric.parameters()}], 
                            lr=conf.lr, weight_decay=conf.weight_decay)
else:
    optimizer = optim.Adam([{'params': net.parameters()}, {'params': metric.parameters()}],
                            lr=conf.lr, weight_decay=conf.weight_decay)

scheduler = optim.lr_scheduler.StepLR(optimizer, step_size=conf.lr_step, gamma=0.1)

重みフォルダーの作成

# Checkpoints Setup
os.makedirs(conf.checkpoints, exist_ok=True)

トレーニング開始!

# Start training
net.train()

for e in range(conf.epoch):
    for data, labels in tqdm(dataloader, desc=f"Epoch {e}/{conf.epoch}",
                             ascii=True, total=len(dataloader)):
        data = data.to(device)
        labels = labels.to(device)
        
        optimizer.zero_grad()
        embeddings = net(data)
        thetas = metric(embeddings, labels)
        loss = criterion(thetas, labels)
        loss.backward()
        optimizer.step()

    print(f"Epoch {e}/{conf.epoch}, Loss: {loss}")

    backbone_path = osp.join(checkpoints, f"{e}.pth")
    torch.save(net.state_dict(), backbone_path)
    scheduler.step()

上記のコードは以下のことを行いました:

  • ネットワークをトレーニングモードに設定
  • エポックごとのトレーニングを実行し、完了後に損失を印刷
  • エポックごとの重みを保存
  • 学習率のスケジューリングを実行

トレーニングを完了するには、GPUマシンが必要です。データ量がかなり多いためです。私が WebFace でトレーニングした際、損失が6に下がったとき、LFWでの精度は96%でした。この時点でモデルはほぼ収束していました。コード内の resnet モデルを使用すれば、損失を約1に下げることができ、精度は約97%に達することができます。

06. テスト

ようこそ!この記事は「Build Your Own Face Recognition Model」シリーズの第六篇です。

このセクションでは、前のセクションで訓練したモデルをテストします!

1 >> 始める前に

訓練の際、モデルは正しい分類を完成させるために訓練されました。テスト段階では、モデルが2つの顔の類似度を計算します。したがって、私たちのテストリストlfw_test_pair.txtは次のようになっています。

Abel_Pacheco/Abel_Pacheco_0001.jpg Abel_Pacheco/Abel_Pacheco_0004.jpg 1
Akhmed_Zakayev/Akhmed_Zakayev_0001.jpg Akhmed_Zakayev/Akhmed_Zakayev_0003.jpg 1
... ...
Enrique_Iglesias/Enrique_Iglesias_0001.jpg Gisele_Bundchen/Gisele_Bundchen_0002.jpg 0
Eric_Bana/Eric_Bana_0001.jpg Mike_Sweeney/Mike_Sweeney_0001.jpg 0

つまり、顔1 顔2 [ラベル]、1は同一人物を、0は異なる人物を示します。最初のセクションで準備したLFWデータセットを使用してテストを行います。
lfw_test_pair.txtには3000組の正例と3000組の負例が提供されており、合計6000のテストサンプルがあります。テストの流れは次のようになります:

サンプルを読み込む -> グループ化バッチ -> モデルが埋め込みを計算 -> dict {imgPath: embeddings} を保存 -> 顔分離の閾値と精度を計算

2 >> サンプルの読み込み

test.pyを作成し、以下のコードを追加します。

import os
import os.path as osp

import torch
import torch.nn as nn
import numpy as np
from PIL import Image

from config import Config as conf
from model import FaceMobileNet

6000のテストケースの中には、画像が重複しているものがあります。まず、重複しない画像のパスを取得します。重複を避けるために、集合を使用します!

def unique_image(pair_list) -> set:
    """Return unique image path in pair_list.txt"""
    with open(pair_list, 'r') as fd:
        pairs = fd.readlines()
    unique = set()
    for pair in pairs:
        id1, id2, _ = pair.split()
        unique.add(id1)
        unique.add(id2)
    return unique

3 >> グループ化

前のステップで7000以上の画像を得たので、それらの埋め込み(特徴とも呼ばれる)を一批ずつ計算します。ここでは、データ処理全体の流れをより明確に理解するために、独自のグループ化関数を作成するつもりです。

def group_image(images: set, batch) -> list:
    """Group image paths by batch size"""
    images = list(images)
    size = len(images)
    res = []
    for i in range(0, size, batch):
        end = min(batch + i, size)
        res.append(images[i : end])
    return res

4 >> データ前処理

グループ化が完了したら、データ前処理を行います。

def _preprocess(images: list, transform) -> torch.Tensor:
    res = []
    for img in images:
        im = Image.open(img)
        im = transform(im)
        res.append(im)
    data = torch.cat(res, dim=0)  # shape: (batch, 128, 128)
    data = data[:, None, :, :]    # shape: (batch, 1, 128, 128)
    return data

5 >> 特徴の計算

一批の画像の特徴を計算し、特徴の辞書を返します。

def featurize(images: list, transform, net, device) -> dict:
    """featurize each image and save into a dictionary
    Args:
        images: image paths
        transform: test transform
        net: pretrained model
        device: cpu or cuda
    Returns:
        Dict (key: imagePath, value: feature)
    """
    data = _preprocess(images, transform)
    data = data.to(device)
    net = net.to(device)
    with torch.no_grad():
        features = net(data) 
    res = {img: feature for (img, feature) in zip(images, features)}
    return res

6 >> 余弦距離

私は余弦距離を使用して、2つの顔の距離を測定します。これは、トレーニングプロセスに対応しています。

def cosin_metric(x1, x2):
    return np.dot(x1, x2) / (np.linalg.norm(x1) * np.linalg.norm(x2))

7 >> 顔の分類

A、Bの2つの顔がどれくらいの距離にあると、A、Bが異なる人であると見なされるのでしょうか?以下のコードはそのままコピーしたもので、著作権は元の作者に帰属します。


def threshold_search(y_score, y_true):
    y_score = np.asarray(y_score)
    y_true = np.asarray(y_true)
    best_acc = 0
    best_th = 0
    for i in range(len(y_score)):
        th = y_score[i]
        y_test = (y_score >= th)
        acc = np.mean((y_test == y_true).astype(int))
        if acc > best_acc:
            best_acc = acc
            best_th = th
    return best_acc, best_th

彼は以下のことを行いました:

  • 1組の顔の距離を閾値として取得
  • 選択した閾値を用いて分類します。閾値を超えるものは同一人物の顔、閾値未満のものは異なる人物の顔とします
  • そのように分類した場合の正確率を計算
  • 次の顔の距離を閾値として取得し、すべての顔が遍歴するまで続けます
  • 最良の正確率と閾値を返します

8 >> 正確率の計算

def compute_accuracy(feature_dict, pair_list, test_root):
    with open(pair_list, 'r') as f:
        pairs = f.readlines()

    similarities = []
    labels = []
    for pair in pairs:
        img1, img2, label = pair.split()
        img1 = osp.join(test_root, img1)
        img2 = osp.join(test_root, img2)
        feature1 = feature_dict[img1].cpu().numpy()
        feature2 = feature_dict[img2].cpu().numpy()
        label = int(label)

        similarity = cosin_metric(feature1, feature2)
        similarities.append(similarity)
        labels.append(label)

    accuracy, threshold = threshold_search(similarities, labels)
    return accuracy, threshold

これで、すべての準備が整いました。test.pyに次のコードを追加します:

if __name__ == '__main__':

    model = FaceMobileNet(conf.embedding_size)
    model = nn.DataParallel(model)
    model.load_state_dict(torch.load(conf.test_model, map_location=conf.device))
    model.eval()

    images = unique_image(conf.test_list)
    images = [osp.join(conf.test_root, img) for img in images]
    groups = group_image(images, conf.test_batch_size)

    feature_dict = dict()
    for group in groups:
        d = featurize(group, conf.test_transform, model, conf.device)
        feature_dict.update(d) 
    accuracy, threshold = compute_accuracy(feature_dict, conf.test_list, conf.test_root) 

    print(
        f"Test Model: {conf.test_model}\n"
        f"Accuracy: {accuracy:.3f}\n"
        f"Threshold: {threshold:.3f}\n"
    )

上記のコードは以下のことを行いました:

  • 事前トレーニングされたモデルをロードします。config.pytest_modelを追加し、テストしたい重みファイルを指し示すことを忘れないでください
  • 画像をグループ化します
  • 顔の特徴を計算します
  • 閾値の検索を行います
  • 結果を印刷します

これで、あなたは全てのチュートリアルを完了しました!おめでとうございます!

初心

顔認識に関する紹介はすでに多くありますが、Build-Your-Own-x の多くの文章からインスパイアを受けて、自分の顔認識モデルを構築するブログを書きたいと思いました。他の人にとって有益であれば幸いです。

謝辞

明記はしていませんが、本プロジェクトにはごく一部のコードが以下のリポジトリから直接コピーまたは修正されており、ライセンスはそれと同じです:

最後に、私のブログを読んでくださった読者の皆様に感謝いたします。お時間をいただき、ありがとうございました。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?