やりたいこと
某プログラミングスクールで、機械学習による画像識別とウェブアプリの構築を学習したので、自分が好きな寿司を題材に実践してみることにしました。
まずスモールスタートで初めて、徐々にネタの種類や精度を発展させていくことができそうな題材ということも大きいです。
実行環境
次のとおりです。
- Google Colaboratory
- heroku
#データ収集~前処理
ネットでざっと調べたのですが、icrawlerを使うと、日本語対応も含め手間をかけずにできました。
from icrawler.builtin import BingImageCrawler #https://icrawler.readthedocs.io/en/latest/
import numpy as np
import os, cv2
def CollectSushiImage(neta):
neta_dir = "./Dataset/{}".format(neta)
if not os.path.exists(neta_dir):
os.mkdir(neta_dir)
bing_crawler = BingImageCrawler(
downloader_threads=4,
storage={'root_dir': neta_dir + "/"})
bing_crawler.crawl(
keyword="{} 寿司".format(neta),
max_num=1000)
画像を収集したい寿司ネタのリストを作り、関数に流し込みます。
neta_list = ["まぐろ", "サーモン", "いか", "海老", "蛸", "鯛",
"鯵","数の子", "はまち", "納豆巻き", "かっぱ巻き",
"かんぴょう巻き", "ネギトロ軍艦", "うに軍艦", "いくら軍艦"]
結果を見てみると寿司ネタあたり400~650くらいの画像が収集できていました。
ここから目検確認・手作業で不適当な画像を除いていきます…(結構時間かかる)。
全然関係ない画像も結構収集されてしまっています…。
続いて、画像を学習用に前処理していく関数を作り、処理していきます。
画像サイズは250 * 250にしていますが、小さくしてもよいと思います。100 * 100 でもそれなりの精度に最終的にはなりました。
scratchで水増し処理を重ねていくのですが、水増ししすぎた感があるので(多いネタで5万枚以上!!)、取捨したほうが良いですね。
def Preprocessing(neta):
neta_dir = "./DatasetPreprocessed/{}".format(neta)
blurer1 = np.ones((3, 3))
min_table = 50
max_table = 205
diff_table = max_table - min_table
LUT_HC = np.arange(256, dtype = 'uint8' ) #https://qiita.com/bohemian916/items/9630661cd5292240f8c7
LUT_LC = np.arange(256, dtype = 'uint8' )
# ハイコントラストLUT作成
for i in range(0, min_table):
LUT_HC[i] = 0
for i in range(min_table, max_table):
LUT_HC[i] = 255 * (i - min_table) / diff_table
for i in range(max_table, 255):
LUT_HC[i] = 255
# ローコントラストLUT作成
for i in range(256):
LUT_LC[i] = min_table + i * (diff_table) / 255
scratch = np.array([
lambda x: cv2.flip(x, 1),#画像の左右反転1
lambda x: cv2.flip(x, -1),#画像の左右反転2
lambda x: cv2.LUT(x, LUT_LC),#ローコントラスト化
lambda x: cv2.LUT(x, LUT_HC),#ハイコントラスト化
lambda x: cv2.warpAffine(x, cv2.getRotationMatrix2D(tuple(np.array([x.shape[1], x.shape[0]]) / 2), 45, 1.0), (x.shape[1], x.shape[0])),#画像の回転1
lambda x: cv2.warpAffine(x, cv2.getRotationMatrix2D(tuple(np.array([x.shape[1], x.shape[0]]) / 2), 150, 1.0), (x.shape[1], x.shape[0])),#画像の回転2
lambda x: cv2.threshold(x, 128, 255, cv2.THRESH_TOZERO)[1],#閾値処理
lambda x: cv2.GaussianBlur(x, (5, 5), 0)#ぼかし
])
# 関数と画像を引数に、加工した画像を元と合わせて水増しする関数
doubling_images = lambda f, imag: (imag + [f(i) for i in imag])
if not os.path.exists(neta_dir):
os.mkdir(neta_dir)
images_dir = glob.glob("./Dataset/{}/*".format(neta))
for image_dir in images_dir:
img = cv2.imread(image_dir)
img = cv2.resize(img, (250,250))
img_name = image_dir.split("/")[-1][:-4]
extension = image_dir.split("/")[-1][-4:]
images = [img]
# doubling_imagesを用いてmethodsがTrueの関数で画像データ(images)を水増し
for func in scratch:
images = doubling_images(func, images)
for i,image in enumerate(images):
cv2.imwrite(neta_dir + "/" + img_name + str(i*100) + extension , image)
return True
トレーニングデータとテストデータを用意します。8:2で分割します。
結局ここで各寿司ネタから5000枚までに絞っています。
一番データが少ないネタがそれくらいなので、アンダーサンプリングしているような感じでしょうか。
neta_paths = os.listdir('./DatasetPreprocessed/')
neta_dict = {neta:i for i, neta in enumerate(neta_paths)}
netas = list(neta_dict.keys())
X = []; y = []
np.random.seed(1)
sample_num = 5000
for neta,i in neta_dict.items():
paths = os.listdir('./DatasetPreprocessed/{}/'.format(neta))
if len(paths) > sample_num:
paths = np.array(paths)[[i for i in random.sample(range(0,len(paths)), k=sample_num)]]
for path in paths:
img = cv2.imread('./DatasetPreprocessed/{}/{}'.format(neta,path))
b,g,r = cv2.split(img)
img = cv2.merge([r,g,b])
X.append(img); y.append(i)
rand_index = np.random.permutation(np.arange(len(X)))
X = np.array(X)[rand_index]; y = np.array(y)[rand_index]
# データの分割
X_train = X[:int(len(X)*0.8)]
y_train = y[:int(len(y)*0.8)]
X_test = X[int(len(X)*0.8):]
y_test = y[int(len(y)*0.8):]
y_train = to_categorical(y_train)
y_test = to_categorical(y_test)
手法・アルゴリズム
最終層を除いてVGG16を延用し、これに独自で構築したモデルを接続して一つのモデルとします。
モデルの構築と訓練の部分のコードは次のとおりです。accuracyスコアを見ながら訓練させました。
ちなみに、冒頭のmagniは画像のサイズもハイパーパラメータのように捉え、調整を容易にするために設定しています。
まったく経験的なものです(かつ処理が重くて、少ない試行回数)。
magni = img.shape[1] / 50
vgg16 = VGG16(include_top = False, weights = "imagenet", input_tensor = Input(shape = (img.shape[1], img.shape[0], 3)))
top_model = Sequential()
top_model.add(Flatten(input_shape = vgg16.output_shape[1:]))
top_model.add(BatchNormalization())
top_model.add(Dense(256*magni**0.5, activation = "relu"))
top_model.add(Dense(512*magni**0.5, activation = "softmax"))
top_model.add(Dense(1024*magni**0.5, activation = "tanh"))
top_model.add(BatchNormalization())
top_model.add(Dense(64*magni**0.5, activation = "tanh"))
top_model.add(Dropout(rate=0.5))
top_model.add(Dense(len(netas), activation = "softmax"))
model = Model(inputs = vgg16.input, outputs = top_model(vgg16.output))
for layer in model.layers[:19]:
layer.trainable = False
model.compile(loss = "categorical_crossentropy",
optimizer = optimizers.SGD(learning_rate = 0.01, momentum = 0.5),
metrics = ["accuracy"])
model.fit(X_train, y_train, batch_size = 30,epochs = 3, validation_data=(X_test, y_test))
scores = model.evaluate(X_test, y_test, verbose=1)
print('Test loss:', scores[0])
print('Test accuracy:', scores[1])
なお、訓練時の様子は次のとおり。
Epoch 1/3
1983/1983 [==============================] - 99s 50ms/step - loss: 1.6320 - accuracy: 0.4660 - val_loss: 0.8304 - val_accuracy: 0.7482
Epoch 2/3
1983/1983 [==============================] - 100s 51ms/step - loss: 0.6074 - accuracy: 0.8218 - val_loss: 0.4956 - val_accuracy: 0.8656
Epoch 3/3
1983/1983 [==============================] - 100s 51ms/step - loss: 0.3643 - accuracy: 0.9104 - val_loss: 0.4694 - val_accuracy: 0.8868
465/465 [==============================] - 19s 41ms/step - loss: 0.4694 - accuracy: 0.8868
Test loss: 0.4693906903266907
Test accuracy: 0.8868266940116882
あとは予測関数を作っておいて、新規の画像を投入したり、性能を手作業でも確認しました。
def pred_neta(img):
img = np.array([img])
result = netas[np.argmax(model.predict(img))]
return result
すると、水増しのしすぎだと思いますが、データセット以外のデータへの精度が悪い…。
かといって、画像収集もなかなか骨が折れるので、各寿司ネタの画像データ数を含むハイパーパラメータの調整で、できるところまでやっていきました。
あとはherokuにデプロイすれば完了です。
なお、herokuにデプロイする際にH10エラーが出ましたが、opencv-pythonではなくopencv-python-headlessを入れると解決しました。
考察・反省
まずデータ収集をもっと頑張らなければいけないと思います。実は不要画像を除くとオリジナルの画像は寿司ネタによりますが、各50~200枚程度。圧倒的に足りないです。処理やモデルの改善は、そのあとかなという気がしています。
また、今回はまぐろとうに軍艦のように、人間が見たらすぐ判別がつくものばかり集めています。図鑑的な感じで役に立つかもしれませんが、本当は白身魚とか、ちょっと判別が付きづらいものとかマイナーなネタをやった方が役に立つと思います。
今後の課題にしたいと思います。
最後にウェブアプリはこんな感じです。https://sushidiscriminator.herokuapp.com/