Python
機械学習
ディープラーニング
Keras
FineTuning

Finetuningでブランドを分類するAIを作る。

アブスト

Pythonの機械学習ライブラリであるKerasを使って、ユニクロとヨウジヤマモトを分類する分類器を作る。またそのFlaskを用いて、服の写真をアップロードすると予測を行なっってくれる非常に簡易的なWEBアプリを作る。なお分類器を作るにあたり、Fine-tuningという技術を使った。

学んだこと

  1. 画像収集方法
  2. OpenCVを使った画像の加工
  3. KerasでのData Augmentation
  4. FineTuningという技術
  5. 簡単なFlaskの使い方

環境

-macOS 10.14
-Python3.6.5

作業ディレクトリ

brand_recog
├── datasets
│   ├── main
│   │   ├── train
│   │   │   ├── uniqlo
│   │   │   └── yohji
│   │   └── validation
│   │       ├── uniqlo
│   │       └── yohji
│   ├── test
│   └── その他画像用ディレクトリ
├── model_detail
├── templates
│   └── webアプリ用htmlファイル
└── その他pythonファイル

画像収集

今回は画像収集に検索エンジンBingが提供している、Bing Image Search APIといものを使った。
👉Bing Image Search API
このImageSearch機能を使うにはお金がかかるのだが、初めて登録される方は確か2万円ほどのクーポンを貰えるので実質無料でできる。しかしクーポンの有効期限は30日なので注意。

bing_img_search.py
from requests import exceptions
import argparse
import requests
import cv2
import os

ap = argparse.ArgumentParser()
ap.add_argument("-q", "--query", required=True,
                help="search query to search Bing Image API for")
ap.add_argument("-o", "--output", required=True,
                help="path to output directory of images")
args = vars(ap.parse_args())

API_KEY = "YOUR API KEY"
MAX_RESULTS = 250
GROUP_SIZE = 50

URL = "https://api.cognitive.microsoft.com/bing/v7.0/images/search"

EXCEPTIONS = {IOError, FileNotFoundError, exceptions.RequestException, exceptions.HTTPError, exceptions.ConnectionError,
              exceptions.Timeout}

term = args["query"]
headers = {"Ocp-Apim-Subscription-Key": API_KEY}
params = {"q": term, "offset": 0, "count": GROUP_SIZE}

print("[INFO] searching Bing API for '{}'".format(term))
search = requests.get(URL, headers=headers, params=params)
search.raise_for_status()

results = search.json()
estNumResults = min(results["totalEstimatedMatches"], MAX_RESULTS)
print("[INFO] {} total results for '{}'".format(estNumResults,
                                                term))

total = 0

for offset in range(0, estNumResults, GROUP_SIZE):
    print("[INFO] making request for group {}-{} of {}...".format(
        offset, offset + GROUP_SIZE, estNumResults))
    params["offset"] = offset
    search = requests.get(URL, headers=headers, params=params)
    search.raise_for_status()
    results = search.json()
    print("[INFO] saving images for group {}-{} of {}...".format(
        offset, offset + GROUP_SIZE, estNumResults))

    for v in results["value"]:
        try:
            print("[INFO] fetching: {}".format(v["contentUrl"]))
            r = requests.get(v["contentUrl"], timeout=30)

            ext = v["contentUrl"][v["contentUrl"].rfind("."):]
            p = os.path.sep.join([args["output"], "{}{}".format(
                str(total).zfill(8), ext)])

            f = open(p, "wb")
            f.write(r.content)
            f.close()

        except Exception as e:
            if type(e) in EXCEPTIONS:
                print("[INFO] skipping: {}".format(v["contentUrl"]))
                continue

        image = cv2.imread(p)

        if image is None:
            print("[INFO] deleting: {}".format(p))
            os.remove(p)
            continue

        total += 1

ほとんどコピペコードなので詳しくコードの説明はできないが、実行するには

$ python bing_img_search.py "検索したい言葉" 画像を保存するディレクトリ 

という風にコマンドを打つ。あと
API_KEY = "YOUR API KEY"
MAX_RESULTS = 250
GROUP_SIZE = 50
の部分は適宜変更が必要で、API_KEYにはBing Image Search APIに登録すると取得できる自分のAPI KEYを文字列として代入する。MAX_RESULTSは最高取得枚数、GROUP_SIZEは何回ごとにダウンロードをリロードするかの設定。
例えば上記の設定の場合、50,100,150,200,250という風に50枚ごとに、計5回リロードを行いながらダウンロードする。

