こんにちは!!研修生のココナツオです!僕はラーメンが大好物です。iPhoneの写真フォルダにはラーメンアルバムを作っていて、日々増えていく写真を眺めることが日々のささやかな楽しみです。
今回は、そのラーメンアルバムのラーメンを種類ごとに分類してみようと思います!!下のような手順で進めていこうと思います!!
実行環境
- Python 3.6.6
- jupyter notebook 5.7.2
- MacBook Air (13-inch)(10.12.6)
FlickrのAPIを利用した画像収集
まずはFlickrという画像共有アプリのAPIを利用して以下のキーワードで各画像300枚ほど集めます!集まった写真の中には関係ないものも含まれているので、そういったものを削除しながら各画像120枚まで厳選します。ものすごく泥臭い作業ですが、この作業が後々の学習精度に大きく貢献します。APIの利用は下のサイトを参考に行いました!
プロダクトマネージャーの雑記「Flickr APIを使って画像ファイルをダウンロードする」
今回使用するキーワードは、「担々麺、家系ラーメン、二郎ラーメン、醤油ラーメン、塩ラーメン、うどん」の6つです!!それぞれ適当にディレクトリの名前をつけて実行します。
from flickrapi import FlickrAPI
from urllib.request import urlretrieve
from pprint import pprint
import os,time,sys
key="自分で取得したAPIキー"
secret="自分で取得したシークレット"
wait_time=2
def main():
go_download("担々麺","tantan")
go_download("家系ラーメン","iekei")
go_download("二郎ラーメン","jirou")
go_download("醤油ラーメン","shoyu")
go_download("塩ラーメン","sio")
go_download("うどん","udon")
def go_download(keyword,dir):
savedir="./image/"+dir
if not os._exists(savedir):
os.mkdir(savedir)
flickr=FlickrAPI(key,secret,format="parsed-json")
res=flickr.photos.search(
text=keyword,
per_page=300,
media="photos",
sort="relevance",
safe_search=1,
extras="url_q,license")
photos=res["photos"]
pprint(photos)
try:
for i ,photo in enumerate(photos["photo"]):
url_q=photo["url_q"]
filepath=savedir+"/"+photo["id"]+".jpg"
if os.path.exists(filepath):continue
print(str(i+1)+":download=",url_q)
urlretrieve(url_q,filepath)
time.sleep(wait_time)
except:
import traceback
traceback.print_exc()
if __name__=="__main__":
main()

