7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 1 year has passed since last update.

女性声優さんの顔をCNNで判別する

Last updated at Posted at 2022-10-20

はじめに

 機械学習の勉強を始めてから3カ月程経ち、先日Tensorflowについて学習しました。身に着けるためにもそれを用いて自分でモデルを作りたいと思い、今回CNNによる画像分類を行いました。
 顔の分類ということで内容としてはありきたりですが、女性声優さん8人の分類をしたいと思います。
Qiitaで顔判別の記事を探しこちら(乃木坂メンバーの顔をCNNで分類)をかなり参考にさせていただきました。
 CNNの原理だったりOpenCVの説明、コードの細かな解説なんかもしたいところではありますが記事書き自体不慣れなため後日記事を更新して追記していけたらと思います。

手順

  1. スクレイピングで画像収集
  2. OpenCVで顔領域の切り取り
  3. 画像の選別(手作業)
  4. 訓練データの水増し
  5. モデルの構築と学習
  6. テストデータの評価
  7. カメラを使ってリアルタイムで判別

0. import

最初に必要なimpor文をまとめておきます

#import文
import glob
import pathlib
import sys
import os
import numpy as np
import matplotlib.pyplot as plt
from icrawler.builtin import BingImageCrawler
import cv2
import shutil
import random
import tensorflow as tf
from tensorflow.keras.models import Sequential
from tensorflow.keras.callbacks import EarlyStopping 
from keras.utils.np_utils import to_categorical
from tensorflow.keras.layers import Conv2D, Dense, BatchNormalization,MaxPooling2D,Activation,Dropout,Flatten
from sklearn.metrics import accuracy_score
from tensorflow.keras.models import  load_model
from PIL import Image

1. スクレイピングで画像収集

 Bing用のicrawlerライブラリを使ってスクレイピングを行うための関数を定義しときます。スクレイピング関数の引数は"検索ワード","画像枚数","保存先パス"になっています。

#スクレイピングを行う関数
def scraping(word,max_num,path):
    bing_crawler = BingImageCrawler(downloader_threads = 4, storage = {'root_dir': path})
    bing_crawler.crawl(keyword = word, filters = None, offset=0, max_num = max_num,
                       min_size = (200, 200), max_size = None)

 次に分類を行う声優さんのリストを作ります。
検索ワード用と画像格納パス指定用で2種類のリストを作り、for文でスクレイピングを行っています。フォルダを開いて確認し複数人映っている画像や使い物にならなそうな画像はここで消しておいた方が後で楽です。
 今回は小倉唯さん、雨宮天さん、水瀬いのりさん、悠木碧さん、上坂すみれさん、佐倉綾音さん、高橋李依さん、伊藤美来さんの分類を行います。私が知ってる声優さんの中から恣意的に選んでいます。

# 学習させる声優リスト
voice_lists_jp = ['小倉唯 声優', '雨宮天 声優', '水瀬いのり 声優', '悠木碧 声優',
             '上坂すみれ  声優', '佐倉綾音  声優', '高橋李依  声優', '伊藤美来 声優']
voice_lists = ['ogurayui', 'amamiyasora', 'minaseinori', 'yuukiaoi',
             'uesakasumire', 'sakuraayane', 'takahasirie', 'itoumiku']
# スクレイピング実行
for i in range(len(voice_lists)):
    scraping(voice_lists_jp[i], 500, './voice_picture/' + voice_lists[i] + '/')

2. OpenCVで顔領域の切り取り

 ディレクトリの中身を取得する関数です。便利なのでコピペして使ってます。

#ディレクトリの中身を取得
def get_file(dir_path):
    file = os.listdir(dir_path)
    return file

 顔認識のカスケード型識別器はいくつか用意されておりますが今回は"haarcascade_frontalface_alt.xml"を使用します。インストール先のパス指定して読み込むことができます。
 引数minNeighborsの値は大きいほど誤分類を許容しなくなりますが、大きくするとちゃんとした顔でも顔と認識されなくなるので切り取り結果を見ながら調整するといいと思います。
 OpenCVの顔認識は横顔や傾いた顔の検出が苦手みたいで使える画像枚数は減ってしまいます。