あと他の記事などでも言われているがBing Image Search APIは最高で800枚ほどしか画像を集められない。つまりMAX_RESULTSを1000に設定していてもそのくらいしか集まらないのだ!(Fuck)

参考にした記事

👉Bingの画像検索APIを使って画像を大量に収集する[1]
こちらの記事はBing Image Search APIの登録方法も書かれていてとても親切です。

画像加工

今回は上着だけの分類にしたかった。そのためBingで集めた画像の中から上着以外のものを手作業で省いていった。これはまあまあ大変だった。。。
次に上着の画像の背景を真っ白にした。単なるユニクロとヨウジの服分類なら背景があっても問題はないと思われる。しかし今回はこれらの集めた画像を使ってGAN(画像の自動生成アルゴリズム)も作ろうと考えていたため背景が白い画像にわざわざ加工した。(GANに関しては画像数が少なすぎてむりぽだった。)

mask.py
import cv2
import os

path = "./datasets/加工した画像のあるディレクトリ"
img_list = os.listdir(path)
if ".DS_Store" in img_list:
    img_list.remove(".DS_Store")

out_dir = "出力ディレクトリ"
if not (os.path.exists(os.path.join("./datasets", out_dir))):
    os.mkdir(os.path.join("./datasets", out_dir))

for img in img_list:
    tgt_img = cv2.imread(os.path.join(path, img))

    # convert an image into gray scale
    gray_img = cv2.cvtColor(tgt_img, cv2.COLOR_BGR2GRAY)

    # get a binary inverse mask
    _, mask_inverse = cv2.threshold(gray_img, 150,255, cv2.THRESH_BINARY)

    # get a binary mask
    mask = cv2.bitwise_not(mask_inverse)

    # convert a mask into 3 channels
    mask_rgb = cv2.cvtColor(mask, cv2.COLOR_GRAY2RGB)

    # apply bitwise and on mask
    masked_img = cv2.bitwise_and(tgt_img, mask_rgb)

    # replace the cut_out parts with white
    mskd_img_replace_white = cv2.addWeighted(masked_img, 1, cv2.cvtColor(mask_inverse, cv2.COLOR_GRAY2RGB), 1, 0)

    cv2.imwrite(os.path.join(os.path.join("./datasets", out_dir), "cpied"+img), mskd_img_replace_white)

やってることは、
(1)osモジュールのlistdir関数に加工したがそうが治めてあるディレクトリのパスをわたし、画像のリストを得る。(画像ではない'.DS_Store'ファイルは除いている)
(2)加工済みの画像を保存するディレクトリがなければ新たに作る。
--img_list内の画像のループ--
(3)imread関数で画像を読み込む。
(4)画像をBGR画像からモノクロ画像に変換。
(5)threshold関数でモノクロ画像から2値化されたフィルター(maskという)を作り出す。threshold関数の第二引数が閾値、第三引数が閾値を超えた場合に設定する値(画像表現における最大値255を設定する)。つまり上記の例の場合、値が150より小さいピクセルは値を0に、150以上のピクセルは255にしてしまったフィルターを生成するということ。第四引数のcv2.THRESH_BINARYは2値化したフィルターを作るという宣言。
(6)bitwise_notでフィルターの白黒を反転させる。
(7)フィルターをモノクロからRGB画像に対応したものに変換する。
(8)bitwise_andで元画像にフィルターを適用する。
(9)フィルターで取り除かれた部分にaddWeighted関数で白い画像で置き換える。(真っ白な背景完成)
(10)加工済み画像を保存。
--ループ終わり--

参考にした記事

👉画像処理入門講座 : OpenCVとPythonで始める画像処理[2]

Data Augmentation

Data Augmentationは日本語に訳すとデータ拡張。例えば分類器を学習させる時、訓練データが少なくても、このデータ拡張を使ってデータ数を増やすことで分類精度が向上することがわかっている。具体的には画像を傾かせたり、引き伸ばしたり、ズームにしたり、横にスライドさせたりなどしてデータ量を増やす。こんなことでデータが増えたと言えるのかと思うかもしれないが、これでも十分に分類器を騙せると研究でわかっている。
Kerasにはデータ拡張が簡単にできるクラスが用意されている!めちゃ便利

