『グラスを分類するAI』を作ってみた

こんにちは!Aidemy研修生の渡部です!今回はCNNを使って画像分類プログラムを作ります。形の似ているものをちゃんと分類してくれるのか気になったので、大体似たような形状をしているグラス3つを用意しました!

左からタンブラー(ハイボールグラス)、ショットグラス、ロックグラスです。今回は3つだけの判別ですが、複数のグラスを判別できることによってホテルやレストランなどで常にグラスの在庫数の把握などが行えるかもしれません。それによって、棚卸しなどの面倒な作業をAIに任せられるという良い点があります!

実行環境

  • Python 3.6.8
  • jupyter notebook 5.7.4
  • macOS High Sierra Version 10.13.6

Flickr APIを利用して画像の取得

今回分類するのはタンブラー、ショットグラス、ロックグラスです。まずは、事前にこれらの名前でそれぞれファイルを作っておき、画像をFlickrという写真共有サービスのAPIを利用して、各400枚ずつ取得し、作っておいたファイルに保存します。

from flickrapi import FlickrAPI
from urllib.request import urlretrieve
from pprint import pprint
import os,time, sys

#APIキーの保存
key = '自分のAPIキー'
secret = '自分のシークレットキー'
#サーバーのパンクやスパムと見なされる可能性があるため、1秒置く
wait_time = 1

def get_photos(glass_name):
    #保存フォルダの指定
    savedir = './' + glass_name

    #JSONデータで受け取る
    flickr = FlickrAPI(key, secret, format='parsed-json')
    result = flickr.photos.search(
        #検索キーワード
        text = glass_name,
        #取得したい数
        per_page = 400,
        #検索するデータの種類
        media = 'photos',
        #データのソート順
        sort = 'relevance',
        #有害コンテンツの除去
        safe_search = 1,
        #取得したいオプション値
        extras = 'url_q, licence'
    )

    photos = result['photos']

    for i, photo in enumerate(photos['photo']):
        try:
            url_q = photo['url_q']
        except:
            print('取得に失敗しました')
            continue

     #この場所にダウンロードして保存
        filepath = savedir + '/' + photo['id'] + '.jpg'
     #同じファイルが存在していた場合、スキップする
        if os.path.exists(filepath): continue
     #url_qの画像をfilepathに保存する
        urlretrieve(url_q, filepath)
     #1秒おく
        time.sleep(wait_time)

#調べたいキーワードをget_photosに渡す
get_photos('highball glass') #タンブラー
get_photos('lowball glass') #ロックグラス
get_photos('old fashioned glass') #ロックグラス
get_photos('shot glass') #ショットグラス

以下を参考にさせて頂き、各グラス400枚ずつ画像を保存しました。ロックグラスだけ画像が足りなかったのでロックグラスという意味の2つの英語を使って集め、合体させて1つのファイルにまとめました。

(一度に1600枚も取ってきて大丈夫なのかと心配したのですが、Flickr APIの開発者ガイドを見ると、1時間以内に3600回以上要求しなければ大丈夫だそうです!)ここまでで2時間くらいかかってしまいましたが、それでも自分で画像探して取ってくるよりも全然楽なので、こういう時プログラミングってすごいって思います笑

手作業で画像を取り除く

今保存した画像には、関係のない写真や絵が混じっていたり、グラスが少ししか写っていなかったり、真上からの写真などもあるのでそれらを全て消していきます!合計1600枚もあるので本当に大変ですが、気合いで丁寧に消していきます笑

関係ない写真が多くあり、いらない写真を消していくと各400枚⇨各150枚まで少なくなってしまいましたが、とりあえずこのままやってみようと思います。

以下の写真が集めてきた写真の例です。左の列から、タンブラー(ハイボールグラス)、ショットグラス、ロックグラスです。グラスの写真を思ったよりも取ってこられなかったので、グラスにデザインが入っているもの、飲み物が入っているもの、複数のグラスが写っているものなども、写真が鮮明であれば使っています。

画像データをNumpy形式に変換

次に、このまま写真のデータを使うと計算時間が非常に長くなってしまうので、下の記事を参考にして、まずはそれぞれの画像をNumpy形式に変換し、学習用と評価用に振り分けたいと思います!scikit-learn には、トレーニングデータとテストデータの分割を行なうメソッドとしてsklearn.model_selection.train_test_split()メソッドがあるので、こちらを使いたいと思います。

