Help us understand the problem. What is going on with this article?

玉子焼きとだし巻き玉子を分類してみた

More than 1 year has passed since last update.

みなさん、こんちわ!都内で機械学習エンジニアとして働いており、MLOps をメインに、NLP やレコメンドの開発をしています。あと、玉子焼きが好きです。
この記事は、ただの集団のアドベントカレンダーの21日目の記事になります。初めてアドベントカレンダーの記事を書きましたので、どうぞあたたかい目で読んで頂ければ幸いです。

はじめに

ネットで玉子焼きを調べていると、「玉子焼きとだし巻き玉子の違って、味以外おんなじだよねー」、「玉子焼きとだし巻き玉子って見た目変わんないよねー」というコメントをちらほら見る。

ここでふと思う。

人が「玉子焼き」と「だし巻き玉子」を分類するのは難しいけど、機械学習なら分類できるのでは!?

そこで、機械学習で分類できることを確かめるために、実際に玉子焼きとだし巻き玉子の分類器を作ってみることにした!!

ということで、「玉子焼き」と「だし巻き玉子」の画像を分類するモデルを作ってみます。機能を実現するために、以下のようなフローを作る。

画像収集 -> 前処理 -> 実験

画像を収集する

まずはデータを集める。自分でも玉子焼きとだし巻き玉子のアルバムを持っているがラベリングされていないので、すぐには使えない。そこで Web から玉子焼きとだし巻き玉子の画像を収集することにする。

Web から画像を取得するにあたって、画像を取得する API を調査した。調査したところ、Bing Image Search API の評判が良さそうだったので、これを使うことにする。クイックスタートAPI 仕様を参考に実装してみる。

import requests

search_url = "https://api.cognitive.microsoft.com/bing/v7.0/images/search"
search_term = "玉子焼き"
headers = {"Ocp-Apim-Subscription-Key" : subscription_key}

def get_bing_images(url, headers, term):
    params  = {"q": term,  "mkt": "ja-JP", "count": 150}
    response = requests.get(url, headers=headers, params=params)
    return response.json()

search_results = get_bing_images(search_url, headers, search_term)

ここでいくつか注意点

  • subscription key は、Image Search API に登録すると key が発行されるので、それを使う。
  • リクエストのパラメータで mkt でリージョンの指定する
  • count で取得する数(最大150件)を定義する。デフォルトは 35 件。

これでデータを取得できる。

しかし、無事データ収集ができたかと思ったら、1つ問題が発生した!

Bing の API で「玉子焼き」の画像を集めることはできたが、「だし巻き玉子」の画像が十分収集できなかった。(Bing の画像検索で「だし巻き玉子」「だし巻き卵」「だし巻きたまご」を検索確認したところ、ヒットなし。「出汁巻玉子」は5件ヒットした。)

このままでは学習できないので、だし巻き玉子の画像を追加で収集するために、Google の Custom Search Engine で収集する仕組みも作った。

import requests

search_url = "https://www.googleapis.com/customsearch/v1"
search_term = "出汁巻玉子"

def get_google_images(url, term, key, cx, start):
    params  = {"q": term,  "key": key, "cx": cx, "start": start}
    response = requests.get(url, params=params)
    return response.json()

search_results = get_google_images(search_url, search_term, subscription_key, cx_key, start_index)

ここでも注意点

  • API 利用のために、Custom Search API のキー(subscription_key)と Search Engine Id(cx_key)を発行する必要がある。
  • 1回の検索で最大 10 件までしか取得できないので、10件以上のデータを取得する場合は、オフセット(start)を使って、次ページのデータを取得する仕組みを用意する必要がある。

Google の API も利用して、やっと玉子焼きとだし巻き玉子のデータを用意することができた!
データが用意できたので、次は前処理を行う。

前処理

前処理でやったことは主に2つ。

  • 画像のリサイズ
  • train と test の分割

画像のリサイズは opencv を使って、全画像のサイズを統一した。
以下、実装の一部。

import glob
import os
import cv2 as cv
import tqdm

def resize_images(base_dir, output_dir):
    for img_path in tqdm.tqdm(glob.glob(f'{base_dir}/*')):
        if '.gitkeep' in img_path:
            continue
        img = cv.imread(img_path)
        try:
            fix_img = cv.resize(img, (128, 128))
        except:
            continue
        cv.imwrite(f'{output_dir}/{os.path.basename(img_path)}', fix_img)
  • 画像は 128x128 に設定。

データのtrain と test の分割は、csv で train と test のデータのパスとラベルを管理した。
Pytorch の data loading tutorial を参考に実装したので、train と test で扱うデータを csv で管理しています。作成する csv は以下のようなかんじ。

image,label
tamagoyaki_0067.jpeg,1
tamagoyaki_0101.jpeg,1
tamagoyaki_0113.jpeg,1
tamagoyaki_0042.jpeg,1
tamagoyaki_0119.jpeg,1
tamagoyaki_0022.jpeg,1

これでモデリングの準備が整った!

train&testの実装

こちらのコードを参考に Pytorch でモデルを作成する。

1からモデルを作ると手間なので、ここは偉大な先人たちが作った学習済みモデルを使う。(Fine-tuning 大事ネ。)

学習済みのモデルを取得するために、cnn-finetuneというライブラリを使って、学習済みのモデルを取得する。使い方は以下の通り。

import torch
from cnn_finetune import make_model

model = make_model('vgg16', num_classes=2, pretrained=True, input_size=(128, 128))
device = torch.device('cuda')
model = model.to(device)