data_augment.py
import numpy as np
import os
import glob
from keras.preprocessing.image import ImageDataGenerator, load_img, img_to_array


def draw_img(generator, x, out_dir, img_index):
    save_name = "expand_" + str(img_index)

    g = generator.flow(x, batch_size=1, save_to_dir=out_dir, save_prefix=save_name, save_format="jpg")

    for j in range(10):
        g.next()


if __name__ == "__main__":
    in_dir = "拡張したい画像のディレクトリ"
    out_dir = "拡張後の保存ディレクトリ"

    if not (os.path.exists(os.path.join("./datasets", out_dir))):
        os.mkdir(os.path.join("./datasets", out_dir))

    images = glob.glob(os.path.join("./datasets", in_dir, "*"))

    generator = ImageDataGenerator(rotation_range=90)

    for i in range(len(images)):
        target_img = load_img(images[i])
        x = img_to_array(target_img)
        x = np.expand_dims(x, axis=0)

        draw_img(generator, x, os.path.join("./datasets", out_dir), i)

draw_img関数はデータ拡張を適用して保存する自作関数。一種類の拡張処理に対して10組の画像を生成している。
処理としては、
(1)保存先ディレクトリが存在しなければ、新たに作る。
(2)globモジュールでディレクトリから画像をとってきてリストにする。
(3)KerasのImageDataGeneratorでデータ拡張するためのジェネレータを作成。この時にどのような種類の拡張処理を行うか設定する。上記コードではrotation_range=90なので90度以内でランダムに回転させるという処理。
--images内の画像のループ--
(4)load_img関数で画像の読み込み。
(5)img_to_array関数で画像をNumpy配列であるndarrayに変換。
(6)先ほど作ったジェネレータを画像に適用して保存。
-- ループ終わり--

参考にした記事

👉Kerasによるデータ拡張[3]

画像数を整える

BingのAPIのスクリプトを叩いて集めた画像は2つのクラス(ユニクロとヨウジ)で数が違っていた。そこで各クラス5000枚の計10000枚に整えるスクリプトを書いた。

arange_5000.py
import os
import re
import random
import shutil

path = "./datasets/クラス別の画像ディレクトリ"
img_list = os.listdir(path)

out_dir = "出力ディレクトリ"
if not (os.path.exists(os.path.join("./datasets", out_dir))):
    os.mkdir(os.path.join("./datasets", out_dir))

for i in range(5000):
    index = re.search(".jpg", img)
    if index:
        shutil.move()


while count > 5000:
    chosen_img = random.choice(img_list)
    if chosen_img != ".DS_Store":
        os.remove(os.path.join(path, chosen_img))

Fine-tuningで分類器を学習

今回は参考にさせていただいた記事に乗っ取りVGG16という有名なCNNのモデルを使ってFine-tuningを試した。CNNをなんとなくでも理解していることを前提にお話しするが、Fine-tuningとは、学習済みの重みとモデルを使って、任意の層までのはその学習済みの重みを用いて(つまり新たに学習しない)、ある層からのみ新たな訓練データ(今回でいうとユニクロとヨウジの上着写真)で学習させるという方法。一般的には全結合層だけでなく、その手前の畳み込み層も新たに学習させる層にするみたい。今回は参考記事と同じで、Imagenetという1000クラスもあるデータセットをVGG16で学習した重みをロードして使った。CNNの浅い層では縦線や横線、まだら模様や縞模様など基本的で一般的な形状しか学習しない。しかしそれらは猫にも、車にも、りんごにも見られるような基本的な形状・模様でわざわざ毎回そこから学習させていては効率的ではないし、Imagenetのような沢山の画像を持つデータセットで学習した方が多くの基本的な形状・模様を学習できるので、できるところは優秀なデータセットの学習結果を拝借しようというのが基本的にFine-tuningの目的。まあ言葉だけではわからないと思うので、リンクから参考記事に飛んでね。

vgg16_finetuning.py
from keras.preprocessing.image import ImageDataGenerator
from keras.applications import VGG16
from keras.layers import Input, Dense, Flatten, Dropout
from keras.models import Sequential, Model
from keras import optimizers
from keras.utils.vis_utils import plot_model