# 顔の切り取りと保存
for voice_list in voice_lists:
    #出力画像のディレクトリ作成
    os.mkdir("./voice_picture/" + voice_list + "_resize_64")
    #入力画像のディレクトリ
    in_dir = "voice_picture/" + voice_list + "/"
    #出力画像のディレクトリ
    out_dir = "voice_picture/" + voice_list + "_resize_64/"
    # 画像の取得
    pic = get_file(in_dir)

    n = 0
    for i in pic:
        # 画像の読み込み 
        image = cv2.imread(in_dir + i)
        # カスケードファイルの読み込み(顔認識)
        cascade = cv2.CascadeClassifier("./opencv-master/data/haarcascades/haarcascade_frontalface_alt.xml")
        # 顔認識の実行(scaleFactor:計算ステップ、minNeighbors:大きいほど誤検出少)
        # face_infoは4次元配列(x,y座標とそこからの縦横のサイズ)
        face_list = cascade.detectMultiScale(image, scaleFactor = 1.1, minNeighbors = 2, minSize = (1,1))
        # 顔だけ切り出して保存
        for rect in face_list:
            n += 1
            x,y,width,height = rect
            face = image[y:y + height, x:x + width]
            face = cv2.resize(face, (64, 64))
            save_path = out_dir + 'resize_' + str(n) + '.jpg'
            cv2.imwrite(save_path, face)

こんな感じで顔だけ切り取れます

3. 画像の選別

手順2の結果フォルダを確認すると以下のような文字であったり顔の一部、画像に映り込んでた他の顔など学習には使えないものがたくさんあるため手作業で削除していきます。(かなり多かったです)

4. データの水増し

 結局最初に集めた画像に対し使えそうな画像は30%程度まで減ってしまいましたので水増し作業をして枚数を稼ぎます。
 データの水増しの前に検証用データとテスト用データは別フォルダに移動させておきます。
顔領域切り取り画像のうち2割を検証用データフォルダに移動し、その後1割をテストデータフォルダに移動させています。

