Deep Learningで長門有希を画像認識させたい。

#ー　はじめに　ー

西暦2006年、日本のアニメ界に時代を築いた作品と言われれば、何を思い浮かべるだろうか？
「涼宮ハルヒの憂鬱」　（作：谷川　流）　多くの人はこう答えるのではないだろうか。
今回は作内に登場する無口キャラ「長門有希」を識別するモデルを作成したいと思う。
ただ筆者の長門への愛が行き過ぎてしまった為に作られたものなので、興味のある方は読んで頂けたらと思う。

<br><br>


#ー　制作目標　ー

①投入された画像から顔を認識し、長門有希を見つけ出す。
②長門有希を見つけたら、画像のどこにいるか表示する。


<br><br>

#ー　制作環境　ー

macOS Big Sur  11.5.2
Python 3.9.7
Keras 2.6.0
tensorflow 2.6.0
VSCode

<br><br>

#ー　制作手順　ー

１、「涼宮ハルヒの憂鬱」の画像をとにかく集める。
２、集めた画像から、顔部分を切り抜き長門とその他に分ける。
３、画像を水増しして、大量の顔写真を用意する。
４、ラベル付けし、学習モデルを作成する。
５、出来上がったモデルに使用していない画像を渡し、必ず長門を探し出す。
６、結果
７、おまけ

<br><br>

それでは早速、制作開始！




<br>



##1、「涼宮ハルヒの憂鬱」の画像をとにかく集める。

間違いなく一番時間がかかったのはここ。
というのも、筆者はこの作業を手作業で約1,200枚スクショしました。
愛、ゆえに乗り切りましたが、かなり疲れました。
ただ、今まで気づかなかった伏線のようなものや、細かい部分に気付けたので良かったです。
同じ様に「俺の嫁認識モデル」を作成しようと考えてる方にはスクショをオススメします。

万が一、スクリーンショットが面倒くさい方は以下のコードで楽をしましょう。
本モデル作成後に、もう一つおまけに作成したモデルに使用したものです。

```python:screenshot.py
import os
import pyautogui
import time

start = time.time()

for l in range(1,13):
    for i in range(288):
        im = pyautogui.screenshot('./test3/' + str(l) +'_'+ str(i) + '.png', region=(499,600,1300,742))
        time.sleep(5)

end = time.time()
print('result time is :', end - start)
```