def save_history(history, result_file):
    loss = history.history['loss']
    acc = history.history['acc']
    val_loss = history.history['val_loss']
    val_acc = history.history['val_acc']
    nb_epoch = len(acc)

    with open(result_file, "w") as fp:
        fp.write("epoch\tloss\tacc\tval_loss\tval_acc\n")
        for j in range(nb_epoch):
            fp.write("%d\t%f\t%f\t%f\t%f\n" % (j, loss[j], acc[j], val_loss[j], val_acc[j]))


if __name__ == "__main__":
    # VGG16をダウンロード
    input_tensor = Input(shape=(150, 150, 3))
    vgg16_model = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

    # 全結合層を構築
    top_model = Sequential()
    top_model.add(Flatten(input_shape=vgg16_model.output_shape[1:]))
    top_model.add(Dense(256, activation='relu'))
    top_model.add(Dropout(0.5))
    top_model.add(Dense(1, activation='sigmoid'))

    # VGG16と全結合層を結合
    model = Model(input=vgg16_model.input, output=top_model(vgg16_model.output))
    print('vgg16_model:', vgg16_model)
    print('top_model:', top_model)
    print('model:', model)

    model.summary()
    plot_model(model, to_file="./model_detail/model.png")

    #層の表示
    for i in range(len(model.layers)):
        print(i, model.layers[i])

    # 最後の畳み込み層の直前までfreeze(学習させない)
    for layer in model.layers[:15]:
        layer.trainable = False

    model.summary()

    # 参考記事の方がAdamよりもSGDを推奨していたので自分もそっちで
    model.compile(loss='binary_crossentropy',
                  optimizer=optimizers.SGD(lr=1e-4, momentum=0.9),
                  metrics=['accuracy'])

    train_datagen = ImageDataGenerator(rescale=1.0 / 255)

    val_datagen = ImageDataGenerator(rescale=1.0 / 255)

    train_generator = train_datagen.flow_from_directory(
        './datasets/main/train',
        target_size=(150, 150),
        batch_size=32,
        class_mode='binary')

    validation_generator = val_datagen.flow_from_directory(
        './datasets/main/validation',
        target_size=(150, 150),
        batch_size=32,
        class_mode='binary')

    # 訓練
    history = model.fit_generator(
        train_generator,
        samples_per_epoch=9000,
        nb_epoch=3,
        validation_data=validation_generator,
        nb_val_samples=1000)

    # 結果の保存
    model.save_weights('./model_detail/vgg16_fine.h5')
    save_history(history, './model_detail/history_vgg16_fine.txt')

save_history関数は参考記事を書いた方の自作関数。学習履歴を保存できて非常に便利。
やってることとしては、
(1)入力サイズを指定してVGG16をロード。(Kerasには標準でクラスとして入ってるので便利。weightsの指定でImagenetもそのままロードできる!)
(2)全結合層を構築。(バイナリ分類なのでSigmoid関数で)
(3)ModelクラスでVGG16と作った全結合層をくっつける。
(4)(pooling層も含め)前から15層をfreeze(新たに学習はせずに学習済み重みを使う設定)する。
(5)学習、結果・重みの保存。

Kerasには、先ほど消化したジェネレータにflow_from_directoryというメソッドがあり、これにディレクトリを渡し、class_mode='binary'と指定すると、渡したディレクトリ内にあるディレクトリから勝手にクラスを区別し、教師データを生成してくれるのだ!具体的にはtrainディレクトリにquniloとyohjiを作っておいて、flow_from_directoryにtrainディレクトリを渡すと、quniloとyohjino2つのディレクトリを認識し、それぞれのクラスの教師データを生成するということを学習と並列して処理してくれる。いや〜便利便利

参考にした記事

👉VGG16のFine-tuningによる犬猫認識 (2)[4]

結果

大学にNVIDAのTeslaを積んだ共用計算サーバーがあるにも関わらず自分のMacで学習をしたので本当に時間がかかったし、エポック数を3にした。以下は結果である。

epoch   loss    acc val_loss    val_acc
0   0.336237    0.853759    0.131006    0.955944
1   0.098064    0.971566    0.056027    0.989986
2   0.055371    0.985281    0.034892    0.994017

かなり良い様に見えるが実際はどうなんだろうか、Flaskでのwebアプリ化をして、初見データでその精度を見てみよう。

Flaskでwebアプリ化

以下はpythonスクリプト。画像を選択してもらい、それを読み込んで予測結果を画面上に文字で表示するという処理。手こずったのはgraph = tf.get_default_graph()の部分。これをしないとmodelがスレッド間で共有されずエラーが出ちゃう。