from PIL import Image
import os,glob
import numpy as np
from sklearn import model_selection


classes = ['highball glass', 'lowball glass', 'shot glass']
num_classes = len(classes)
image_size = 50

#画像の読み込み
X = [] #画像データ
Y = [] #ラベルデータ

#それぞれのファイルごとにループさせる
for index, class_ in enumerate(classes):
    photos_dir = './' + class_
    
    #jpg形式の画像データを保存
    files = glob.glob(photos_dir + '/*.jpg')  
    
    #フォルダ内の全ての画像を1つずつ渡す
    for i, file in enumerate(files): 
        #画像データが150を超えたらループを抜ける
        if i >= 150: break
        image = Image.open(file)
        image = image.convert('RGB')
        #画像データを50 x 50に変換
        image = image.resize((image_size,image_size))
        data = np.asarray(image)
        X.append(data)
        Y.append(index)
X = np.array(X)
Y = np.array(Y)

#引数無しだと7.5:2.5に分けられるが、今回は7:3に分ける。
X_train,X_test,y_train,y_test = model_selection.train_test_split(X,Y,test_size=0.3)
xy = (X_train,X_test,y_train,y_test)
#Numpy配列をファイルに保存
np.save('./glass.npy',xy)

今回は学習用データと評価用のデータを7:3で分けました!画像が少な過ぎてちゃんと分類してくれるか不安ですね…. (参考: 画像データからnumpy形式に変換する方法)

len(X_train) #学習用データ 315枚
len(X_test) #評価用データ 135枚

モデルの構築・学習・評価

モデルはCNNを使い、モデルを構築して学習と評価を行いたいと思います!モデルの構築には以下のKerasの画像分類プログラムを参考にしたいと思います。

from keras.models import Sequential
from keras.layers import Conv2D, MaxPooling2D
from keras.layers import Activation, Dropout, Flatten, Dense
from keras.utils import np_utils
import numpy as np
import keras

classes = ['highball glass', 'lowball glass', 'shot glass']
num_classes = len(classes)
image_size = 50

#メインの関数を定義する
def main():
    X_train,X_test,y_train,y_test = np.load('./glass.npy')
    #画像ファイルの正規化
    X_train = X_train.astype('float') / 256
    X_test = X_test.astype('float') / 256
   #教師ラベルをone-hot vectorにする
    y_train = np_utils.to_categorical(y_train,num_classes)
    y_test = np_utils.to_categorical(y_test,num_classes)

    model = model_train(X_train,y_train)
    model_eval(model,X_test,y_test)

def model_train(X,y):
    model = Sequential()
    model.add(Conv2D(32,(3,3), padding='same',input_shape=X.shape[1:]))
    model.add(Activation('relu'))
    model.add(Conv2D(32,(3,3)))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2,2)))
    model.add(Dropout(0.25))

    model.add(Conv2D(64,(3,3),padding='same'))
    model.add(Activation('relu'))
    model.add(Conv2D(64,(3,3)))
    model.add(Activation('relu'))
    model.add(MaxPooling2D(pool_size=(2,2)))
    model.add(Dropout(0.25))

    model.add(Flatten())
    model.add(Dense(512))
    model.add(Activation('relu'))
    model.add(Dropout(0.5))
    model.add(Dense(3))

    model.add(Activation('softmax'))

    #最適化の手法
    opt = keras.optimizers.rmsprop(lr=0.0001,decay=1e-6)

    #モデルのコンパイル
    model.compile(loss='categorical_crossentropy',
                    optimizer=opt,metrics=['accuracy'])

    #モデルの学習
    model.fit(X, y, batch_size=32,epochs=100)

    #モデルの保存
    model.save('./glass_cnn.h5')

    return model

#モデルの評価
def model_eval(model,X,y):
    scores = model.evaluate(X,y,verbose=1)
    print('Test Loss: ', scores[0])
    print('Test Accuracy: ', scores[1])

if __name__ == '__main__':
    main()