#検証データは別フォルダへ移動
os.makedirs("./valid", exist_ok = True)
for voice_list in voice_lists:
    in_dir = "./voice_picture/" + voice_list + "_resize_64/*"
    in_img = glob.glob(in_dir)
    random.shuffle(in_img)
    os.makedirs('./voice_picture/valid_64/' + voice_list, exist_ok = True)
    for t in range(len(in_img) // 5):
        shutil.move(in_img[t], "./voice_picture/valid_64/"+voice_list)

#最終テストデータも別フォルダへ移動
os.makedirs("./test", exist_ok = True)
for voice_list in voice_lists:
    in_dir = "./voice_picture/"+voice_list+"_resize_64/*"
    in_img = glob.glob(in_dir)
    random.shuffle(in_img)
    os.makedirs('./voice_picture/test_64/' + voice_list, exist_ok = True)
    for t in range(len(in_img) // 10):
        shutil.move(in_img[t], "./voice_picture/test_64/" + voice_list)

 訓練データを作成します。
 1枚の画像を-10度,0度,10度回転させ、それぞれに対しぼかし処理や閾値処理などを行いデータを増やしています。

# 回転と閾値とぼかし
for voice_list in voice_lists:
    in_dir = "./voice_picture/" + voice_list + "_resize_64/*"
    out_dir = "./voice_picture/train_64/" + voice_list
    os.makedirs(out_dir, exist_ok = True)
    in_img = glob.glob(in_dir)
    for i in range(len(in_img)):
        img = cv2.imread(in_img[i])
            #回転
        for ang in [-10,0,10]:
            h, w = img.shape[:2]
            M = cv2.getRotationMatrix2D(center = (w / 2, h / 2), angle = ang, scale = 1)
            img_rot = cv2.warpAffine(img, M, dsize = (64, 64))
            img_path = os.path.join(out_dir, str(i) + "_" + str(ang) + ".jpg")
            cv2.imwrite(img_path,img_rot)
            # 閾値
            img_thr = cv2.threshold(img_rot, 100, 255, cv2.THRESH_TRUNC)[1]
            img_path = os.path.join(out_dir, str(i) + "_" + str(ang) + "thr.jpg")
            cv2.imwrite(img_path, img_thr)
            # ぼかし
            img_gau = cv2.GaussianBlur(img_rot, (5, 5), 5)
            img_path = os.path.join(out_dir,str(i) + "_"+str(ang) + "gau.jpg")
            cv2.imwrite(img_path, img_gau)
            #グレースケール
            img_gray = cv2.cvtColor(img_rot, cv2.COLOR_BGR2GRAY) 
            img_path = os.path.join(out_dir, str(i) + "_"+str(ang) + "gray.jpg")
            cv2.imwrite(img_path,img_gray)
            #左右反転
            img_flip = cv2.flip(img_rot, 1)
            img_path = os.path.join(out_dir, str(i) + "_"+str(ang) + "flip.jpg")
            cv2.imwrite(img_path, img_flip)
            #ノイズ除去(カラー)
            img_denoise = cv2.fastNlMeansDenoisingColored(img_rot)
            img_path = os.path.join(out_dir,str(i) + "_"+str(ang) + "denoise.jpg")
            cv2.imwrite(img_path, img_denoise)

下図は左から順に通常、回転、閾値、ぼかし、白黒化、反転、ノイズ除去の一例です。

 データにラベルを付けていきます(画像に対しそれがどの人物かの答えを与えます)。
 X_〇〇に画像のRGB配列データを格納し、Y_〇〇に正解のラベル(0~7)を割り当てます。
またto_categoricalでOne-Hotエンコーディング処理を行っております。
(それぞれで記述してますがfor文でまとめた方がすっきりしそうです。)

# 訓練データのラベル付け
X_train = [] 
Y_train = [] 
for i in range(len(voice_lists)):
    in_dir = "./voice_picture/train/" + voice_lists[i] + "/*"
    in_img = glob.glob(in_dir)
    print(len(in_img))
    for j in range(len(in_img)):
        img = Image.open(in_img[j])        
        img = img.convert('RGB')
        data = np.asarray(img)
        X_train.append(data)
        Y_train.append(i)
        
# 検証データのラベル付け
X_val = [] # 画像データ読み込み
Y_val = [] # ラベル(名前)
for i in range(len(voice_lists)):
    in_dir = "./voice_picture/valid/" + voice_lists[i] + "/*"
    in_img = glob.glob(in_dir)
    print(len(in_img))
    for j in range(len(in_img)):
        img = Image.open(in_img[j])        
        img = img.convert('RGB')
        data = np.asarray(img)
        X_val.append(data)
        Y_val.append(i)
        
# テストデータのラベル付け
X_test = [] # 画像データ読み込み
Y_test = [] # ラベル(名前)
for i in range(len(voice_lists)):
    in_dir = "./voice_picture/test/" + voice_lists[i] + "/*"
    in_img = glob.glob(in_dir)
    print(len(in_img))
    for j in range(len(in_img)):
        img = Image.open(in_img[j])        
        img = img.convert('RGB')
        data = np.asarray(img)
        X_test.append(data)
        Y_test.append(i)

x_train = np.array(X_train)
x_val = np.array(X_val)
x_test = np.array(X_test)
y_train = to_categorical(Y_train)
y_val = to_categorical(Y_val)
y_test = to_categorical(Y_test)

訓練データは2000枚程度*8人の量になりました。

5. モデルの構築と学習

 TensorflowのSequentialAPIでモデルを作っていきます。
 畳み込み層とバッチ正規化層、活性化関数、プーリング層の4つのレイヤーを1まとまりとして隠れ層を重ねています。その後Flatten層(一次元配列化)を通してDense層(全結合)を並べています。
 層の数や層内のニューロン数はいろんなQiita記事参考にしながら精度がよくなるよう調整しています。
 バッチ正規化層についてはこちら(Batch Normalizationについて)がわかりやすかったです。

# モデルの定義
model = Sequential()

model.add(Conv2D(input_shape = (64, 64, 3), filters = 32, kernel_size = (3, 3), strides = (1, 1), padding="same"))
model.add(BatchNormalization())
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size=(2, 2)))

