本記事はChainer Advent Calendar 2016の20日目のエントリです。
ペンパイナッポー(以下PP)とアッポーペン(以下AP)の画像識別方式の確立は急務です。下記は"PPAP"のここ三ヶ月のGoogle Trend推移ですが、すごい速度で減衰しています。正直もう遅いのかもしれませんが、であったとしても次のビッグウェーブに一目散に乗れるように反射神経を鍛えておく必要があります。
PPとAPをひと目で見分ける認識器の必要性についてご理解いただいたところで、昨今のディープラーニングによる物体認識の進展に目を向けますと、Deep Learningによる一般物体検出アルゴリズムの紹介という記事にまとまっているように、優れた手法が次々と提案されています。最後のコメントにあるように、最大の課題は学習用のデータセットをどう準備するかというところです。
残念なことに、PPAPデータセットの整備は非常に遅れていると言わざるを得ません。そこで、今回は最近の手法であるYOLO ver2を使い、データセットの作成から認識器の学習まで一連の処理をやってみます。最初に断っておきますがchainerがなかなか出てきませんのでご容赦ください。
YOLO と YOLO ver2
YOLO(You Only Look Once)はワンショット方式の物体認識CNN(Convolutional Neural Network)です。物体のクラスと画像内の座標(その物体を囲むバウンディングボックス)を同時に推定する、という意味のワンショットです。走査型の方式ではCNNを走査回数だけ演算することになり、演算時間がかかりますが、ワンショットだとCNN演算が一回で済みます。
YOLOは、画像をブロックに切り分けて、ブロックごとに複数組(ver2では5組)の予測ベクターを出力します。予測ベクターは、そのブロック内に中心があるオブジェクトのクラス確率とバウンディングボックス座標、バウンディングボックスの信頼度、から構成されます。ブロックごとの出力ベクターのチャネル数は全部で(class数+5)×(ブロックごとのBoundingBox個数)、です。5はBoundingBoxの座標とサイズ(x,y,w,h)と信頼度です。
YOLOはdarknetというCベースのフレームワーク上に実装されていますが、個人的にはいろいろと実験する上ではpythonベースになっていたほうが扱いやすいです。旧エントリでYOLOの学習済のネットをchainerで利用することについて書きましたが、今回は学習までやってみます。そうこうしているうちにver2がリリースされ、より性能が上がったようなので、どうせならver2でやります。論文がまだ公開されていないのでソースコードから読み取るしかないのですが、変化点は下記のようです。
- 後段を全結合からFCN(Fully Convolutional Network)に変更。
- 出力層(region_layer)の構成や出力の解釈が変更。
- 1ブロックあたりのバウンディングボックスの数を2から5に増量。
- BatchNormalizationを全面で採用。
- 窓幅と高さにbiasを導入。
とくに最後のbiasはコード上にマジックナンバーが書かれているのでさっぱりわかりません。早く論文を読みたいところです。
データセットの作成
YOLOの学習には、物体のクラスとバウンディングボックスをラベル付けする必要があります。こういうラベリングを手動で数千枚とかやるのは現実的ではない気がするので、自動生成することを考えます。
まずPPとAPを作成します。絵心がないのでフリー素材をお借りしてきます。果物の画像はこちら。これに、こちらからお借りしたボールペンをちょん切ってAh!と挿します。
これでPP画像とAP画像が作成できました。透過PNGにしておきます。
次に背景はなんとなく空の画像にしました。こちらからお借りしました。PPとAPをランダムに回転・スケーリングして背景画像に貼り付けます。
貼り付け位置がバウンディングボックスのground truthになりますので、darknetのテストデータの作法に従って座標を記述していきます。作法は下記のようなものです。
- ppap_train.txt , ppap_val.txt に画像のパス一覧を書きます。
- imagesフォルダに[image_id].pngというファイル名の画像を入れます。
- labelsフォルダに画像と拡張子違いの[image_id].txtを作成します。テキストファイルには、クラスとバウンディングボックスを画像に含まれるオブジェクトの個数だけ書いておきます。
画像が重なった時のことを考えて、なるべく複数個を画像内に置くようにしていたのですが、いろいろ試した結果としては1画像あたりのオブジェクトは1個にしたほうが学習はうまくいきました。学習用の画像の数はあまり試行錯誤していません。ひとまず1000枚としました。
事前学習
いきなりバウンディングボックスまで含めた学習を行うのではなく、いったん認識だけの問題を解く事前学習をして、係数をコピーしてきて初期値として使う、という転移学習が定石だそうです。このあたりの手順もあまり論文に書かれていないのですが、ひとまずPPかAPを回転・スケーリングした画像を用意して、前半の構成が最終的なネットワークと同じで、後半が全結合なClassificationの構成にして学習しました。赤色のところが本番ネットとの違いです。
事前学習は下記のように行います。
darknet classifier train cfg/ppap-pre.dataset cfg/tiny-yolo-pretrain.cfg
終了後、事前学習後の重みを切り出します。
darknet partial configfile from.weight to.weight num
最後の数字はネットワークのレイヤ番号ですが、darknetは学習実行時などにレイヤ番号と画像サイズを下記のように表示しますので、これを参考に切ります。この場合はnumから先を切り落とした重み係数がtarget.weightに入ります。
layer filters size input output
0 conv 16 3 x 3 / 1 416 x 416 x 3 -> 416 x 416 x 16
1 max 2 x 2 / 2 416 x 416 x 16 -> 208 x 208 x 16
2 conv 32 3 x 3 / 1 208 x 208 x 16 -> 208 x 208 x 32
...
12 conv 512 3 x 3 / 1 13 x 13 x 256 -> 13 x 13 x 512
13 conv 512 3 x 3 / 1 13 x 13 x 512 -> 13 x 13 x 512
14 conv 35 1 x 1 / 1 13 x 13 x 512 -> 13 x 13 x 35
15 detection
今回の場合は13を指定してみます。
本番学習
darknet detector train cfg/ppap.dataset cfg/tiny-yolo-ppap.cfg pretrain.weights
のようにして学習します。先程事前学習してから切り出した係数を初期値に使っています。target.weightsには12レイヤまでの重みしか入っていないのですが、それ以降の係数は読み出せないので、初期値が使われます。
少しでも演算量を減らそうと、ネットワークは少しだけ改変しました。あまり意味ないかもしれません。最終段付近のチャネル数を削減しています。赤色の部分がオリジナルのtiny-yolo-v2から変更したところです。
最終段のFCNの出力画像サイズ13x13がそのままブロックサイズを示していて、チャネル方向がブロックごとの予測ベクターに相当します。チャネル数は、さきほども述べたとおり(class数+5)×5ですので、今回は(2+5)×5=35になります。
バウンディングボックスの中心座標x,yはブロック幅で正規化されています。また、サイズw,hは画像サイズで正規化されています。全部で13x13x5のバウンディングボックス候補があるので、クラス確率とバウンディングボックスの信頼度をつかってIOU(Intersection Over Union)を計算し、信頼できるバウンディングボックスを選別・生成します。ここはちょっと説明しはじめると長いので省略します。
結果
ここで訓練後の重みとネットワークをchainerに移植しましたが、長くなるので先に結果を示します。
静止画だとこんなかんじです。APを赤、PPを黄色のバウンディングボックスで囲んであります。
バウンディングボックスの精度はあまりよくないですね。クラス認識は、心なしかAPのほうがよい印象です。オブジェクトが重なった場合も苦手ですがこれはまあ想定どおり。
35個のベクターを最終的に表示するバウンディングボックスに絞っていく過程で、認識とIOUの2つの閾値がありますが、このあたりは調節次第で表示結果が結構変わるので注意です。
やはり動画で性能を見たいところです。飛ばしてみましょう。なかなかファンキーな動画になってしめしめという感じです。解像度荒いのはGIFアニメのサイズ上限とのことでご勘弁ください。
回転とスケールにはそこそこ強い様子が見て取れます。バウンディングボックスは多少ばたばたしますが、全般に中心位置はそこそこ信頼できそうです。そしてなぜかやはりPPのほうが苦手な傾向です。ときどき間違って赤い枠がついてしまってますね。
テンプレートマッチングでおなじことをやろうとするとバリエーションを舐めるのが大変と思いますが、それでもCNNの演算とどちらが速いかは正直微妙なところかもしれません。
ではもう少しいじめてみます。重なりには弱いはずなので交差させてみます。
どうでしょうか。もう少し頑張って欲しいところでしょうかね。一度1枚に1画像という条件で学習をしたあとに、徐々にオブジェクト数を増やした条件で再学習していくようなことをすれば改善できるでしょうか。
さらにいじめてみます。PとAとPPとAPの区別は付いているんでしょうか。また、ペンの刺さる位置などがずれたら?
正しい画像に対してはばっちりですね。正しくない画像には、バウンディングボックスの位置に迷いが見られます。クラス確率なども正しい画像と数%の差が出ていたので、しきい値を切ればわけられなくもない範囲です。もちろん、そういう訓練をすればもっと性能は上がるはずです。PやA、あるいは「PPでもAPでもない」というクラスをちゃんと作ってそちらにラベルが付いているようなテストデータを用意することになると思います。
結局アプリケーションごとに、実現したいファンクションをうまく表現するようなデータセットをどう準備するかが重要という当たり前の結論になりますね。でも人類はそういうことを考えるところに注力して、せっせとマーキングするような作業からは開放されたいところです。
chainer化
Chainer Advent Calendarなのになかなかchainerが出てこずスミマセン。ここで、darknet/tiny-yolo_v2のchainer化について説明します。
darknetでは、BatchNormalization入りのconvolutionというレイヤーが定義されていて、オリジナルコードを見ると、
- bias抜きの畳込み
- BatchNormalization
- bias
- leakyReLU
という構成になっていたので、その通りに並べたChainを作ります。再利用しやすいように関数化しておきます。
def darknetConv2D(in_channel, out_channel, bn=True):
return Chain(
c = L.Convolution2D(in_channel,out_channel, ksize=3, pad=1,nobias=True),
n = L.BatchNormalization(out_channel,use_beta=False,eps=0.000001),
b = L.Bias(shape=[out_channel,]),
これをメインのChainにどんどん並べます。最近のchainerはチャネル数にNoneと書くと入力データからチャネル数を類推してくれますので、試行錯誤の際は便利です。
class YOLOtiny(Chain):
def __init__(self):
super(YOLOtiny, self).__init__(
c1 = darknetConv2D(3,16),
c2 = darknetConv2D(None,32),
c3 = darknetConv2D(None,64),
...
)
convolution -> leakyReLU -> max_poolingの繰り返しなので、その一連の流れも関数化しておきます。
def CRP(c, h, stride=2):
# "C"onvolution -> leaky"R"elu -> max"P"ooling
h = c.b( c.n( c.c(h),test=True))
h = F.leaky_relu(h,slope=0.1)
h = F.max_pooling_2d(h,ksize=2,stride=stride,pad=0)
return h
メインのChainの__call__()でCRP()を積んでいきます。
class YOLOtiny(Chain):
def __call__(self,x):
h = CRP(self.c1, x)
h = CRP(self.c2, h)
...
return h
それから、細かいことですが、2x2のMax Poolingで奇数サイズのときの画像サイズがdarknetとchainerで一致しなかったので、パディングしておいてサイズが合うようにクロップしました。
Valiableに対してクロップをするにはchainer1.10あたりで追加されたFunction.get_item()を使います。記述が独特ですがとても便利です。numpyで:と書くところにslice(None)を、a:bと書くところにslice(a+1,b+1)を書く、と覚えればよいでしょう。次元は、[batch, channel, y, x ]と並んでいるので、batchのぶんのslice(None)を足すことを忘れないようにしましょう。下記のように記述すると、numpy的に言うとh=h[:,:,0:13,0:13]のようなスライス処理がなされます。
h = F.get_item(h,(slice(None),slice(None),slice(1,14),slice(1,14)))
学習係数は、旧エントリではTensorFlow版から持ってきましたが、今回はdarknetの学習後の係数を直接吸い出してみます。レイヤ順にバイナリでダンプされているので、順序とサイズを間違えずに読み出すだけです。あたまに4x32bitのヘッダ(バージョン情報など)が書かれているのでスキップしますが、面倒なのでファイル全体をfloat32のndarryにぶっこんで、全てスライスで処理します。
dat=np.fromfile(file,dtype=np.float32)[4:] # skip header(4xint)
self.c1.c.W.data = dat[*:*].reshape(out_ch,in_ch,1,1)
self.c1.b.b.data = dat[*:*]
のようにしました。datを前から積んでいくことになりますが、ndarrayにはpop()という概念がないので、やむなくoffsetを管理しながら切り出していく処理を記述しました。このへんはもっとスッキリ書けるはずですね。
また、旧エントリではIOUの処理をやっていなかったので、同じ対象に複数のバウンディングボックスが生成されてしまっていました。このあたりも今回は対応しました。
一連のコードはgithubに上げました。少々とっちらかっていますがご容赦ください。
おわりに
YOLOのおかげでPPとAPを認識することができました。これで安心して年末を迎えることができます。
今回の例だと認識対象がイラストなので、クラシックな画像認識手法で十分性能は出せるとは思います。しかし、ポイントなのは、被写体の特徴を何も考えずにそこそこの性能が出せるというところで、ここがディープラーニングの工学的価値の一つと思います。でも結局大量のデータを人力で作らなきゃいけないんでしょ?と思われがちですが、ある程度は自動化できます。
イラストではなく実画像でやりたい場合も、実物を単一色背景のもと撮影してクロマキーで背景を抜いたり、3Dスキャナなどで取り込んだ3Dモデルをつかってblenderなどで合成したり、手間やクオリティに差はあれど同じような手順で、単体だけ用意してあとは自動化することは考えられると思います。実物でのファインチューニングをなくせるかというと微妙な気もしますが、かなりの手間は減らせると思います。
Chainer Advent Calendarということなので、学習処理までchainer化するのが目標でしたが、存外にver2の変更点などを読み取るのに時間がとられ間に合いませんでした。結果としてchainer感の薄いエントリとなってしまいました。
しかし、darknetのネットワーク構成に合わせていく際に、chainerの記述力の高さがあらためて確認できました。そういう目的で使うことは少ないかもしれませんが、ネットワーク構造を標準化していくような動きもぼちぼち散見されますので、対応力の高いフレームワークには十分価値があるのではないかと思います。実のところ最近はKerasとchainerを行ったり来たりする日々ですが、手早くなにかを実験するときはやはりchainerですね!
なお、こちらの方が学習までフルセットなYOLOv2のchainer実装をされたようです!