Epoch 95/100
337/337 [==============================] - 4s 13ms/step - loss: 0.0850 - acc: 0.9852
Epoch 96/100
337/337 [==============================] - 5s 16ms/step - loss: 0.1004 - acc: 0.9703
Epoch 97/100
337/337 [==============================] - 4s 13ms/step - loss: 0.0624 - acc: 0.9881
Epoch 98/100
337/337 [==============================] - 4s 12ms/step - loss: 0.0834 - acc: 0.9733
Epoch 99/100
337/337 [==============================] - 4s 12ms/step - loss: 0.1008 - acc: 0.9792
Epoch 100/100
337/337 [==============================] - 3s 10ms/step - loss: 0.0655 - acc: 0.9911
113/113 [==============================] - 1s 6ms/step
Test Loss:  0.9943334302016064
Test Accuracy:  0.6725663748462644

正解率があまり高くないのでいくつか精度をあげる工夫をして、正解率を上げようと思います!まずは、画像の量を増やすために水増しをしてみようと思います!

画像の水増し

画像の水増しにはKerasのImageDataGeneratorを使います。パラメーターは、rotation_range、horizontal_flip、vertical_flipを設定し、画像をランダムに回転、反転させ、1枚の画像から新たに9枚の画像を生成するようにします。

from PIL import Image
import os,glob
import numpy as np
from sklearn import model_selection
from keras.preprocessing.image import ImageDataGenerator, array_to_img

classes = ['highball glass', 'lowball glass', 'shot glass']
num_classes = len(classes)


#画像の読み込み
X_train = []
X_test = []
Y_train = []
Y_test = []

datagen = ImageDataGenerator(
                 # -20° ~ 20° の範囲でランダムに回転する。
                            rotation_range=20,
                 # ランダムで上下反転する。
                            horizontal_flip=True,
                            # ランダムで左右反転する。
                            vertical_flip=True
                            #ランダムに水平シフトする。
                            width_shift_range=0.1,
                            #ランダムに垂直シフトする。
                            height_shift_range=0.1,
                            )

#それぞれのファイルごとにループさせる
for index, classlabel in enumerate(classes):
    photos_dir = './' + classlabel
    #jpg形式の画像データを保存
    files = glob.glob(photos_dir + '/*.jpg')  
    
    

    #フォルダ内の全ての画像を1つずつ渡す
    for i, file in enumerate(files): 
        #画像データが150を超えたらループを抜ける
        if i >= 150: break

        image = Image.open(file)
        image = image.convert('RGB')
        #画像データを50 x 50に変換
        image = image.resize((50, 50))
        #画像から配列に変換
        data = np.asarray(image)
        
        #トレーニング用データだけ水増ししたいので、
        #水増しする前にテスト用データを保存しておく。
        if i < 45:
            X_test.append(data)
            Y_test.append(index)
            continue
        #残りのデータを3次元から4次元配列に変更
        data = data.reshape((1,) + data.shape)
        
        #画像を9枚生成する        
        g = datagen.flow(data, batch_size=1)
        for i in range(9):
            batches = g.next()
            g_img = batches[0].astype(np.uint8)
            X_train.append(g_img)
            Y_train.append(index)
            
X_train = np.array(X_train)
X_test = np.array(X_test)
y_train = np.array(Y_train)
y_test = np.array(Y_test)

xy = (X_train,X_test,y_train,y_test)
np.save('./glass_augment.npy',xy)

ImageDataGeneratorで指定できるパラメーターはこちらを参考にさせていただきました。: Keras の ImageDataGenerator を使って学習画像を増やす

再びモデルの学習・評価

epochs数を50で試します。

1575/1575 [==============================] - 16s 10ms/step - loss: 0.6694 - acc: 0.7314
Epoch 45/50
1575/1575 [==============================] - 15s 9ms/step - loss: 0.6439 - acc: 0.7289
Epoch 46/50
1575/1575 [==============================] - 15s 10ms/step - loss: 0.6475 - acc: 0.7333
Epoch 47/50
1575/1575 [==============================] - 15s 10ms/step - loss: 0.6073 - acc: 0.7568
Epoch 48/50
1575/1575 [==============================] - 16s 10ms/step - loss: 0.5976 - acc: 0.7638
Epoch 49/50
1575/1575 [==============================] - 15s 10ms/step - loss: 0.5947 - acc: 0.7568
Epoch 50/50
1575/1575 [==============================] - 15s 10ms/step - loss: 0.5790 - acc: 0.7689
135/135 [==============================] - 1s 8ms/step
Test Loss:  0.7299521702307242
Test Accuracy:  0.696296297620844