model.add(Conv2D(filters = 64, kernel_size = (3, 3), strides = (1, 1), padding = "same"))
model.add(BatchNormalization())
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size = (2, 2)))

model.add(Conv2D(filters = 128, kernel_size = (3, 3), strides = (1, 1), padding = "same"))
model.add(BatchNormalization())
model.add(Activation('relu'))
model.add(MaxPooling2D(pool_size = (2, 2)))

model.add(Flatten())

model.add(Dense(256))
model.add(Activation('sigmoid'))
model.add(Dense(128))
model.add(Activation('sigmoid'))
model.add(Dense(8))
model.add(Activation('softmax'))

# コンパイル
model.compile(optimizer = 'sgd', loss = 'categorical_crossentropy', metrics = ['accuracy'])
model.summary
_________________________________________________________________
 Layer (type)                Output Shape              Param #   
=================================================================
 conv2d_171 (Conv2D)         (None, 64, 64, 32)        896       
                                                                 
 batch_normalization_22 (Bat  (None, 64, 64, 32)       128       
 chNormalization)                                                
                                                                 
 activation_138 (Activation)  (None, 64, 64, 32)       0         
                                                                 
 max_pooling2d_161 (MaxPooli  (None, 32, 32, 32)       0         
 ng2D)                                                           
                                                                 
 conv2d_172 (Conv2D)         (None, 32, 32, 64)        18496     
                                                                 
 batch_normalization_23 (Bat  (None, 32, 32, 64)       256       
 chNormalization)                                                
                                                                 
 activation_139 (Activation)  (None, 32, 32, 64)       0         
                                                                 
 max_pooling2d_162 (MaxPooli  (None, 16, 16, 64)       0         
 ng2D)                                                           
                                                                 
 conv2d_173 (Conv2D)         (None, 16, 16, 128)       73856     
                                                                 
 batch_normalization_24 (Bat  (None, 16, 16, 128)      512       
 chNormalization)                                                
                                                                 
 activation_140 (Activation)  (None, 16, 16, 128)      0         
                                                                 
 max_pooling2d_163 (MaxPooli  (None, 8, 8, 128)        0         
 ng2D)                                                           
                                                                 
 flatten_25 (Flatten)        (None, 8192)              0         
                                                                 
 dense_125 (Dense)           (None, 256)               2097408   
                                                                 
 activation_141 (Activation)  (None, 256)              0         
                                                                 
 dense_126 (Dense)           (None, 164)               42148     
                                                                 
 activation_142 (Activation)  (None, 164)              0         
                                                                 
 dense_127 (Dense)           (None, 3)                 495       
                                                                 
 activation_143 (Activation)  (None, 3)                0         
                                                                 
=================================================================
Total params: 2,234,195
Trainable params: 2,233,747
Non-trainable params: 448
_________________________________________________________________

 モデルができましたので学習していきます。callbacks.EarlyStoppingは学習が進まなくなったら早めに終了します。ここでは7に設定していますので7エポック連続でそれまでの最高精度を更新できなかったら終了です。今回学習回数は100エポックに設定していますが実際は20エポック程度で終了しております。

# 学習
history = model.fit(x_train, y_train, batch_size = 32, 
                         epochs = 100, verbose = 1, validation_data = (x_val, y_val),
                         callbacks = [tf.keras.callbacks.EarlyStopping(monitor = 'val_loss', patience=7)])

エポック毎の正答率を確認してみましょう。