vgg16 以外にも resnet などがあるので、いろんなモデルが試せて便利!

データを準備する。データを扱うクラスは以下の通り。

import pandas as pd
from skimage import io
from torch.utils.data import Dataset
from torchvision import transforms

class MyDataSet(Dataset):
    def __init__(self, csv_path, root_dir, transform=None):
        self.data_df = pd.read_csv(csv_path)
        self.root_dir = root_dir

        if transform is None:
            self.transform = transforms.Compose([
                transforms.ToTensor(),
                transforms.Normalize(
                mean=model.original_model_info.mean,
                std=model.original_model_info.std),
            ])
        else:
            self.transform = transform

    def __len__(self):
        return self.data_df.shape[0]

    def __getitem__(self, idx):
        image_name = os.path.join(self.root_dir, self.data_df.iloc[idx, 0])
        image = io.imread(image_name)
        label = self.data_df.iloc[idx, 1]
        return self.transform(image), int(label)

こちらのチュートリアルを参考に用意した。

各データを読み込む処理を実装。

import torch

# load train
train_set = MyDataSet('../images/train_data.csv', '../images/resize/data/')
train_loader = torch.utils.data.DataLoader(train_set, shuffle=True)
# load test
test_set = MyDataSet('../images/test_data.csv', '../images/resize/data/')
test_loader = torch.utils.data.DataLoader(test_set, shuffle=False)

criterion と optimizer の設定する。

import torch.nn as nn
import torch.optim as optim

criterion = nn.CrossEntropyLoss()
optimizer = optim.Adam(model.parameters(), lr=1e-5)

train の実装

def train(epoch):
    total_loss = 0
    total_size = 0
    model.train()
    for batch_idx, (data, target) in enumerate(train_loader):
        data, target = data.to(device), target.to(device)
        optimizer.zero_grad()
        output = model(data)
        loss = criterion(output, target)
        total_loss += loss.item()
        total_size += data.size(0)
        loss.backward()
        optimizer.step()
        if (batch_idx+1) % 20 == 0 or (batch_idx+1)==len(train_loader):
            progress = 100. * (batch_idx+1) / len(train_loader)
            average_loss = total_loss / total_size
            print(f'Train Epoch: {epoch} [{(batch_idx+1) * len(data)}/{len(train_loader.dataset)} ({progress:.0f}%)]\tAverage loss: {average_loss:.6f}')

testの実装

def test():
    model.eval()
    test_loss = 0
    correct = 0
    outputs = []
    targets = []
    with torch.no_grad():
        for data, target in test_loader:
            targets.append(target)
            data, target = data.to(device), target.to(device)
            output = model(data)
            test_loss += criterion(output, target).item()
            pred = output.data.max(1, keepdim=True)[1]
            correct += pred.eq(target.data.view_as(pred)).long().cpu().sum().item()
            outputs.append(output.cpu().detach().numpy())

    test_loss /= len(test_loader.dataset)
    accuracy = 100. * correct / len(test_loader.dataset)
    print(f'\nTest set: Average loss: {test_loss:.4f}, Accuracy: {correct}/{len(test_loader.dataset)} ({accuracy:.0f}%)\n')
    return outputs, targets

実験する!

for epoch in range(10):
    train(epoch)
    test()

実験

いろんなモデルで比較実験してみる。

実験で使うデータ。

label train data test data
玉子焼き 103 45
出汁巻玉子 70 31

実験するモデル。

  • NN系
    • Alexnet
    • Resnet18
    • Squeezenet
    • Vgg11
    • Vgg16
  • Logistic Regression
  • Gradient Boosting Machine

Logistic Regression は scikit-learn のものを、GBM は LightGBM を使う。
epoch 数は 10 で回してみる。

実験した結果は以下のようになった。

アルゴリズム Average Loss Accuracy
Alexnet 0.2580 87%
Resnet18 0.8520 47%
Squeezenet 0.1777 95%
Vgg11 0.2620 93%
Vgg16 0.2407 95%
Logistic Regression 0.5482 76%
GBM 0.6318 73%

比較したところ、Squeezenet が一番良かった。というか、NN系は Resnet を除いて全体的に良い。
実際に判定結果をみても、微妙な判定が少ないので、2つをきっちりと分けることができるみたい。

スクリーンショット 2018-12-21 16.24.37.png

また、ベースラインとして用意した Logistic Regression も悪くない。人間の目で分類するのが難しい課題でも機械学習を使えばある程度分けれることがわかった。DNNすごい。。。

まとめ

玉子焼き分類モデルを作って、玉子焼きとだし巻き玉子を分類してみた。結果は驚きの accuracy 90% オーバー!!うれしいね。画像系の機械学習を初めてきちんとやったので良い経験になった。モデルの特性など理解できていないところが多々あるので、時間を作って勉強したい。一方で、精度は良かったものの、元データにノイズ(フライパンの画像や某レシピサービスのロゴなど)が混ざっているので、これを除去する仕組みを作るのもやってみたいと思った。玉子焼き関連でいろんなエンジンが作れそうなので、このあたりは継続的に続けていきたい。
今回作成したものは下記URLにおいてますので、興味があれば触れてみてください。実験結果もそのまま残してあるので、参考にしていただければ幸いです。

https://github.com/wararaki/tamagoyaki

また今回の記事作成に当たり、モデルのチューニング等で助けていただいた t-ohtsuki 氏に感謝!

以上です。
コメントやアドバイスをいただけると嬉しいので、どうぞよろしくお願いします。m(_ _)m

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away