app.py
from flask import Flask, render_template, request, redirect, url_for
import os
import uuid
from keras.applications.vgg16 import VGG16
from keras.models import Sequential, Model
from keras.layers import Input, Dropout, Flatten, Dense
from keras.preprocessing import image
import numpy as np
import tensorflow as tf

app = Flask(__name__)

result_dir = './model_detail'
img_height, img_width = 150, 150
channels = 3

# VGG16
input_tensor = Input(shape=(img_height, img_width, channels))
vgg16_model = VGG16(include_top=False, weights='imagenet', input_tensor=input_tensor)

# FC
top_model = Sequential()
top_model.add(Flatten(input_shape=vgg16_model.output_shape[1:]))
top_model.add(Dense(256, activation='relu'))
top_model.add(Dropout(0.5))
top_model.add(Dense(1, activation='sigmoid'))

# VGG16とFCを接続
model = Model(input=vgg16_model.input, output=top_model(vgg16_model.output))

# 学習済みの重みをロード
model.load_weights(os.path.join(result_dir, 'vgg16_fine.h5'))

model.compile(loss='binary_crossentropy',
              optimizer="adam",
              metrics=['accuracy'])
graph = tf.get_default_graph()


@app.route("/", methods=["GET", "POST"])
def upload_file():
    if request.method == "GET":
        return render_template("index.html")
    if request.method == "POST":
        global graph
        f = request.files["file"]  # アップロードされた画像を保存
        file_path = os.path.join("./datasets/test", str(uuid.uuid4()) + ".jpg")
        f.save(file_path)

        # 画像を読み込んで4次元テンソルへ変換
        with graph.as_default():
            img = image.load_img(file_path, target_size=(img_height, img_width))
            x = image.img_to_array(img)
            x = np.expand_dims(x, axis=0)

            x = x / 255.0

            # クラスを予測
            pred = model.predict(x)[0]
            if pred > 0.5:
                predict = "ヨウジヤマモト"
            else:
                predict = "ユニクロ"
            return render_template("index.html", filepath=file_path, predict=predict)


if __name__ == '__main__':
    app.run(host="0.0.0.0", port=int("5000"), debug=True)

以下はhtmlファイル。

index.html
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>UNIQLO or YOHJI</title>
</head>
<body>
 {% if predict %}
     <IMG SRC="{{filepath}} " BORDER="1"> 予想:{{predict}} <BR>
     <HR>
 {% endif %}
 ファイルを選択して送信してください<BR>
 <form action = "./" method = "POST" enctype = "multipart/form-data">
     <input type = "file" name = "file" />
     <input type = "submit"/>
 </form>
</body>
</html>

このwebアプリローカルでしか動かしていない。大したもんでもないし、公開するの面倒だからその気はあんまりない笑 興味があればご自分のパソコンで試してみてください。

そして考察

色んな画像を試してみて結構正解に当てれていたが、この服は当てられなかった。
image.png
正解はヨウジヤマモトなのだがユニクロと言いやがる。でもこれは人でもユニクロと間違えちゃう人はいるのではないかと思う。ディープラーニングの研究では目指す精度の指標としてhuman levelのパフォーマンスを設けるらしいのだが、上の画像を間違える人もいるのならそんなにこの分類器の欠陥ではないなと思う。ただ共用計算サーバーでもっとエポック数を大きくして学習させたいなと思っている。

参考にした記事

👉KerasとTensorFlowでCannot interpret feed_dict key as Tensorが出た場合の対応[5]
👉ChainerとFlaskで作る機械学習デモアプリ 後編 Webアプリの構築[6]

Weekend Engineer

10月からWeekendEngineer(WE)というコミュニティに入っています。これもその中でも研修みたいな形でやりました!
よければホームページどうぞ👉Weekend Engineerホームページ💻

参考記事・サイト

[1]Bingの画像検索APIを使って画像を大量に収集する
[2]画像処理入門講座 : OpenCVとPythonで始める画像処理
[3]Kerasによるデータ拡張
[4]VGG16のFine-tuningによる犬猫認識 (2)
[5]KerasとTensorFlowでCannot interpret feed_dict key as Tensorが出た場合の対応
[6]ChainerとFlaskで作る機械学習デモアプリ 後編 Webアプリの構築