ラーメンを本気で分類してみた🍥

こんにちは!!研修生のココナツオです!僕はラーメンが大好物です。iPhoneの写真フォルダにはラーメンアルバムを作っていて、日々増えていく写真を眺めることが日々のささやかな楽しみです。

今回は、そのラーメンアルバムのラーメンを種類ごとに分類してみようと思います!!下のような手順で進めていこうと思います!!

  1. 実行環境
  2. FlickrのAPIを利用した画像収集
  3. CNNモデル作成
  4. 学習・評価(1回目)
  5. 画像水増し
  6. 学習・評価(2回目)
  7. 自分のラーメンの写真でテスト!!
  8. テストデータの混同行列の考察
  9. Grad-CAMを利用してディープラーニングを覗く
  10. 考察・今後の改善・感想
  11. 参考文献

実行環境

Python 3.6.6

jupyter notebook 5.7.2

MacBook Air (13-inch)(10.12.6)

FlickrのAPIを利用した画像収集

まずはFlickrという画像共有アプリのAPIを利用して以下のキーワードで各画像300枚ほど集めます!集まった写真の中には関係ないものも含まれているので、そういったものを削除しながら各画像120枚まで厳選します。ものすごく泥臭い作業ですが、この作業が後々の学習精度に大きく貢献します。APIの利用は下のサイトを参考に行いました!

今回使用するキーワードは、「担々麺、家系ラーメン、二郎ラーメン、醤油ラーメン、塩ラーメン、うどん」の6つです!!それぞれ適当にディレクトリの名前をつけて実行します。

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

#APIキーとシークレットを指定する
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)
    #APIでダウンロード
    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モデルについては、下のサイトなどにわかりやすくまとまっています!大まかな理解の助けになると思います。また、後ほど最後の畳み込み層の名称を使うので適当に名前を定義しておきます。

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

#cnnのモデルを定義する

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

#コンパイル済みのcnnのモデルを返す

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)#xとyがnumpyのリストとして与えられる
    print("保存しました:"+outfile,len(x))
    
#path以下の画像を読み込む
def glob_files(path,label):
    files=glob.glob(path+"/*.jpg")
    random.shuffle(files)
    #各ファイルを処理
    num=0
    #print(files)
    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
#ラベルデータをone-hotベクトルに直す
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)
#cnnモデルを取得
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クラスを用いて水増しをしていきたいと思います!

https://keras.io/ja/preprocessing/image/

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は(3, 150, 150)で受け取る
        x = x.reshape((1,) + x.shape)  #(1, 3, 150, 150)に変換する

        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:#1枚から10枚作る
                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
#ラベルデータをone-hotベクトルに直す
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)#画像のshape、ラベルデータの数
model.load_weights("./image/photos-model-light_add.hdf5")

img=cv2.imread(photo)
img=cv2.resize(img, (32,32))#画像のshape
img=cv2.cvtColor(img,cv2.COLOR_BGR2RGB)
plt.imshow(img)
plt.show()
    #arrayに変換
x=np.asarray(img)
x=x.reshape(-1,32,32,3)#画像のshape
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("これは、、ごめんなさいわかりません。")

実行するとこのように表示されます!!下はうまくいった例です。

これは99%の確率で二郎ラーメンです!!

間違えてしまう例もちらほらありますね。

これは87%の確率で塩ラーメンです!!

これ以降は、どのような画像のときにうまく分類できて、どのようなときはできないのかを考えていきたと思います。

テストデータの混同行列の考察

テストデータの混同行列をみて、間違えてしまうときの傾向や得意な分類を考えていきましょう。下のprint_cmx関数は混同行列を計算してヒートマップで表示するものです。

ちなみに、混同行列とは分類精度の評価でよく使われるものです!定義とかは下のサイトで確認して見てください!

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.7727270.80952422
家系ラーメン0.783784
0.9062500.84058032
二郎ラーメン0.821429
0.9200000.86792525
醤油ラーメン0.714286
0.5263160.60606119
塩ラーメン0.5000000.4285710.46153821
うどん0.7407410.8000000.76923125

Grad-CAMを利用してディープラーニングを覗く

この技術は、画像のどのような特徴に注目しているかを可視化することができます。これを利用することで、データの偏りを見つけたり、誤った判断を引き起こしている画像の中の物体などを見つけることができます。

下のGrd_Cam関数は下のサイトを参考にしました!変更する点は、「画像のサイズを200から32にする」ことと「最後の畳み込み層の名称を自分で設定して指定する」ことです!!この関数は、元の画像に注目している箇所を合成して出力します。

https://qiita.com/haru1977/items/45269d790a0ad62604b3

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) #set learning phase

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   # 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, (32, 32), 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

試しに画像を生成してみると、意外と注目する箇所にばらつきがあるので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):#100回の平均をとりたい
        image = Grad_Cam(model, x, 'relu_conv2') #名称を設定した層
        sum=sum+image
    sum=sum/100
    cv2.imwrite("mean_"+file_name, sum)

