catalinaの備忘録

ソフトウェアやハードウェアの備忘録。後で逆引きできるように。

MTGのカード画像を分類する学習器をつくる

どうしてイラストから分類?

まず、わざわざDeepLearningフレームワークを使ってイラスト分類する理由です。

最初は安直に「カードに名称書いてあるやん。これ文字認識(OCR)にかければいいでしょ」と思って文字認識API叩いてみました。 結果は惨憺たるもので、とても実用に耐えられるものではありませんでした。 工夫すればなんとかなる気はしますが、仮に英語だけに対応して精度をあげても、同じような苦労を日本語でもやることを考えるとコストかかりすぎです。

というわけで、DeepLearningでモデルを作って機械に分類する方法を作ってもらうことにしました。

MTGの学習用データをつくる

前回のエントリ http://catalina1344.hatenablog.jp/entry/2017/03/30/221959 で、MTGのカードイラストを手に入れることはできました。 これを水増しします。

水増しの方針は色々考えられますが、基本は元画像に対して何かの変化(ノイズ)を加えることです。 実例をもとにノイズの要因を考えてみると、次のようなものがあります。

  • 撮影環境の輝度変化
  • カード表面からの反射光
  • カメラ映像からカード部分を抽出する際のパースペクティブ変換での補間で発生するノイズ

これらを一度に全部やろうとすると収集がつかなくなるので、今回はパースペクティブ変換における「拡大縮小」に関するノイズを加えることにします。

1つの教師データをもとに特定のノイズのみを想定して学習データを水増しさせた場合、そのノイズ以外の外的要因に対するロバスト性がないモデルになることが予想されますが、今はそれは置いておきます。

とりあえずノイズを載せる

統計学的にノイズを載せるなら正規分布をもとにうんたらとかありますが、とりあえず今回は簡単な方法でいきます。 まず乱数が必要なので作ります。 C++の乱数って便利になってたんですね。

 // 乱数を生成する
    std::random_device  rnd;
    std::vector<std::uint_least32_t> v(10);
    std::generate(v.begin(), v.end(), std::ref(rnd) );
    std::mt19937    engine(std::seed_seq(v.begin(), v.end() ) );
    std::uniform_real_distribution<double>   dist(-noise_strong, noise_strong);

これでdistを使って乱数を得られます。

一般的にデジタル画像は縮小してから拡大するとぼやける(というかブロックノイズ)が乗るので、これを利用します。 以下のコードはopenCVを使って画像を縮小してから元のサイズに戻す処理です。縮小のサイズを変えることでノイズの載り方が変わるので、この縮小後のサイズに乱数で求めたオフセットをはかせます。(-noise_strong ~noise_strong) 一定量以上縮小しないとノイズとはならないので、baseには0.4とか0.3みたいな適当な値を入れておきます。

 double noise_factor = base + dist(engine);

    // 縮小して元のサイズに戻すだけ
    auto size = cv::Size((int)(src_img.cols*noise_factor), (int)(src_img.rows*noise_factor) );
    cv::resize(src_img, tmp, size);
    auto base_size = cv::Size(src_img.cols, src_img.rows);
    cv::resize(tmp, dst_img, base_size);

この方法で画像を水増しするとこうなりました。

f:id:Catalina1344:20170409205035p:plain

元画像のURLはこちら。

http://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=417574&type=card

以前にAndroidタブレットでカード部分を抽出してパースペクティブ変換して取り出したものは2とか5みたいなぼやけた感じだった気がします。 Androidタブレットはもう稼働させてないので、すぐに比較できないのが残念です。

あとWebAPIからとってきた画像はどうもサーバー側で生成しているもののようで、実際のカードの画像ではない気がしています。 たとえば上記カードであればフレーバーテキストが枠とかみ合っていなくて違和感があります。

これは今後の課題ということで。 (うまくいけばイラストだけで分類できる可能性もありますし。)

Chainerで画像データを読み込む(datasetsを使う)

これで学習用データが準備できたので、DeepLearningに突っ込んでいろいろ試していきます。

Chainerにはユーザーが準備した学習データ(データセット)を読み込むための方法が提供されています。

今回は画像の分類なのでchainer.datasets.LabeledImageDatasetを使います。名前のまんまですね。 これは第一引数にファイルパスとラベルのタプルのリスト、第二引数に画像を参照するルートディレクトリを与えて使います。 たとえば

labeldImage[
  ("image_path_A-0", 0),
  ("image_path_A-1", 0),
  ("image_path_B-0", 1),
  ("image_path_B-1", 1)
]

こんな感じのデータです。 画像ファイルを置くディレクトリ構成を工夫してあげればラベルと画像の関連付けが簡単になるので幸せになれます。 ちなみに外部の適当なファイルに上記のようなデータの羅列を書いておいて読み込ませる使い方もできるらしい。