水増し後、正解率が数パーセントですが上がりました。また、epochs数を100に増やして試してみたのですが、トレーニングデータの正解率は100%近いのに対して、66%の正解率しか出ませんでした。この場合はモデルが十分に汎化出来ておらず、過学習に陥ってしまった可能性が高いです。この画像数でepochs数100は多すぎたかもしれません。epochs数は50でいきます。

混同行列をヒートマップで表示させる

以下を真似て混同行列をヒートマップで表示させ、テストデータの分類にどのようなミスが多いのかなどを見ていきます! 【python】混同行列(Confusion matrix)をヒートマップにして描画 

import pandas as pd
import seaborn as sns
from sklearn.metrics import confusion_matrix
import matplotlib.pyplot as plt

#y_true->正解ラベル, y_pred->予想ラベル
def print_cmx(y_true, y_pred):
    labels = sorted(list(set(y_true)))
    cmx_data = confusion_matrix(y_true, y_pred, labels=labels)
    labels = ['highball glass', 'lowball glass', 'shot glass']
    
    df_cmx = pd.DataFrame(cmx_data, index=labels, columns=labels)

    plt.figure(figsize = (10,7))
    sns.heatmap(df_cmx, annot=True)
    plt.show()

縦軸が予想されたクラスで、横軸が正解のクラスです。なので、highball glassだと予測して、実際はhighball glassだったのは33枚、lowball glassだったのは5枚、shot glassだったのは6枚というようになっています。

これを見ると、shot glassだと分類して、外しているものが他と比べると少し多いようですが、そもそも全体的に外している回数が多いと感じます。もしかしたら、元のデータセットの画像の質が悪いんじゃないかと思ったので、データセットの画像を選別し直します。とりあえず、取ってきた画像が少なかったから数合わせとして消さずにおいた、グラスが複数写っている画像を3つのフォルダ全てから手作業で消します。

データセットの画像を選別し、モデルの学習を行う

複数のグラスが写っているのを消したら、shot glassの画像が66枚まで少なくなりました。上の画像がshot glass のフォルダです。
そこで、例のごとく学習用データとテストデータを7:3に分けてImageDataGeneratorで画像を水増しして、モデルの学習をします。ImageDataGeneratorのパラメーターは1回目と同じで、epochs数は30に変更しました。

再々評価

Epoch 25/30
1269/1269 [==============================] - 15s 12ms/step - loss: 0.3296 - acc: 0.8810
Epoch 26/30
1269/1269 [==============================] - 15s 12ms/step - loss: 0.3239 - acc: 0.8842
Epoch 27/30
1269/1269 [==============================] - 15s 12ms/step - loss: 0.3011 - acc: 0.8834
Epoch 28/30
1269/1269 [==============================] - 16s 12ms/step - loss: 0.2863 - acc: 0.8936
Epoch 29/30
1269/1269 [==============================] - 15s 12ms/step - loss: 0.2760 - acc: 0.9078
Epoch 30/30
1269/1269 [==============================] - 15s 12ms/step - loss: 0.2545 - acc: 0.9078
54/54 [==============================] - 0s 6ms/step
Test Loss:  0.8330826008761371
Test Accuracy:  0.7962963073341934

分類器としてはあまり良くない精度だと思いますが、正解率は80%近くまで上がりました。結果として、複数のグラスが写っている画像を消すことで、単体のグラスへの分類精度が上がり最初のモデルと比べると10%も高いグラスの分類モデルを作ることができました!データ数が減っても質に対しての妥協はせずにちゃんと画像を選別することが大切だと感じました。

混同行列を見るとショットグラスに分類してミスしてるのが多かったです。理由はおそらくタンブラーとロックグラスは互いにそこまで似てないのに対して、ショットグラスは縦長のものや深めのものがあって、タンブラーとロックグラスのどちらにも似ているということが関係してると思います。それぞれの違いをちゃんと認識させるためにも、画像の枚数がもっと必要だったかもしれません。  画像の質が精度を左右するというくらい、画像の質は大切なことを学びました。

Grad-CAMを実装