#エポック毎の正答率表示
plt.plot(history.history["accuracy"], label = "accuracy", ls = "-", marker = "o")
plt.plot(history.history["val_accuracy"], label = "val_accuracy", ls = "-", marker = "x")
plt.ylabel("accuracy")
plt.xlabel("epoch")
plt.legend(loc = "best")
plt.show()

 検証データで85%くらいの精度は出ていそうです。訓練データは水増しにより似た画像がたくさんあるためほぼ100%近いのかなと思います。
(繰り返し実行していて内部にその重みとかが保存されたりするものなのかわかりませんがval_accuracyが最初から高くなってしまってます。)

6. テストデータの評価

 学習には使ってないテストデータでの正答率も見てみましょう。
 np.argmaxで予測した数字(0~7)の配列を取り出して、予測データと実際の結果を比較しています。

#93枚テストデータの正答率確認
y_pred = model.predict(x_test)
y_pred_max = np.argmax(y_pred, axis = 1)
y_test_max = np.argmax(y_test, axis = 1)
accuracy = accuracy_score(y_test_max, y_pred_max)
print('テストデータでの正答率:', accuracy)

image.png
87%で見分けることができました!

 間違えた画像と誰を誰と間違えたか確認してみましょう。
 予測結果の数字と実際の数字が違った場合にその画像を表示し、名前を表示するために改めて声優リスト定義しております。

voice_lists_jp = ['小倉唯', '雨宮天', '水瀬いのり', '悠木碧',
             '上坂すみれ', '佐倉綾音', '高橋李依', '伊藤美来']
for i in range(len(y_pred_max)):
    if y_pred_max[i] != y_test_max[i]:
        print(voice_lists_jp[y_test_max[i]] + ""+voice_lists_jp[y_pred_max[i]] + "と誤って予測した")
        plt.imshow(x_test[i])
        plt.show()

一部ですが結果は以下のように表示されます。

 これを見ると最初の2枚(いのりんとあおちゃんをすみぺと誤って予測)は目元の赤色っぽいメイクがすみぺっぽいため間違えたのかと思いました。
 他の誤予測結果については原因がわかりませんが計算量を減らすため画像サイズを64*64pixelまでリサイズしたことが原因で判別が難しくなったのかもしれません。
後日の課題として機械学習が画像のどこに着目して判別しているかを可視化して確認したいと思います。

モデル保存しときます

#モデルを保存
model.save("CNN_model.h5")

 それでは読み込んだ画像上に結果を表示できるようにしてみます。
 表示名前用にまたリスト定義しなおしています。また予測結果の数値に合わせて声優リストの名前を返す関数を定義しときます。

voice_lists = ['Ogura Yui', 'Amamiya Sora', 'Minase Inori', 'Yuki Aoi',
             'Uesaka Sumire', 'Sakura Ayane', 'Takahashi Rie', 'Itou Miku']
#予測メソッド
def face_who(img):
    name_index = np.argmax(model.predict(img))
    for i in range(len(voice_lists)):
        if name_index == i:
            name = voice_lists[i]
    return name

 さらに顔認識を行い顔部分を枠で囲って先ほどの関数を使って得られた名前を画像上に表示する関数を定義します。最初のOpenCVでの顔領域切り取りの際は顔認識の誤判別の許容度合いの引数minNeighbors=2にしていましたが、ここでは誤認識はしてほしくないため3に変えています。
 どの程度がいいかは試しながら決めるのがいいかと思いますが、今回読み込ませた画像では2で生じた誤認識が3にしたら消えたのでとりあえず3にしときました。