実行すると、一貫して注目している箇所がくっきりわかるようになりました。何枚か抜粋して見ます。見やすいように、元画像と並べます。

これは100%の確率で二郎ラーメンです!!
これは99%の確率で家系ラーメンです!!

上の2つはきちんと具材に注目して分類できていることがわかります。二郎ラーメンは山盛りのモヤシの部分が真っ赤ですね。家系ラーメンはほうれん草とスープかな?

これは99%の確率で担々麺です!!

上の担々麺は、具材のそぼろ肉?に注目してるようにも見えますが、関係のなさそうなところにの方が注目していますね。個人的には、担々麺こそスープの色を特徴として捉えやすいので、スープに注目が行くと予想していましたが、、、

これは94%の確率で醤油ラーメンです!!

醤油ラーメンは具材にも、ほんのり注目できているものん、器の輪郭の方が強く注目しています。

これは99%の確率で塩ラーメンです!!
これは99%の確率で塩ラーメンです!!

上の2つは塩ラーメンの分類が成功しているものです。1枚目は具材やスープへの注目はあまり見られないものの、器の輪郭を強く注目しています。2枚目はスープや麺を注目しているように見えます。例えばスープの色を特徴と捉えているかもしれないですね。

これは95%の確率で醤油ラーメンです!!#これはうどんです
これは98%の確率でうどんです!!#これは担々麺です

1枚目ははうどんを間違えて分類しているものです、全体的に何に注目しているか判別しにくいですね、、、強いて言うならラーメンの外の部分を強く特徴として捉えています。

2枚目は担々麺を間違えて分類しているものです。これはラーメン自体にほとんど注目できていないですね。むしろ後ろの背景のコップを特徴として捉えてしまっています。

高い精度を出している種類の画像は、注目しているものがある程度定まっていると思いました。逆に低い精度の種類のラーメンに分類したときは、注目しているものの説明がつかなかったり、ばらつきが多かったりする傾向があると思いました。

考察・今後の改善・感想

うまく画像が分類できなかった原因や、疑問点などを考察していこうと思います。

  • ラーメンの種類によって、特徴を掴みにくいときがある

精度がよかった上の3つは大抵ラーメンの上に乗っている具材の種類が似ています。例えば二郎ラーメンは「山盛りの野菜」、家系ラーメンは「ほうれん草とのり」ですね。一方他の3つは、店によって具材がバラバラでした。例えば、うどんは、きつねうどんもあったしかけうどんもあったし、天ぷらのときもありました。塩ラーメンに至ってはもっとバラバラです。

  • 画像の画素数が少なかった

今回扱っている画像のサイズは、32×32×3です。これは本来の写真の見栄えとはかけ離れているものがあります。もっと画素数を大きくすれば、より細かに特徴が捉えられるのではないかと思います。しかし、画素数を多くすると学習にかかる時間も増えてくることが難点です。また画像数(特徴量)が増えすぎるのもよくないので、ちょうどいい画素数をその都度試すことが必要です。

  • 画像の枚数がまだ足りない

今回用意したラーメンの画像の特徴として、それぞれ絶対の注目してほしい箇所である、「具材の種類、スープの色、麺の太さ」を確実に学習させるにはもっと膨大なデータが必要だった可能性がありますね。Grad-CAMの結果から、具材を学習させるには、比較的十分な画素数と画像数だったのではないでしょうか。

  • 画像の中にラーメンと関係ないものがまだ多い

300枚から100枚まで厳選しましたが、まだまだ余計な物体(コップ、テーブルの隙間、容器、レンゲ、箸)が写り込んでいるということではないでしょうか。対処法として、集めた画像からさらにラーメンのどんぶりを輪郭抽出して新しく作った画像を学習させるなどが考えられます。また、余計な物体が写り込んでいない画像をもっと増やすことも有効だと思います。

また、ラーメン以外のものを使用した場合もラーメンと認識されてしまうことが多々あります。対処法として、もっとたくさんの種類の画像を学習させることではないでしょうか。このモデルでは各正解ラベルの正解率の総和が1になるようにできているので、どの分類にもない画像データが使われた場合は確率が分散して80%以下に引っかかる可能性が高くなると予想されます。

これは99%の確率で塩ラーメンです!! #これはお寿司です

上位3つはいい精度で分類できているので画像自体の問題が大きいと思いますが、CNNモデル自体ももっと試行錯誤する必要があるかもしれないですね。またGrad-Camによる分析も、分類されたラーメンの種類ごとに全てまとめてデータ化した方が正確に傾向が掴めるはずです。

今回わかったことは、画像認識は思っていたより大変な技術だということです。しかし、改善できそうはなところいくつも見つかったので色々試してみようと思います。より深い、本質的な考察・改善ができるよう今後も精進してまいります!!

参考文献

すでに引用先を示したもの以外にこのブログを書くにあたって参考にしたサイトや本を以下に記します。また、今回使用したコードをgithubで公開しているのでぜひ見てみてください!

https://qiita.com/takurooo/items/c06365dd43914c253240


https://github.com/kokonatsu78/ramen_type

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です