分類器がグラスのどの特徴をみて判断しているのか気になったので、CNNが着目している特徴箇所を特定することが出来るというGrad-CAMを使ってみます!以下のブログを真似して実装しました。 (参考 : kerasでGrad-CAM 自分で作ったモデルで)

import pandas as pd
import numpy as np
import cv2
from keras import backend as K
from keras.preprocessing.image import array_to_img, img_to_array, load_img
from keras.models import load_model

K.set_learning_phase(1) #set learning phase

def Grad_Cam(input_model, x, layer_name):

    # 前処理
    X = np.expand_dims(x, axis=0)

    X = X.astype('float32')
    preprocessed_input = X / 255.0


    # 予測クラスの算出

    predictions = model.predict(preprocessed_input)
    class_idx = np.argmax(predictions[0])
    class_output = model.output[:, class_idx]


    #  勾配を取得

    conv_output = model.get_layer(layer_name).output   # layer_nameのレイヤーのアウトプット
    grads = K.gradients(class_output, conv_output)[0]  # gradients(loss, variables) で、variablesのlossに関しての勾配を返す
    gradient_function = K.function([model.input], [conv_output, grads])  # model.inputを入力すると、conv_outputとgradsを出力する関数

    output, grads_val = gradient_function([preprocessed_input])
    output, grads_val = output[0], grads_val[0]

    # 重みを平均化して、レイヤーのアウトプットに乗じる
    weights = np.mean(grads_val, axis=(0, 1))
    cam = np.dot(output, weights)


    # 画像化してヒートマップにして合成

    cam = cv2.resize(cam, (50,50), cv2.INTER_LINEAR) # 画像サイズは200で処理したので
    cam = np.maximum(cam, 0) 
    cam = cam / cam.max()

    jetcam = cv2.applyColorMap(np.uint8(255 * cam), cv2.COLORMAP_JET)  # モノクロ画像に疑似的に色をつける
    jetcam = cv2.cvtColor(jetcam, cv2.COLOR_BGR2RGB)  # 色をRGBに変換
    jetcam = (np.float32(jetcam) + x / 2)   # もとの画像に合成

    return jetcam

それぞれ何枚ずつか画像を持ってきて実行しました!左は元の画像と右が分類器が着目している画像です。赤くなるほど着目度が高いです。

ショットグラス1

ショットグラス2

ショットグラス3

ショットグラス4

ショットグラスは特に飲み口と底を見ている印象が強いです!確かにショットグラスって底が他のグラスに比べて分厚いものが多いので、そこに気づいたんですかね。2枚目はグラスの影をグラスの底だと認識しているのかなあという感じです。

4枚目はグラスの上の何もない場所が赤くなってて何に反応しているのか分からないのですが、もしかしたら、グラスを斜め上から撮った画像を多く使ったりしているからそれに関係しているのかもしれないですね。

ロックグラス1

ロックグラス2

ロックグラス3

ロックグラス4

ロックグラスはグラスの左部分と底を見ていますね!3枚目の画像は中央に大きくグラスが写っているのにも関わらず背景に反応してます。4枚目に至っては女性らしき人の手と胸を見てますね。多分男の子なんでしょうね笑

タンブラー1

タンブラー2

タンブラー3

ショットグラスやロックグラスが底見ているのに対してタンブラーはグラスの中身を強く見ているようです!レモンやオレンジがついてるのを何枚か入れていたのですが、それには全然反応していないようです。

感想

このように実際に何かを作ると、疑問が出てくるたびに調べたりするので、めちゃくちゃ勉強になりますし、得られることは大きいです。振り返ってみると、コードを書いてる時間よりも他の方の記事を読んだり調べたりする時間がほとんどでした。笑
疑問点や試したいこともたくさんあるので、また作ってみたいと思います!

それではまた次の記事でお会いしましょう。最後までご覧くださりありがとうございました。

プログラミング未経験からでもAIスキルが身につくAidemy Premium




PythonやAIプログラミングを学ぶなら、オンライン制スクールのAidemy Premiumがおすすめです。
「機械学習・ディープラーニングに興味がある」
「AIをどのように活用するのだろう?」
「文系の私でもプログラミング学習を続けられるだろうか?」
少しでも気になることがございましたら、ぜひお気軽にAidemy Premiumの【オンライン無料相談会】にご参加いただき、お悩みをお聞かせください!