Chainerで画像データセットを学習用とテスト用に分割する

よくあるDeepLearningの学習フェーズでは、データセットを学習用データとテスト用データに分割して使います。 先人がせっかくいろいろと理論を考えてくれてるので、それを利用します。 先ほどの手順で得られたデータセットをchainerに分割してもらいます。

train, test = chainer.datasets.split_dataset_random(dataset, div_num)

これでtrainに学習用データ、testにテスト用データがそれぞれ格納されます。 他にも交差検証用の分割とかいろいろありましたが、まずは何か動くものが欲しいのでランダム分割を使いました。

とりあえず学習を試す

CNNの前に、まずは単純なmnistのサンプルについてるNNの構成を使えばいいじゃないと思って試しました。

残念ながらMemoryErrorになります。

どうしてMemoryError?

学習の前に重み係数を初期化しているあたりでMemoryErrorでお亡くなりになります。 どれくらいのメモリ容量を確保しようとしているのか見てみると、43G要素くらいでした。無理ですよね。

どうしてLinearなNNでそんな膨大なメモリ容量が必要なのか?

よく考えると、LinearなNNに大きな画像を入れようとしているからこそ起きる問題でした。 まず画像サイズについて。幅223,高さ311で3chなので約208KByteです。(水増し時点でαチャンネル削除したRGB形式なので3ch)。 これを32bit浮動小数点型として扱ったとしてもたかだか4倍です。 (以降、Byte型とかfloat型とか考えるの面倒なので"要素数"で統一します)

次にNNの形についてです。 mnistサンプルにあるNNはこんな形をしています。

class MLP(chainer.Chain):

    def __init__(self, n_units, n_out):
        super(MLP, self).__init__(
            # the size of the inputs to each layer will be inferred
            l1=L.Linear(None, n_units),
            l2=L.Linear(None, n_units),
            l3=L.Linear(None, n_out),
        )

    def __call__(self, x):
        h1 = F.relu(self.l1(x))
        h2 = F.relu(self.l2(h1))
        return self.l3(h2)

こうやって使いました。 274は分類ラベルの数です。(今回使っているカラデシュというセットには274種類のカードがある)

unitnum = 223*311*3
model = L.Classifier(MLP(unitnum, 274))

問題の箇所はl1,l2で、全結合層で、入力層と同じ数のユニットをもちます。 この層の重み係数を保持するために必要なデータの要素数は、「1つのユニットにつき208K個」です。 つまり、この重み係数の領域を確保するために必要なメモリサイズは208Kの二乗(=43.288G)の容量が必要になってしまいます。 今時のマシンはメモリが潤沢とはいえ、これでは無理ですね。

畳み込みNNにする

メモリ容量という側面から畳み込みNNが必要とされた一つの要因が見えた気がします。 単純に全結合していてはお話にならないということですね。

というわけで、おとなしく畳み込みニューラルネットワークを使ってみることにします。 まずは動くかどうかの実験です。畳み込み+MaxPoolingが2層、1層の識別層です。

class MLP(chainer.Chain):

    def __init__(self, n_units, n_out):
        super(MLP, self).__init__(
            l3=L.Linear(None, n_out),  # n_units -> n_out
            conv1 = F.Convolution2D(3, 3, 7, stride=3 ),
            conv2 = F.Convolution2D(3, 1, 7, stride=3 ),
        )

    def __call__(self, x):
        h = F.relu(self.conv1(x))
        h = F.max_pooling_2d(h, ksize=2, stride=2)

        h = F.relu(self.conv2(h))
        h = F.max_pooling_2d(h, ksize=3, stride=3)

        y  = self.l3(h)
        return y

initの引数n_unitsを使わなくなりましたが、これで呼び出し元は変更なしで大丈夫です。

できた!

しばらく動作させて学習の進捗を見るとこんなグラフになりました。

f:id:Catalina1344:20170409205737p:plain f:id:Catalina1344:20170409205824p:plain

1epochあたり10秒くらいで、だいたい3時間くらい動かしました。

学習は遅いですが順調に進んでいるような気がします。 このあたりの高速化はDropOutを入れるとかいろいろあるので、追々試してみます。

感想と今後の展望

画像からMTGのカードを分類するための土台(を作るための準備)ができました。 学習には時間がかかるので色々とバッググラウンドで走らせて実験しつつHoloLensアプリ側の実装を進めたいと思います。

HoloLensからどうやってこの学習モデルを使うかは検討中です。 現状想定しているのは

  • OpenCV3以降のdnnモジュールを使ってHoloLens上で動作させる
  • PC上で動作させ、webAPIを作ってHTTP経由で叩く

この2つあたりです。

HoloLensの性能を見る限り、なんとなく後者が妥当な気がしています。

そもそもUWP用のOpenCVビルドは古いバージョンなのでdnn入っていませんし。

それでは今回はこれにて。