こんにちは!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
key = '自分のAPIキー'
secret = '自分のシークレットキー'
wait_time = 1
def get_photos(glass_name):
savedir = './' + glass_name
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('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_
files = glob.glob(photos_dir + '/*.jpg')
for i, file in enumerate(files):
if i >= 150: break
image = Image.open(file)
image = image.convert('RGB')
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)
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)
np.save('./glass.npy',xy)
今回は学習用データと評価用のデータを7:3で分けました!画像が少な過ぎてちゃんと分類してくれるか不安ですね…. (参考: 画像データからnumpy形式に変換する方法)
モデルの構築・学習・評価
モデルは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
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(
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
files = glob.glob(photos_dir + '/*.jpg')
for i, file in enumerate(files):
if i >= 150: break
image = Image.open(file)
image = image.convert('RGB')
image = image.resize((50, 50))
data = np.asarray(image)
if i < 45:
X_test.append(data)
Y_test.append(index)
continue
data = data.reshape((1,) + data.shape)
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
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)
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
grads = K.gradients(class_output, conv_output)[0]
gradient_function = K.function([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)
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)
jetcam = (np.float32(jetcam) + x / 2)
return jetcam
それぞれ何枚ずつか画像を持ってきて実行しました!左は元の画像と右が分類器が着目している画像です。赤くなるほど着目度が高いです。
ショットグラスは特に飲み口と底を見ている印象が強いです!確かにショットグラスって底が他のグラスに比べて分厚いものが多いので、そこに気づいたんですかね。2枚目はグラスの影をグラスの底だと認識しているのかなあという感じです。
4枚目はグラスの上の何もない場所が赤くなってて何に反応しているのか分からないのですが、もしかしたら、グラスを斜め上から撮った画像を多く使ったりしているからそれに関係しているのかもしれないですね。
ロックグラスはグラスの左部分と底を見ていますね!3枚目の画像は中央に大きくグラスが写っているのにも関わらず背景に反応してます。4枚目に至っては女性らしき人の手と胸を見てますね。多分男の子なんでしょうね笑
ショットグラスやロックグラスが底見ているのに対してタンブラーはグラスの中身を強く見ているようです!レモンやオレンジがついてるのを何枚か入れていたのですが、それには全然反応していないようです。
感想
このように実際に何かを作ると、疑問が出てくるたびに調べたりするので、めちゃくちゃ勉強になりますし、得られることは大きいです。振り返ってみると、コードを書いてる時間よりも他の方の記事を読んだり調べたりする時間がほとんどでした。笑
疑問点や試したいこともたくさんあるので、また作ってみたいと思います!
それではまた次の記事でお会いしましょう。最後までご覧くださりありがとうございました。