range(1,13)は全12話を想定。入れ子になっているrange(288)は５秒に１枚の写真（time.sleep(5)）を24分間撮影するようにしています。
またデスクトップ上のどこを撮影するのかはregionの値を変えて合わせてください。
動画を流すだけで自動で画面をキャプチャしてくれます。
詳しくは[**こちら**](https://qiita.com/s_minami/items/d0dbba4f2a8826aa9ba9)での記事を参照してください。


<br>

##２、集めた画像から、顔部分を切り抜き長門とその他に分ける。

画像を集めたら、そこから学習機に使う為、顔部分を抽出して切り取ります。
この工程は、流石に手作業はかなり厳しいです。
素直にカスケード分類器（「特徴量」をまとめたデータセットのこと）を使います。
顔検出をするメソッドcascade.detectMultiScaleを行うことで顔だけを切り出します。
今回はアニメ顔検出用の分類器として有名な[**lbpcascade_animeface.xml**](https://ultraist.hatenablog.com/entry/20110718/1310965532)というものを使います。

```python:face.py
import cv2

#lbpcascade_animeface.xmlで顔を抽出する関数face_cutを作成
def face_cut(img_path, save_path):
    img = cv2.imread(img_path,1)
    
    cascade = cv2.CascadeClassifier('lbpcascade_animeface.xml')
    facerect = cascade.detectMultiScale(img)
    for i, (x,y,w,h) in enumerate(facerect):
        face_img = img[y:y+h, x:x+w]
        face_img = cv2.resize(face_img, (50, 50))
        cv2.imwrite(save_path, face_img)


#1200枚の画像をfor文で回すため、名称リストを作る。
lists = list(range(1,1201))
list_name = []
for a in lists:
    list_name.append("x" +str(a))

for l in range(1,1201):
    face_cut('x'+str(l)+'.png', 'x'+str(l)+'_.png')
```
画像名は全選択して名前変更してしまうのが、一番楽だと思います。
上記コードの場合は、事前に、全選択⇨「x」に名称変更をしておきます。
名称変更後、枚数分のlist_nameを作成しfor文で回しました。
単純ですが、単純ゆえに分かりやすく、色んなところに使い回せるので好きです。


切り抜きが終わったら、画像を振り分けます。
ここは手作業で行います。
顔以外の部分を切り抜いていたりもするので、気を付けましょう。

![スクリーンショット 2021-09-21 16.17.27.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/87c1ec6b-11ba-a21e-41af-5046602181e0.png)
綺麗に椅子が抜かれていたりします。

<br>

##３、画像を水増しし、大量の顔写真を用意する

データを長門とそうでないものに振り分けたら、データを水増しします。
下記コードで水増しします。

```python:mizumasi.py

import os
import numpy as np
import cv2

def scratch_image(img, flip=True, thr=True, filt=True, resize=True, erode=True):
    # 水増しの手法を配列にまとめる
    methods = [flip, thr, filt, resize, erode]
            
    # flip は画像の左右反転
    # thr  は閾値処理
    # filt はぼかし
    # resizeはモザイク
    # erode は収縮
    #     をするorしないを指定している
    # 
    # imgの型はOpenCVのcv2.read()によって読み込まれた画像データの型
    # 
    # 水増しした画像データを配列にまとめて返す
    
    # 画像のサイズを習得、収縮処理に使うフィルターの作成
    img_size = img.shape
    filter1 = np.ones((3, 3))
    # オリジナルの画像データを配列に格納
    images = [img]

    # 手法に用いる関数
    scratch = np.array([
       
        #画像の左右反転
        lambda x: cv2.flip(x, 1),
        
        #閾値処理
        lambda x: cv2.threshold(x,100,255,cv2.THRESH_TOZERO)[1],
        
        #ぼかし
        lambda x:cv2.GaussianBlur(img, (5, 5), 0),
        
        #モザイク処理
        lambda x:cv2.resize(cv2.resize(x,(img_size[1]//5,img_size[0]//5)),(img_size[1],img_size[0])),
        
        #収縮
        lambda x:cv2.erode(x,filter1)
        
    ])
    
    # 関数と画像を引数に、加工した画像を元と合わせて水増しする関数
    doubling_images = lambda f, imag: (imag + [f(i) for i in imag])
    
    # doubling_imagesを用いてmethodsがTrueの関数で水増し
    for func in scratch[methods]:
        images = doubling_images(func,images)
          
    return images
    
#1200枚の画像をfor文で回すため、名称リストを作る。
lists = list(range(1,1201))
list_name = []
for a in lists:
    list_name.append("x" +str(a))

for a in listb:   
  
    # 画像の読み込み
    cat_img = cv2.imread("./loop8/"+a+".png",1)

    # 画像の水増し
    scratch_cat_images = scratch_image(cat_img)

    # 画像を保存するフォルダーを作成
    if not os.path.exists("scratch_images"):
        os.mkdir("scratch_images")

    for num, im in enumerate(scratch_cat_images):
        # まず保存先のディレクトリ"scratch_images/"を指定、番号を付けて保存
        cv2.imwrite("scratch_images/" + str(a)+str(num) + ".png" ,im) 

```

水増しに使いたくない手法があれば、関数scratch_imageの引数の初期値TrueをFalseにして下さい。
全てTrueで使用すると1枚が３２枚に増えます。
水増しをしたことで約３万枚の画像が出来上がりました。
これで、準備段階は完了しました。

<br><br>
##４、ラベル付けし、学習モデルを作成する。
いよいよ学習モデルの作成です。
VGG16を使用して学習機を作っていきたいと思います。
VGGとはOxford大学の研究グループが発表した畳み込み層とプーリング層から構成されるCNNです。
2014年のコンペに出したのが未だに使われているのは凄いですね。
VGG16は重みのある層が16層重なっているものになります。



モデル作成の前に、まずはラベル付けを行います。
前述のコードmizumasi.pyを使い用意した約３万枚のデータを長門かそうじゃないかの２つに分類します。

```python:model.py
import matplotlib.pyplot as plt
import os
import cv2
import random
import numpy as np
from keras.utils.np_utils import to_categorical



DATADIR = "./Images"
CATEGORIES = ["no_nagato", "yuki_Nagato"]
IMG_SIZE = 50
training_data = []
def create_training_data():
    for class_num, category in enumerate(CATEGORIES):
        path = os.path.join(DATADIR, category)
        for image_name in os.listdir(path):
            try:
                img_array = cv2.imread(os.path.join(path, image_name),)  # 画像読み込み
                img_resize_array = cv2.resize(img_array, (IMG_SIZE, IMG_SIZE))  # 画像のリサイズ（必要な場合）
                training_data.append([img_resize_array, class_num])  # 画像データ、ラベル情報を追加
            except Exception as e:
                pass

create_training_data()#関数create_training_dataでラベル付する

random.shuffle(training_data)  # データをシャッフル
X_trains = []  # 画像データ
y_trains = []  # ラベル情報
# データセット作成
for feature, label in training_data:
    X_trains.append(feature)
    y_trains.append(label)
# numpy配列に変換
X = np.array(X_trains)
y = np.array(y_trains)

print(len(X_trains))

'''
# データセットの確認
for i in range(0, 4):
    print("学習データのラベル：", y_train[i])
    plt.subplot(2, 2, i+1)
    plt.axis('off')
    plt.title(label = 'no' if y_train[i] == 0 else 'nagato')
    img_array = cv2.cvtColor(X_train[i], cv2.COLOR_BGR2RGB)
    plt.imshow(img_array)
plt.show()
'''

#データの分割
X_train = X[:24000]
X_test = X[23999:]
train_y = y[:24000][:]
test_y = y[23999:][:]

```
ラベル付けが正しく行われているか確認したい場合、データセットの確認部分の'''を消して有効化してください。
ラベル付けが終わったら、学習機を用意し、大量のデータをぶち込みます。
以下、モデル作成部分のコード

```python:model.py
y_train = to_categorical(train_y)
y_test = to_categorical(test_y)

# vgg16のインスタンスの生成
input_tensor = Input(shape=(50, 50, 3))
vgg16 = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dense(128, activation='relu'))
top_model.add(Dropout(0.2))
top_model.add(Dense(64,activation="relu"))
top_model.add(Dense(2,  activation='softmax'))

# vgg16とtop_modelを連結
model = Model(inputs=vgg16.input, outputs=top_model(vgg16.output))
# 19層目までの重みをfor文を用いて固定
for layer in model.layers[:19]:
  layer.trainable = False

model.compile(loss='categorical_crossentropy',
              optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
              metrics=['accuracy'])

history = model.fit(X_train, y_train, batch_size=16, epochs=5,validation_data=(X_test, y_test))


# 精度の評価
scores = model.evaluate(X_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])

#modelの保存
model.save("nagato_model.h5")

#epoch毎の予測値の正解データとの誤差を表しています
#バリデーションデータのみ誤差が大きい場合、過学習を起こしています

loss=history.history['loss']
val_loss=history.history['val_loss']
epochs=len(loss)

plt.plot(range(epochs), loss, marker = '.', label = 'loss')
plt.plot(range(epochs), val_loss, marker = '.', label = 'val_loss')
plt.legend(loc = 'best')
plt.grid()
plt.xlabel('epoch')
plt.ylabel('loss')
plt.show()
```
<br>

実行結果がこちら
![nagato.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/23670a78-1940-8932-19fe-c9193f3a25fd.png)
Test loss: 0.12008047848939896
Test accuracy: 0.957076370716095

満足のいく数値が出るまで何回かエポック数を弄ったり、バッチサイズを変えたりしましたが、約９６％の精度が出たので良しとしました。
ラベル付けとモデル作成を含め、実行時間は２０分くらいでした。
<br><br>

##５、出来上がったモデルに使用していない画像を渡し、必ず長門を探し出します。
使用していない画像を使い、長門を探し出します。

```python:hyouka.py
import os
import cv2
import matplotlib.pyplot as plt
from keras.models import load_model
import numpy as np
import glob


#ディレクトリを作成
if not os.path.exists("result"):
    os.mkdir("result")
dirname = "./result/"
#modelの読み込み
model = load_model("./nagato_model5.h5")
#適用する画像があるディレクトリを開く
img_path_list = glob.glob("test/*")
num = 0
for img_path in img_path_list:
        img = cv2.imread(img_path, 1)
        name,ext = os.path.splitext(img_path)
        num += 1
        file_name = dirname + "pic" +  str(num) + str(ext)
        img_gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
        cascade_path = "./lbpcascade_animeface.xml"
        cascade = cv2.CascadeClassifier(cascade_path)
        #顔認識を実行
        faces=cascade.detectMultiScale(img_gray, scaleFactor=1.2, minNeighbors=1, minSize=(68,68))
        Labels=["no", "Nagato"]

        Threshold = 0.95
        #顔が検出されたとき
        if len(faces) > 0:
            for fp in faces:
                # 学習したモデルでスコアを計算する
                img_face = img[fp[1]:fp[1]+fp[3], fp[0]:fp[0]+fp[2]]
                img_face = cv2.resize(img_face, (50, 50))
                score = model.predict(np.expand_dims(img_face, axis=0))
                # 最も高いスコアを書き込む
                score_argmax = np.argmax(np.array(score[0]))
                #閾値以下で表示させない
                if score[0][score_argmax] < Threshold:
                    continue
                #文字サイズの調整
                fs_rate= 0.008
                text =  "{0} {1:.1f}% ".format(Labels[score_argmax], score[0][score_argmax]*100)
                #文字を書く座標の調整
                text_rate = 0.22
                #ラベルを色で分ける
                #cv2なのでBGR
                if Labels[score_argmax] == "no":
                    cv2.rectangle(img, tuple(fp[0:2]), tuple(fp[0:2]+fp[2:4]), (0, 0, 255), thickness=3)
                    cv2.putText(img, text, (fp[0],fp[1]+fp[3]+int(fp[3]*text_rate)),cv2.FONT_HERSHEY_DUPLEX,(fp[3])*fs_rate, (0,0,255), 2)
                if Labels[score_argmax] == "Nagato":
                    cv2.rectangle(img, tuple(fp[0:2]), tuple(fp[0:2]+fp[2:4]), (0, 255, 0), thickness=3)
                    cv2.putText(img, text, (fp[0],fp[1]+fp[3]+int(fp[3]*text_rate)),cv2.FONT_HERSHEY_DUPLEX,(fp[3])*fs_rate, (0,255,0), 2)
                
                plt.figure(figsize=(8, 6),dpi=200)
                plt.imshow(cv2.cvtColor(img, cv2.COLOR_BGR2RGB))
                plt.show()
            cv2.imwrite(file_name, img)
        # 顔が検出されなかったとき
        else:
            print("no face")
```
<br><br>

さて、実行結果は、
、
、
、
、
、
、
、
、
、
、
、
、
、
、
、
、

![pic15.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/1ada340f-c5b3-c018-6b6d-b11d3dcbe44d.jpeg)




![pic11.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/d23ed960-0971-ca47-102e-f2a19e253d84.jpeg)




![pic19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/309ee91e-c313-60c6-d926-640b570c060f.png)



![pic2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/040ccd17-9b3b-c5c6-cea9-ca93c22a7a3f.png)


どうやらこのモデルも長門の良さを学習してくれたようです。よかった。

しかし、正解率１００％ではないのでデフォルメされているような画像は間違えているのもありました。
![pic3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/87a6fed0-7005-c93e-c4b1-9074af5cc948.png)

訓練データに使う画像を水増しするのではなく、元データのバリエーションを増やしたり、バッチサイズやエポック数をもっと細かく調整すれば、もっと精度の高いモデルが作れると思います。

<br><br>

#７、おまけ

どうやら作中に出てくるパラレルワールドのもう一人の長門、通称「**消失長門**」は長門と認めていないみたいです。
![pic14.jpeg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/2309c97b-a4dc-cef5-6239-43fff0fbe00e.jpeg)


実は、消失長門と通常の長門の判別がつくのかを検証するため、あえて訓練データに消失長門を入れませんでした。
消失長門を長門と判定する事も勿論ありましたし、モデル作成時点で正解率約９６％なので、消失長門関係なく間違えている可能性がないとは言い切れませんが、デフォルメされていない画像で、正面を見ていて「no」と判定されたのは消失長門だけでした。

<br>

![pic9.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/892214e9-5d02-9b0e-b946-f500e751fd9c.png)



消失長門と長門を判別するモデルが作りたいのであれば、消失長門を**長門ではない**データとして訓練データに追加してしまえば良いのです。
しかし、今回は微妙な表情の違いなどを認識して、判定することができるのかを検証したかったので、これで良しとします。

正確に違いを認識できているとは言い切れませんが、通常の長門は頬を染めてデレてくる事はないと理解できるモデルに育った様で感慨深いです。

<br><br>
![pic12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/39b16561-5b03-4988-7de6-a2d7cf63d423.png)

ただ、どうやら初めてみる長門の笑顔がかわいすぎて１００％長門と判定してしまう人間味溢れるモデルに育った様なので、これからもちゃんと教育していきたいと思います。
<br>
![pic23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2081956/bbf57079-09b2-9c87-151f-5e04abcbb0cc.png)

湯呑みが99.5%長門と判定されたのはマジで分かりません。
たしかに長門のだけどさ、、、、、





<br><br><br><br>

参考になった記事
[AIに五等分の花嫁の正妻を判定させてみた](https://qiita.com/revvve44/items/4efe6722986777312b5c)
[Kerasでアニメ 「けいおん！」を画像認識させてみた]
(https://qiita.com/Taka_input/items/04a23bd8e9101788e583)
[OpenCVでアニメの顔検出]
(https://qiita.com/mczkzk/items/fda37ac4f9ddab2d7f45)