CNNモデル作成
Karasというライブラリを用います!画像認識では定番のCNNモデルを使用します。扱う画像のサイズが32×32×3であることに注意します。CNNモデルについては、下のサイトなどにわかりやすくまとまっています!大まかな理解の助けになると思います。また、後ほど最後の畳み込み層の名称を使うので適当に名前を定義しておきます。
https://cpp-learning.com/operate_pixel/cnn_model.py
と名前をつけて保存しておきましょう。
import keras
from keras.models import Sequential
from keras.layers import Dense,Dropout,Flatten
from keras.layers import Conv2D,MaxPooling2D
from keras.optimizers import RMSprop
def def_model(in_shape,nb_classes):
model=Sequential()
model.add(Conv2D(32,
kernel_size=(3,3),
activation="relu",
input_shape=in_shape))
model.add(Conv2D(32,(3,3),activation="relu"))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))
model.add(Conv2D(64,(3,3),activation="relu"))
model.add(Conv2D(64,(3,3),activation="relu",name="relu_conv2"))
model.add(MaxPooling2D(pool_size=(2,2)))
model.add(Dropout(0.25))
model.add(Flatten())
model.add(Dense(512,activation="relu"))
model.add(Dropout(0.5))
model.add(Dense(nb_classes,activation="softmax"))
return model
def get_model(in_shape,nb_classes):
model=def_model(in_shape,nb_classes)
model.compile(
loss="categorical_crossentropy",
optimizer=RMSprop(),
metrics=["accuracy"])
return model
学習・評価(1回目)
先ほど収集した画像を全てNumpy形式に変換して保存します。この際に各画像データにラベルデータが対応するようにします。
import os,glob,random
import cv2
import numpy as np
outfile="image/photos_ramen_add.npz"
max_photo=120
photo_size=32
x=[]
y=[]
def main():
glob_files("./image/tantan",0)
glob_files("./image/iekei",1)
glob_files("./image/jirou",2)
glob_files("./image/shoyu",3)
glob_files("./image/sio",4)
glob_files("./image/udon",5)
np.savez(outfile,x=x,y=y)
print("保存しました:"+outfile,len(x))
def glob_files(path,label):
files=glob.glob(path+"/*.jpg")
random.shuffle(files)
num=0
for f in files:
if num >=max_photo:break
num+=1
img=cv2.imread(f)
img=cv2.resize(img, (photo_size,photo_size ))
img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
img=np.asarray(img)
x.append(img)
y.append(label)
print(num)
if __name__=="__main__":
main()
変換したら早速トレーニング開始です!このファイルのディレクトリにcnn_model.pyを置くことを忘れないでください。また、Numpyの変換データの場所についても、image/photos_ramen.npzであることに注意しましょう。
import cnn_model
import keras
import matplotlib.pyplot as plt
import numpy as np
from sklearn.model_selection import train_test_split
im_rows=32
im_cols=32
im_color=3
in_shape=(im_rows,im_cols,im_color)
nb_classes=6
photos=np.load("image/photos_ramen.npz")
x=photos["x"]
y=photos["y"]
x=x.reshape(-1,im_rows,im_cols,im_color)
x=x.astype("float32")/255
y=keras.utils.np_utils.to_categorical(y.astype("int32"),nb_classes)
x_train,x_test,y_train,y_test=train_test_split(x,y,train_size=0.8)
model=cnn_model.get_model(in_shape,nb_classes)
hist=model.fit(x_train,y_train,
batch_size=32,
epochs=20,
verbose=1,
validation_data=(x_test,y_test))
score=model.evaluate(x_test,y_test,verbose=1)
print("正解率=",score[1],"loss=",score[0])
plt.plot(hist.history["acc"])
plt.plot(hist.history["val_acc"])
plt.title("Accuracy")
plt.legend(["train","test"],loc="upper left")
plt.show()
plt.plot(hist.history["loss"])
plt.plot(hist.history["val_loss"])
plt.title("Loss")
plt.legend(["train","test"],loc="upper left")
plt.show()
model.save_weights("./image/photos-model-light_add.hdf5")
下のような結果になりました。正解率は58%で良いとも言えない数字ですね。
Epoch数は、だいたい20あたりからテストデータの正解率が横ばいだったので20でモデルを作ります。
Train on 576 samples, validate on 144 samples
~Epochのプロセスは省略~
正解率= 0.5763888888888888 loss= 1.5871914360258315
画像水増し
精度をあげる方法として、定番なのが画像の枚数を増やすことです。データ数が少ない時にはよく使います。今回は、KerasのImageDataGeneratorクラスを用いて水増しをしていきたいと思います!
Keras ImageDataGenerator
ImageDataGeneratorの引数の説明をします!
rotation_range=45とは、-45°~45°の範囲で画像がランダムに回転
vertical_flip=Trueはランダムに上下反転
horizontal_flip=Trueはランダムに左右反転
以上3つを定義しています。(他にもたくさん引数はあります)
この3つの引数に従って、ランダムに画像が生成されるプログラムを作ります。今回は1枚に対して10枚の画像が生成されるようにします。
from keras.preprocessing.image import ImageDataGenerator, array_to_img, img_to_array, load_img
import numpy as np
datagen = ImageDataGenerator(
rotation_range=45,
vertical_flip=True,
horizontal_flip=True,
fill_mode='nearest')
def images_gen(x_list,y_list):
x_list_add=[]
y_list_add=[]
for x ,y in zip(x_list,y_list):
x = x.reshape((1,) + x.shape)
batch_list=[]
i = 0
for batch in datagen.flow(x, batch_size=1):
batch=batch.astype(np.uint8)
batch=batch.reshape((32, 32, 3))
x_list_add.append(batch)
y_list_add.append(y)
i += 1
if i > 9:
break
x_np_add=np.array(x_list_add)
y_np_add=np.array(y_list_add)
return x_np_add,y_np_add
このimages_gen関数を学習するプログラムに組み込みます。水増しするのは、データを分割した後のトレーニングデータだけなので、テストデータには回転や反転させた画像は含まれていません。
x=x.reshape(-1,im_rows,im_cols,im_color)
x_train,x_test,y_train,y_test=train_test_split(x,y,train_size=0.8)
x_train_add,y_train_add=images_gen(x_train,y_train)
x_train_add=x_train_add.astype("float32")/255
x_test=x_test.astype("float32")/255
y_train_add=keras.utils.np_utils.to_categorical(y_train_add.astype("int32"),nb_classes)
y_test=keras.utils.np_utils.to_categorical(y_test.astype("int32"),nb_classes)
model=cnn_model.get_model(in_shape,nb_classes)
hist=model.fit(x_train_add,y_train_add,
batch_size=32,
epochs=20,
verbose=1,
validation_data=(x_test,y_test))
model.save_weights("./image/photos-model-light_add.hdf5")
学習・評価(2回目)
データ水増し後でトライしてみるとこのようになりました。トレーニングデータは90%以上の正解率を出しているものの、テストデータの正解率は75%付近で横ばいですね。一回目と比べて10%以上の精度向上に成功したものの画像分類の精度としては物足りない結果となってしまいました、、、
同じくEpoch数は20でモデルを作ります。
Train on 5760 samples, validate on 144 samples
~Epochのプロセスは省略~
正解率= 0.75 loss= 1.5086454815334744
とりあえず、このモデルを使ってラーメンを分類するプログラムを完成させたいと思います。
自分のラーメンの写真でテスト!!
それでは自分のもっているラーメンの画像でテストしてみます。分類される際、何%の正解率かも表示されるようにします。また、今回用意した写真と関係ない画像を排除できるように、80%以下の正解率の場合は分類が表示されないようにしました。
import cnn_model
import keras
import matplotlib.pyplot as plt
import numpy as np
import cv2
photo="自分で試したい画像"
labels=["担々麺","家系ラーメン","二郎ラーメン","醤油ラーメン","塩ラーメン","うどん"]
model=cnn_model.get_model((32,32,3),6)
model.load_weights("./image/photos-model-light_add.hdf5")
img=cv2.imread(photo)
img=cv2.resize(img, (32,32))
img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
plt.imshow(img)
plt.show()
x=np.asarray(img)
x=x.reshape(-1,32,32,3)
x=x/255
pre=model.predict([x])[0]
idx=pre.argmax()
per=int(pre[idx]*100)
if per>80:
print("これは"+str(per)+"%の確率で"+labels[idx]+"です!!")
else:
print("これは、、ごめんなさいわかりません。")
実行するとこのように表示されます!!下はうまくいった例です。
間違えてしまう例もちらほらありますね。
これ以降は、どのような画像のときにうまく分類できて、どのようなときはできないのかを考えていきたと思います。
テストデータの混同行列の考察
テストデータの混同行列をみて、間違えてしまうときの傾向や得意な分類を考えていきましょう。下のprint_cmx関数は混同行列を計算してヒートマップで表示するものです。
ちなみに、混同行列とは分類精度の評価でよく使われるものです!定義とかは下のサイトで確認して見てください!
kaekenのData Science探究ブログ-Python/scikit-learn/分類精度を評価する際に使われる混同行列 (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= ["tantan","iekei","jirou","shoyu","sio","udon"]
df_cmx = pd.DataFrame(cmx_data, index=labels, columns=labels)
plt.figure(figsize = (10,7))
sns.heatmap(df_cmx, annot=True)
plt.xlabel("Predict-labels")
plt.ylabel("True-labels")
plt.show()
predict_classes = model.predict_classes(x_test, batch_size=32)
true_classes = np.argmax(y_test,1)
print_cmx(true_classes,predict_classes)
これを実行してみると、、、
縦軸が正解のラベル、横軸が予測されたラベルです。二郎ラーメンや家系ラーメンはほとんど分類ミスしてないようですね。一方塩ラーメンはうどんに分類されることが多い傾向が見て取れます。テストデータの各ラーメンの画像枚数に多少偏りがあると思われるので、F値なども確かめて見ましょう。下のコードPandasの形式で表にしました。
from sklearn.metrics import precision_recall_fscore_support
x=precision_recall_fscore_support(true_classes,predict_classes)
data = {"適合率": list(x[0]),
"再現率":list(x[1]),
"F値":list(x[2]),
"枚数":list(x[3])}
df = pd.DataFrame(data)
df.index=["担々麺","家系ラーメン","二郎ラーメン","醤油ラーメン","塩ラーメン","うどん"]
df
表にするとわかりやすいですね。「担々麺、二郎ラーメン、家系ラーメン」だけでみるとF値が結構高いので、他の3つの精度が足を引っ張っているようです。特に塩ラーメンに関しての精度は最悪ですね笑
|
適合率
|
再現率
|
F値
|
枚数
|
担々麺
|
0.850000
|
0.772727
|
0.809524
|
22
|
家系ラーメン
|
0.783784
|
0.906250
|
0.840580
|
32
|
二郎ラーメン
|
0.821429
|
0.920000
|
0.867925
|
25
|
醤油ラーメン
|
0.714286
|
0.526316
|
0.606061
|
19
|
塩ラーメン
|
0.500000
|
0.428571
|
0.461538
|
21
|
うどん
|
0.740741
|
0.800000
|
0.769231
|
25
|
Grad-CAMを利用してディープラーニングを覗く
この技術は、画像のどのような特徴に注目しているかを可視化することができます。これを利用することで、データの偏りを見つけたり、誤った判断を引き起こしている画像の中の物体などを見つけることができます。
下のGrd_Cam関数は下のサイトを参考にしました!変更する点は、「画像のサイズを200から32にする」ことと「最後の畳み込み層の名称を自分で設定して指定する」ことです!!この関数は、元の画像に注目している箇所を合成して出力します。
Qlita「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
K.set_learning_phase(1)
def Grad_Cam(model, x, layer_name):
'''
Args:
model: モデルオブジェクト
x: 画像(array)
layer_name: 畳み込み層の名前
Returns:
jetcam: 影響の大きい箇所を色付けした画像(array)
'''
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, (32, 32), 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
試しに画像を生成してみると、意外と注目する箇所にばらつきがあるので100回繰り返した平均をとった画像を生成したいと思います!!そうすることで、一貫して注目する箇所が見つかるのではないかと考えました。
import cnn_model
from keras.utils.vis_utils import plot_model
import matplotlib.pyplot as plt
%matplotlib inline
def grad_mean(file_name):
sum = np.zeros(3072).reshape(32 ,32,3)
model=cnn_model.get_model((32,32,3),6)
model.load_weights("./image/photos-model-light_new.hdf5")
x = img_to_array(load_img(file_name, target_size=(32,32)))
for i in range(0,100):
image = Grad_Cam(model, x, 'relu_conv2')
sum=sum+image
sum=sum/100
cv2.imwrite("mean_"+file_name, sum)
実行すると、一貫して注目している箇所がくっきりわかるようになりました。何枚か抜粋して見ます。見やすいように、元画像と並べます。

上の2つはきちんと具材に注目して分類できていることがわかります。二郎ラーメンは山盛りのモヤシの部分が真っ赤ですね。家系ラーメンはほうれん草とスープかな?
上の担々麺は、具材のそぼろ肉?に注目してるようにも見えますが、関係のなさそうなところにの方が注目していますね。個人的には、担々麺こそスープの色を特徴として捉えやすいので、スープに注目が行くと予想していましたが、、、
醤油ラーメンは具材にも、ほんのり注目できているもの、器の輪郭の方が強く注目しています。
上の2つは塩ラーメンの分類が成功しているものです。1枚目は具材やスープへの注目はあまり見られないものの、器の輪郭を強く注目しています。2枚目はスープや麺を注目しているように見えます。例えばスープの色を特徴と捉えているかもしれないですね。
これは98%の確率でうどんです!!#これは担々麺です
1枚目ははうどんを間違えて分類しているものです、全体的に何に注目しているか判別しにくいですね、、、強いて言うならラーメンの外の部分を強く特徴として捉えています。
2枚目は担々麺を間違えて分類しているものです。これはラーメン自体にほとんど注目できていないですね。むしろ後ろの背景のコップを特徴として捉えてしまっています。
高い精度を出している種類の画像は、注目しているものがある程度定まっていると思いました。逆に低い精度の種類のラーメンに分類したときは、注目しているものの説明がつかなかったり、ばらつきが多かったりする傾向があると思いました。
考察・今後の改善・感想
うまく画像が分類できなかった原因や、疑問点などを考察していこうと思います。
- ラーメンの種類によって、特徴を掴みにくいときがある
精度がよかった上の3つは大抵ラーメンの上に乗っている具材の種類が似ています。例えば二郎ラーメンは「山盛りの野菜」、家系ラーメンは「ほうれん草とのり」ですね。一方他の3つは、店によって具材がバラバラでした。例えば、うどんは、きつねうどんもあったしかけうどんもあったし、天ぷらのときもありました。塩ラーメンに至ってはもっとバラバラです。
今回扱っている画像のサイズは、32×32×3です。これは本来の写真の見栄えとはかけ離れているものがあります。もっと画素数を大きくすれば、より細かに特徴が捉えられるのではないかと思います。しかし、画素数を多くすると学習にかかる時間も増えてくることが難点です。また画像数(特徴量)が増えすぎるのもよくないので、ちょうどいい画素数をその都度試すことが必要です。
今回用意したラーメンの画像の特徴として、それぞれ絶対の注目してほしい箇所である、「具材の種類、スープの色、麺の太さ」を確実に学習させるにはもっと膨大なデータが必要だった可能性がありますね。Grad-CAMの結果から、具材を学習させるには、比較的十分な画素数と画像数だったのではないでしょうか。
300枚から100枚まで厳選しましたが、まだまだ余計な物体(コップ、テーブルの隙間、容器、レンゲ、箸)が写り込んでいるということではないでしょうか。対処法として、集めた画像からさらにラーメンのどんぶりを輪郭抽出して新しく作った画像を学習させるなどが考えられます。また、余計な物体が写り込んでいない画像をもっと増やすことも有効だと思います。
また、ラーメン以外のものを使用した場合もラーメンと認識されてしまうことが多々あります。対処法として、もっとたくさんの種類の画像を学習させることではないでしょうか。このモデルでは各正解ラベルの正解率の総和が1になるようにできているので、どの分類にもない画像データが使われた場合は確率が分散して80%以下に引っかかる可能性が高くなると予想されます。
これは99%の確率で塩ラーメンです!! #これはお寿司です
上位3つはいい精度で分類できているので画像自体の問題が大きいと思いますが、CNNモデル自体ももっと試行錯誤する必要があるかもしれないですね。またGrad-Camによる分析も、分類されたラーメンの種類ごとに全てまとめてデータ化した方が正確に傾向が掴めるはずです。
今回わかったことは、画像認識は思っていたより大変な技術だということです。しかし、改善できそうはなところいくつも見つかったので色々試してみようと思います。より深い、本質的な考察・改善ができるよう今後も精進してまいります!!
参考文献
すでに引用先を示したもの以外にこのブログを書くにあたって参考にしたサイトや本を以下に記します。