#読み込んだ画像に対して誰か表示
def discriminate_face(img):
    img = img.convert('RGB')
    data = np.asarray(img)
    cascade = cv2.CascadeClassifier("./opencv-master/data/haarcascades/haarcascade_frontalface_alt.xml")
    face_list = cascade.detectMultiScale(data, scaleFactor=1.1, minNeighbors=3, minSize = (1, 1))
    if len(face_list) > 0:
        for rect in face_list:
            x, y, width, height = rect
            cv2.rectangle(data, (x, y), (x+width, y+height), (255, 0, 0), thickness = 5)
            image_cut = data[y:y+height,x:x+width]
            image_resize = cv2.resize(image_cut, (64, 64)) 
            image_resize = np.expand_dims(image_resize, axis = 0)
            name = face_who(image_resize)
            cv2.putText(data, name, (x, y + height + 60), cv2.FONT_HERSHEY_DUPLEX, 2, (0, 255, 0), 5)
            #枠の中塗りつぶしたい時用
            #cv2.rectangle(data, (x, y), (x + width, y + height), (255, 0, 0), thickness = -1)
    return data

手動でcheckフォルダ作って自分でダウンロードした画像入れてます。
実行!

image = Image.open("./voice_picture/check/26.jpg")
whoImage = discriminate_face(image)
plt.imshow(whoImage)
plt.show()

上手く判別されました。先ほど書いたようにminNeighbors弄ることで誤認識の許容度が変わります。
minNeighbors=2(左)、3(右)

また折角なので全員分判別した画像を載せておきます。
ゆいゆい

てんちゃん

いのりん

あおちゃん

すみぺ

あやねる

りえりー

みっく

7. カメラを使ってリアルタイムで判別

 最後にPCカメラを使ってリアルタイムで判別できるようにしてみます。OpenCVのVideoCaptureを使うことでPCカメラを起動し映像を取得、処理を行い出力することができます。参考になりそうな記事はそこら中にありますのでそちらをご確認ください。キーを押した瞬間だけ判別器を起動するとかもできるようですが、今回は単純に実行したら常に判別器が動いてその結果を出力し続けるコードです。
 残念ながら本人はいないためスマホで画像を映して実行してみました。

def main():
    # モデルの読み込み
    model = load_model('./CNN_model.h5')
    #Webカメラの映像表示
    capture = cv2.VideoCapture(0)
    if capture.isOpened() is False:
            raise("IO Error")
    while True:
        #Webカメラの映像とりこみ
        ret, image = capture.read()
        if ret == False:
            continue
        k = cv2.waitKey(10)
        face = Image.fromarray(image)
        whoImage = discriminate_face(face)
        cv2.imshow('CHECK',whoImage)
        #ESCキーでキャプチャー画面を閉じる
        if  k == 27:
            break
    cv2.destroyAllWindows()

if __name__ == '__main__':
    main()

ちゃんと判別してくれました!!!
ただ被写体の角度とかで結果がころころ変わってしまうこともあります…
学習データ増やせば自信もって判別してくれてこういうこともなくなるのかな…

ディレクトリ構造

ディレクトリの構造を示しておきます。また動作環境はjupyter notebookです。
〇〇は声優さんの名前が入ります。

ディレクトリ
./voice_picture
  /〇〇
  /〇〇_resize_64
  /train_64
    /〇〇
  /valid_64
    /〇〇
  /test_64
    /〇〇
  /check
./opencv-master
  /...

まとめ、後日の課題

まとめ

CNNで顔判別モデルを作成し87%の精度で判別することができました。
人数増やしていったらどこまで精度保てるかとかやってみたいなと思いました。
画像の分析は初めてでしたが、たくさんのQiita記事を見させていただきましてとても勉強になりました。この投稿も誰かしらが画像系のモデルを作るきっかけになればうれしいです。

後日課題(できたら後日投稿しようと思います)

今回のモデルが画像のどこを見て判別しているかを可視化(Grad-CAM)
pretrainedモデルをチューニングして学習させ今回自分で作ったモデルと精度比較
この記事を見やすく修正できるよう記事書き方の練習(コードやOpenCVの解説とかもしたい)
8人の誰かである確率が低いときは顔が検出されても"誰でもない"のような例外処理を作りたい(現状私の顔が映っても誰かしらには判別されてしまう)

7
8
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
7
8

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?