画像内から検出した顔領域をImageMagickで固定サイズに切り出す

TensorFlowでのDeep Learningによるアイドルの顔識別 のためのデータ作成 - すぎゃーんメモ の記事で書いているけれど、学習用データとして使うために収集した画像から「顔の領域」だけを切り出して「固定サイズ」(112x112など)に切り出す必要があって。

以前にも書いたけど、自撮り画像はけっこう顔が傾いた状態で写っているものが多いので、それも検出できるようにしたりしている。

で、せっかく傾きの角度も含めて検出できるならそのぶんを補正して回転加工して切り出すようにしていて。

…というのを RMagick のRVGを使ってcanvasっぽい感じでどやこや書いていたのだけど、どうも使っているImageMagickのバージョンなどの影響もあるのかもしれないけど

  • #destroy!とか明示的に呼んでるはずなのにメモリ使用量がどんどん増え続けてしまう
  • 特定の画像を読み込ませて加工しようとすると必ずSegmention faultになってclockworkプロセスごと死んでしまう

といった問題が起きていて、ちょっと原因追うのも面倒 というかわざわざこれくらいの加工にRMagickで頑張りすぎることもないんじゃないか、と思って捨てることにした。

要はImageMagickCLIを使いこなせればそれくらいのことが出来るはず、ということで調べたら

  • 指定倍率で拡大縮小させて
  • 中心を指定して回転させて
  • 任意の場所に並行移動する

というのにピッタリな、「Scale-Rotate-Translate (SRT) Distortion」というのがあることを知った。

Angle -> centered rotate
Scale Angle -> centered scale and rotate
X,Y Angle -> rotate about given coordinate
X,Y Scale Angle -> scale and rotate about coordinate
X,Y ScaleX,ScaleY Angle -> ditto
X,Y Scale Angle NewX,NewY -> scale, rotate and translate coord
X,Y ScaleX,ScaleY Angle NewX,NewY -> ditto

という具合に、引数で「回転角」「倍率」「回転中心座標」「中心の移動先座標」をそれぞれ指定することで一発で変換ができるらしい。

ImageMagickCLI wrapper的な MiniMagick を使ってそれぞれ実験してみる。

顔の検出は Google Cloud Vision API でのFACE_DETECTIONのレスポンスを使うとする。


1. まずは回転角度だけを指定する場合

MiniMagick.logger.level = Logger::DEBUG
detected['responses'].first['faceAnnotations'].each do |annotation|
  srt = [
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.39s] mogrify -distort SRT -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90415-1tuqqvg.jpg
DEBUG -- : [0.36s] mogrify -distort SRT -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90415-kfub15.jpg


2. ちょっと背景を…

回転によって空く領域はvirtual-pixelで指定できるらしい。デフォルトは白のようなので黒にする。

detected['responses'].first['faceAnnotations'].each do |annotation|
  srt = [
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90304-1e0od53.jpg
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90304-1yp6bof.jpg


それぞれの顔が真っ直になるよう微妙に回転角が調整されているのが確認できる


3. 回転中心座標を指定

何も指定していないと画像中央を中心として回転していたけど、顔の中心座標を指定すると

detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.22s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91029-ctowe2.jpg
DEBUG -- : [0.20s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91029-irskuw.jpg


顔の中心位置は動かずにそこを中心に回転した形、になる


4. スケールを指定する

顔のサイズと、切り出したいサイズの比 から倍率を求めて指定

size = 96
detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    size.to_f / [xs.max - xs.min, ys.max - ys.min].max,
    - annotation['rollAngle']
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.31s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90917-l2v5ta.jpg
DEBUG -- : [0.29s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-90917-1gf4b9q.jpg


倍率の差はあまり分からないけど、それぞれ回転したものが縮小されているのは間違いない


5. 移動先座標を指定

最終的に左上から指定サイズでcropするために、変換後のものを左上に寄せる。顔の中心を既に指定しているので、これが指定サイズ領域の中心になるようになれば良い。

detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    size / [xs.max - xs.min, ys.max - ys.min].max,
    - annotation['rollAngle'],
    "#{size * 0.5},#{size * 0.5}"
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
  end
end
DEBUG -- : [0.28s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 48.0,48.0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91316-icip1v.jpg
DEBUG -- : [0.30s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 48.0,48.0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91316-13jij7r.jpg


6. 指定サイズでcrop

既に左上に寄せてあるので、offsetなしで切り取れば良いだけ、となる。

detected['responses'].first['faceAnnotations'].each do |annotation|
  xs = annotation['fdBoundingPoly']['vertices'].map { |v| v['x'] }
  ys = annotation['fdBoundingPoly']['vertices'].map { |v| v['y'] }
  srt = [
    "#{(xs.min + xs.max) * 0.5},#{(ys.min + ys.max) * 0.5}",
    size / [xs.max - xs.min, ys.max - ys.min].max,
    - annotation['rollAngle'],
    "#{size * 0.5},#{size * 0.5}"
  ].join(' ')
  MiniMagick::Image.open('./CgVXVF5UEAAFrM_.jpg').mogrify do |convert|
    convert.background('black')
    convert.virtual_pixel('background')
    convert.distort(:SRT, srt)
    convert.crop("#{size}x#{size}+0+0")
  end
end
DEBUG -- : [0.28s] mogrify -background black -virtual-pixel background -distort SRT 457.0,402.0 0.43636363636363634 -47.189156 48.0,48.0 -crop 96x96+0+0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91967-1wh75bk.jpg
DEBUG -- : [0.27s] mogrify -background black -virtual-pixel background -distort SRT 210.5,290.5 0.4085106382978723 -38.082096 48.0,48.0 -crop 96x96+0+0 /var/folders/j7/n0xq_2fs1bz3llgcm9snw7cc0000gn/T/mini_magick20160429-91967-un47j8.jpg


できあがり。


…ということで、

$ convert <元画像> -background black -virtual-pixel background -distort SRT '<顔の中心座標> <拡大縮小倍率> <回転角度> <中心移動先座標>' -crop <切り出しサイズ>+0+0 <出力画像>

のような形でconvertmogrifyを使えば一発で検出された顔の領域を指定サイズで得ることができることが分かった。


のでRMagickの使用を止めてこの方法で顔画像領域を取得するよう変更した。今のところは問題なく動いているっぽい。

結論

音咲セリナちゃんも宇佐美幸乃ちゃんも可愛い。

署名などの検証に定数時間の比較関数を使う

全裸bot for LINE - すぎゃーんメモ の記事にフィードバックをいただきまして。

全然知らなかったのだけど、Timing attackという攻撃手法が存在するそうで。

たとえば文字列の比較で、先頭から1文字ずつ比較していってその中身が異なっていたらreturnする、という処理をしている場合。

func cmpstring(s1, s2 string) int {
	l := len(s1)
	if len(s2) < l {
		l = len(s2)
	}
	for i := 0; i < l; i++ {
		c1, c2 := s1[i], s2[i]
		if c1 < c2 {
			return -1
		}
		if c1 > c2 {
			return +1
		}
	}
	if len(s1) < len(s2) {
		return -1
	}
	if len(s1) > len(s2) {
		return +1
	}
	return 0
}

パスワードや署名の検証など「正しい文字列が与えられたらtrue、そうでない場合はfalse」という場面でこういう方法で文字列比較を行っていると、与える文字列によって処理の演算回数が変わるので、その実行時間を計測しながら何度も試行することで正しい文字列が推測できてしまう、ということらしい。

今回のようなHTTP経由での数バイトの文字列比較ではネットワーク遅延などの誤差の方が遥かに大きく この手法で破れるとはあまり思えないけれど、こういったものを防ぐために「入力の値にかかわらず定数時間で処理を行う比較関数」がちゃんと用意されているので、それを使うのがベターでしょう。

func (bot *Bot) checkSignature(signature string, body []byte) bool {
	decoded, err := base64.StdEncoding.DecodeString(signature)
	if err != nil {
		return false
	}
	hash := hmac.New(sha256.New, []byte(bot.ChannelSecret))
	hash.Write(body)
	return hmac.Equal(decoded, hash.Sum(nil))
}

このhmac.Equalが中でsubtle.ConstantTimeCompareを使うようになっていて、その実装が

// ConstantTimeCompare returns 1 iff the two slices, x
// and y, have equal contents. The time taken is a function of the length of
// the slices and is independent of the contents.
func ConstantTimeCompare(x, y []byte) int {
	if len(x) != len(y) {
		return 0
	}

	var v byte

	for i := 0; i < len(x); i++ {
		v |= x[i] ^ y[i]
	}

	return ConstantTimeByteEq(v, 0)
}

// ConstantTimeByteEq returns 1 if x == y and 0 otherwise.
func ConstantTimeByteEq(x, y uint8) int {
	z := ^(x ^ y)
	z &= z >> 4
	z &= z >> 2
	z &= z >> 1

	return int(z)
}

のようになっていて、必ずすべてのbyteについて比較してその結果が等しくなっているかどうか、という計算になっているようだ。
こういった比較関数を使うことでTiming attackを防ぐことができる。

全裸bot for LINE

BOT API Trial Account Overview - LINE Business Center が公開されて、LINEのbotが作れるようになった、ということで 遅ればせながら自分も 過去に作ったTwitter bot を移植してみた。

テキストを受け取ってちょっと改変してオウム返しする、というのは練習としては良い題材ですね。kagome を使えばPure Goで形態素解析できるし、ということでGoで書いてみた。

source code
https://github.com/sugyan/line-zenra-bot

Callback URLにSSLが必須、送信側はIP Whilelistに登録している必要がある、ということでちょっと制限があるけれど、これくらいのお遊び程度のものならherokuで受けて fixie addonを使えばIP固定させて使うことができる。もっと本格的に使おうと思ったらちゃんとした構成を考える必要があるでしょうけども。


実装の参考に、と幾つかの記事や公開コードを読んでみたのですが Signature validationを無視しているものが多くて気になりました。

お遊び程度のデモなら気にすることもないのかもしれないけど、Callback URLに対して誰でも出鱈目なJSONをPOSTできる状態で そのまま信頼してそのデータを使うなんてとんでもない。
そんなに難しいことでもないし、ちゃんとSignature validationはしておこう。

func (bot *Bot) handle(w http.ResponseWriter, req *http.Request) {
	defer req.Body.Close()
	body, err := ioutil.ReadAll(req.Body)
	if err != nil {
		...
	}
	if !bot.checkSignature(req.Header.Get("X-Line-Channelsignature"), body) {
		return nil, errors.New("invalid signature")
	}
	...
}

func (bot *Bot) checkSignature(signature string, body []byte) bool {
	hash := hmac.New(sha256.New, []byte(bot.ChannelSecret))
	hash.Write(body)
	return signature == base64.StdEncoding.EncodeToString(hash.Sum(nil))
}

※追記しました

TensorFlowでのDeep Learningによるアイドルの顔識別 のためのデータ作成

続・TensorFlowでのDeep Learningによるアイドルの顔識別 - すぎゃーんメモ の続き、というかなんというか。
前回までは「ももいろクローバーZのメンバー5人の顔を識別する」というお題でやっていたけど、対象をより広範囲に拡大してみる。

様々なアイドル、応援アプリによる自撮り投稿

あまり知られていないかもしれないけど、世の中にはものすごい数のアイドルが存在しており、毎日どこかで誰かがステージに立ち 歌って踊って頑張っている。まだまだ知名度は低くても、年間何百という頻度でライブを中心に活動している、所謂「ライブアイドル」。俗に「地下アイドル」と言ったりする。

そういったアイドルさんたち 活動方針も様々だけど、大抵の子たちはブログやTwitterを中心としてWebメディアも活用して積極的に情報や近況を発信していたりする。

そんな中、近年登場した「自撮り投稿サービス」。
概要としては、各アイドルさんが自撮りなどの写真を投稿し、ファンのユーザーが「応援」としてポイントを送ることでその数で順位がつく、というもの。ある期間で上位にランクインすると街頭広告や雑誌インタビューに掲載されるといったイベントも開催されるため、アイドルさんは可愛い自撮りをたくさん投稿するしファンはポイント貯めたり買ったりして頑張って応援する。有名なところで以下の2つのサービスが登場している。

それぞれのサービスに登録されているアイドルさんを 雑にスクレイピング して取得したところ、軽く1000件を超えた。それだけたくさんアイドルがいるわけで、最終目的としては入力した顔画像をそれら1000人以上のアイドルに分類すること、となる。

…のだけど、つまりそれを学習させるためにそれらのアイドルさんたちの「顔画像」と「(それが誰であるかを示す)ラベル」のセットが大量に必要となるわけで。

自撮り投稿から顔識別用データセットを作るには

多くのアイドルさんが利用しているこの自撮り投稿サービスから、投稿者と画像をセットで引っ張ってくれば「自撮り」なんだから自動でラベル付け済みの顔画像データセットを作れるんじゃないか、と考えたのだけど、まぁそんなに簡単な話でもなく。
まず「主に自撮りが投稿される」のであって必ず投稿した本人だけの顔が写っているとは限らない。別にそういうルールがあるわけではないので、共演した別のアイドルさんとの2ショットだったり 同じグループのメンバーと一緒の仲良しショットがあったりすることも珍しくない。そういった写真は顔検出は自動で出来ても、複数検出されたもののうちどの顔が投稿者本人なのかは分からない。
あと、そもそも上記の自撮り投稿サービスたちはデータ取得用のAPIが公開されていない。CHEERZの方はWeb版があるのでスクレイピングすれば可能そうではあるけど、DMM.yellはアプリ専用なのでそれなりにハックが必要になる。

Twitterから収集、管理するWebアプリ

…やはりラベル付けは人力でやるしかない、と覚悟を決めて、とにかくまずは収集してみることにした。
アプリには取得APIが無いが、投稿時にTwitterと連動する機能がついているようで、大抵のアイドルさんは自分のTwitterアカウントと紐付けてそちらにも画像付きTweetで流している。ので、連動投稿に付与されている「#CHEERZ」「#dmmyell」といったハッシュタグTwitter検索をかければ大抵の画像は取得できる。それらの投稿から各アイドルさんの個人アカウントも把握できるので、自撮りアプリ連携ではない普段の画像Tweetも収集対象になるように登録しておく。

そうやって定期ジョブでTwitterからひたすら画像付きTweetを取得し、 自作の顔検出器 にかけて顔部分を抽出して保存していく。このあたりは 前回までももクロちゃんデータセットを作成したときと同様で、管理用のWebアプリを自作している。

以前はこれをHerokuで運用していたのだけど、今回はあっという間に顔画像が10000件を超えて 無料枠で利用できる範囲をオーバーしてしまったので、以前から持っていたのにほぼ使っていなかったさくらVPSのサーバに移行した。

ひたすら目視でラベル付け

こんな感じで、様々なアイドルさんの顔画像が取得できる。(かわいい)

が、これらをそれぞれ「誰であるか」のラベル付けをする必要があり。僕だって一応ドルヲタとして年間200〜300の現場に足を運び何百組というアイドルを見てきたし そのへんの人よりはアイドルに詳しいつもりだから 100人とか200人くらいなら顔を識別できる自信はあるけれど、1000人くらいの顔がごちゃ混ぜになっていると流石に無理がある。

とりあえずは知ってる顔はどんどん片付けていって、あとは知らない子でも普通に自撮りっぽい画像で1人で写っていればそれはまぁ本人に違いないだろう、と判断できるのでラベルを付けていく。本当に見たことのない知らない子たちの集合写真とかはまったく判別できないので後回し。

というのを考えて、顔が1つだけ検出されている画像を優先でランダムで選択しつつラベル付けしていったり。いちおう入力しやすいよう補完つけたりインタフェースを工夫したりはしている。

気の遠くなるような作業ではあるけれど、まぁ好きなので意外と飽きない。数千件は自力でラベル付けできた。

とりあえず学習させてみる

と、こんな作業を続けているうちに 何人かはある程度の枚数の顔画像が揃うので、集まっている限りのデータを使って 前回のもの と同じ、96x96 size, 3 channelの画像を入力とする4層の畳み込みと3層の全結合によるネットワークを使って学習させてみる。
1分類につき10枚程度だと心許ないが、30枚くらい学習させたらある程度の特徴を掴んでそれっぽいものは判別してくれるようになるのでは…?という目論見。

学習用と評価用とかデータセットを分ける余裕はないので、とりあえずはラベル付けしたものは全部学習用に使う。前回と同じTFRecordのファイルを入力に使うため、 ダウンロードできるように顔画像JPEGバイナリとラベル番号のセットを含むTFRecordバイナリを吐くエンドポイントも 実装 した。

ラベル付けして30枚以上集まっている顔画像セットに、それ以外のものとして 数枚しか集まっていないものやそもそも顔画像じゃないものも「分類対象外」のラベルとして3割ほど混ぜる。数千枚を50クラスくらいに分類するもの、となる。これは前回と同じモデル(むしろ畳み込み層のパラメータ数は減らしてる)で 1000〜2000 stepくらいでも十分にcross entropyが減少して 教師データに対してはほぼミスなく ちゃんと分類してくれるようになる。

推論結果を確認、修正

で、ある程度学習が済んだモデルが出来上がったら、未分類の顔画像に対してその分類器にかけて推論してもらう。

学習済みの顔に近いものは高いスコアで識別されるはずなので、これを確認することで、未分類の顔画像に対して「誰であるか」を考えるのではなく「○○と推測されているが、合っているか」だけを考えることになるので、人間の負担が軽減し作業が捗る。

そして当然ながらまだまだ学習データ数が少ないので、この推論はすごくよく間違う(上記の画像のは全部あってます)。
傾向としては、一人金髪の子を学習すると 髪の明るい人物は大抵その子と認識するようになる とか、画質がボヤけ気味のが多い子を学習しているとボヤけてる画像はだいたいその子と認識する、とか。髪の短い子や 頬にほくろのある子 とか 一応なんらかの特徴を掴んでいるようで 似たようなものはそれっぽく分類することは多いけど、やっぱりヒトが見たら全然ちがうだろって思うような間違いをしていたりはする。

それはそれで確認しながら正しくラベル付けしてやることでまた学習に使えるデータが増えるので、ある程度の答え合わせが済んだら増えたデータセットを元に再び学習してやる。そうすることで以前間違えたようなものはもう同じ間違いはしないし、さらに精度の高い推論をするよう進化する。

似たような入力に対し何度も学習プロセスを繰り返すのに 毎回まっさらな状態から始めるのは非効率だと思ったので、学習し直すときは前回の学習済みモデルのパラメータをロードしてそこから始めることにした。分類対象数が増えた場合でも、それは最後の全結合の 隠れ層→出力層 の部分だけしか構造は変わらないので、それ以外のパラメータはそのまま使っても 初期化状態から始めるよりは早い、はず。

def restore_or_initialize(sess):
    if os.path.exists(FLAGS.checkpoint_path):
        for v in tf.all_variables():
            print 'restore variables "%s"' % v.name
            try:
                restorer = tf.train.Saver([v])
                restorer.restore(sess, FLAGS.checkpoint_path)
            except Exception:
                print 'could not restore, initialize!'
                sess.run(tf.initialize_variables([v]))
    else:
        print 'initialize all variables'
        sess.run(tf.initialize_all_variables())

https://github.com/sugyan/tf-classifier/blob/master/models/v2/train.py#L30-L46

結論

ということで、学習用のデータを用意するのは大変だけど、

  • ある程度集まったらとりあえず学習させる
  • 学習させたモデルを使って推論させてみる
  • 推論結果を検証することで学習データを増やし、再び学習させることで精度が上がる

というサイクルを続けることで、なんだかんだで自力で13000点ほどの分類済みのアイドル顔データを作ることができている。



現時点での最新の学習済み分類器での結果はたとえば

というかんじで、100クラス以上の分類数である中で Luce Twinkle Wink☆ のメンバー5人中4人くらいは一応判別できるようになっている。

課題

30枚くらい集まれば学習対象になって 推論結果にも出るからさらにデータを増やすのに使えるけれど、そもそもその30枚くらいまで集めるのが大変なわけで。
似たような特徴を持つ顔をクラスタリングしてまとめてラベル付けできたら良いのかな…?と思って、学習済みモデルの隠れ層の出力パターンが似たものとかで分類できないかと調べてみたのだけど ちょっと有意な傾向は把めなそうだった…。

あとは推論結果に対する検証なんかはアイドルに詳しいドルヲタの方々に手伝ってもらう形で集合知を利用して実現できれば良いのだけど、なんとも良いインタフェースが思い浮かばない。

余談

分類作業しているうちに、知らなかった子の顔もけっこう覚えるようになる。人間のラーニング能力もすごいな、って。

今後の展望

だんだん「アイドルの顔画像」が集まってきたので、今度はこれらを利用して"生成する"というのにもチャレンジしてみたいと思っている。

他にもなにか良いアイディアがあれば。現時点で作ったデータセットも何かに利用したい、という方がおりましたら提供の相談させていただきますのでお気軽に連絡ください。

続・TensorFlowでのDeep Learningによるアイドルの顔識別

TensorFlowによるディープラーニングで、アイドルの顔を識別する - すぎゃーんメモ の続き。

前回は最も簡単に画像分類を試すために TensorFlow に同梱されているtensorflow.models.image.cifar10パッケージのモデルや学習機構を利用して約75%の識別正答率の分類器を作ったが、それよりも良い結果を出したいし色々ためしてみたい、ということで今回は色々と自前で実装したり改良を加えてみた。

結論だけ先に書くと、約90%の正答率のものを作ることができた。分類数も変えてしまっているので一概には前回のものと比較できないけど。

入力画像の変更

まずは入力の画像について。
前回はCIFAR-10のデータセットに合わせて、検出して切り出した顔画像を32x32サイズに縮小したものを利用していた。

32x32 → inside 96x96 of 112x112

流石に32x32では小さすぎて人間が見てもなかなか区別できなかったりしたし、もうすこしハッキリと分かるくらいの画像サイズを入力に使えるようにしよう ということで各辺3倍サイズの96x96画像を入力にすることにした(画素数で言うと9倍)。
そして、ある程度のスケーリング誤差も吸収できるようにと 顔画像収集時には検出された顔領域の1.2倍ほどの少し大きめの領域で切り出し112x112サイズで取得し、そこから96〜112の間でランダムに切り出してさらに収縮させて最終的に96x96サイズに収まるように、というのを後述のDistortionのところで行った。

6 → 5 Classification

切り出す領域を変更したので顔画像は収集し直してラベルも付け直した。収集方法は同じで、ももクロメンバー5人についてそれぞれ200点、計1000点を学習用のデータセットとして用いた。
前回は6番目のラベルとして「ももクロ以外」の人物の顔を学習・評価に使っていたが、どうにも種類が少なくて分類のラベルとして使うのに適しているとは思えなかったので除外することにした。

TFRecord file

CIFAR-10のバイナリデータの場合、各ピクセルについてのR, G, Bの値を1byteずつ使って表す形だったので1画像あたり32 * 32 * 3 = 3072byteだったが、これが各辺3倍にするとデータサイズが9倍になってしまう。1000点集めると96 * 96 * 3 * 1000 = 27648000byte(26.4MB)。
まぁ別にそれくらいならどうってことないのだけど、もう少し小さいサイズで済むならそれに越したことはない。

TensorFlowには"TFRecords"というバイナリデータ列も含めたシリアライズのファイル形式をサポートするReader & Writerがあり、固定長でない構造的なデータなども複数格納したりできる。

ので、ここに分類の正解ラベルの値とJPEG画像のバイナリデータ列をセットで入れてシリアライズして書き込むことで、112x112サイズでも1画像あたり3~5KB程度でデータセットを作成できる。
使うときはtf.TFRecordReaderでTFRecord fileを読んでFeatureを取り出せばあとはJPEGバイナリデータからtf.image.decode_jpegで画像に復元できる。
(tf.parse_single_exampleあたりはtensorflow-0.6.0と最新コードでは引数などインタフェースが異なるので注意。最新masterのドキュメント読みながらコード書いてたら動かなくてハマった)

Distortion

読み込んでdecodeした画像を、学習データとしてさらにランダムに加工して使う。これはtensorflow.models.image.cifar10.distorted_inputsでも使われている手法。

TensorFlowにはtf.image.random_crop, tf.image.random_flip_left_right, tf.image.random_brightness, tf.image.random_contrastなどの画像加工系メソッドが用意されており、これらによる加工処理を入れることで明るさや色合いを変えたり反転・拡大縮小したりできるので、1つの顔画像からも異なる複数の画像を生成して学習に利用できる。

例えば

という具合に。
各random系メソッドでは加工の度合いの上限・下限を指定できたりするので、その幅を拡げてもっと極端にすると

のようになったりする。
どの程度までやるのが適切なのかは分からないけど 異常になりすぎない程度に抑えておいた。

Code

コードとしてはこんなかんじ。

def inputs(files, distort=False):
    fqueue = tf.train.string_input_producer(files)
    reader = tf.TFRecordReader()
    key, value = reader.read(fqueue)
    features = tf.parse_single_example(value, features={
        'label': tf.FixedLenFeature([], tf.int64),
        'image_raw': tf.FixedLenFeature([], tf.string),
    })
    image = tf.image.decode_jpeg(features['image_raw'], channels=3)
    image = tf.cast(image, tf.float32)
    image.set_shape([IMAGE_SIZE, IMAGE_SIZE, 3])

    if distort:
        cropsize = random.randint(INPUT_SIZE, INPUT_SIZE + (IMAGE_SIZE - INPUT_SIZE) / 2)
        framesize = INPUT_SIZE + (cropsize - INPUT_SIZE) * 2
        image = tf.image.resize_image_with_crop_or_pad(image, framesize, framesize)
        image = tf.image.random_crop(image, [cropsize, cropsize])
        image = tf.image.random_flip_left_right(image)
        image = tf.image.random_brightness(image, max_delta=0.4)
        image = tf.image.random_contrast(image, lower=0.6, upper=1.4)
        image = tf.image.random_hue(image, max_delta=0.04)
        image = tf.image.random_saturation(image, lower=0.6, upper=1.4)
    else:
        image = tf.image.resize_image_with_crop_or_pad(image, INPUT_SIZE, INPUT_SIZE)

    min_fraction_of_examples_in_queue = 0.4
    min_queue_examples = int(FLAGS.num_examples_per_epoch_for_train * min_fraction_of_examples_in_queue)
    images, labels = tf.train.shuffle_batch(
        [tf.image.per_image_whitening(image), tf.cast(features['label'], tf.int32)],
        batch_size=BATCH_SIZE,
        capacity=min_queue_examples + 3 * BATCH_SIZE,
        min_after_dequeue=min_queue_examples
    )
    images = tf.image.resize_images(images, INPUT_SIZE, INPUT_SIZE)
    tf.image_summary('images', images)
    return images, labels

Inference

分類推定のモデルは、 VGGNet と呼ばれる画像分類のための畳み込みネットワークを参考に、独自に定義して作った。

x2 conv layers

VGGNetは「3x3でのconvolutionと2x2でのmax pooling」の組み合わせを複数(5回?)繰り返した後に 3層の全結合で最終的な出力を得ている。これを真似して、cifar10のときには2回だった畳み込み&プーリングを4回行なうようにして

  1. 96 * 96 * 348 * 48 * 32の畳み込み&プーリング層
  2. 48 * 48 * 3224 * 24 * 64の畳み込み&プーリング層
  3. 24 * 24 * 6412 * 12 * 128の畳み込み&プーリング層
  4. 12 * 12 * 1286 * 6 * 256の畳み込み&プーリング層
  5. 9216(= 6 * 6 * 256) * 1024の全結合層
  6. 1024 * 256の全結合隠れ層
  7. 256 * 5の全結合出力層

とした。畳み込みのときの出力channel数や中間層の数など、どれくらいに設定するのが適切なのかはよく分からないので適当に。

パラメータ数としてはweightだけで計算すると
(3 * 3 * 3 * 32) + (3 * 3 * 32 * 64) + (3 * 3 * 64 * 128) + (3 * 3 * 128 * 256) + (9216 * 1024) + (1024 * 256) + (256 * 5) = 10088544
くらい。cifar10のものでは
(5 * 5 * 3 * 64) + (5 * 5 * 64 * 64) + (4096 * 384) + (384 * 192) + (192 * 6) = 1754944
だったので5.7倍くらいには増えている。

Code

コードとしてはこんなかんじ。

def inference(images):
    def _variable_with_weight_decay(name, shape, stddev, wd):
        var = tf.get_variable(name, shape=shape, initializer=tf.truncated_normal_initializer(stddev=stddev))
        if wd:
            weight_decay = tf.mul(tf.nn.l2_loss(var), wd, name='weight_loss')
            tf.add_to_collection('losses', weight_decay)
        return var

    def _activation_summary(x):
        tensor_name = x.op.name
        tf.scalar_summary(tensor_name + '/sparsity', tf.nn.zero_fraction(x))

    with tf.variable_scope('conv1') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 3, 32], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(images, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[32], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv1 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv1)
    pool1 = tf.nn.max_pool(conv1, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool1')

    with tf.variable_scope('conv2') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 32, 64], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(pool1, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[64], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv2 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv2)
    pool2 = tf.nn.max_pool(conv2, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool2')

    with tf.variable_scope('conv3') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 64, 128], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(pool2, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[128], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv3 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv3)
    pool3 = tf.nn.max_pool(conv3, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool3')

    with tf.variable_scope('conv4') as scope:
        kernel = tf.get_variable('weights', shape=[3, 3, 128, 256], initializer=tf.truncated_normal_initializer(stddev=0.1))
        conv = tf.nn.conv2d(pool3, kernel, [1, 1, 1, 1], padding='SAME')
        biases = tf.get_variable('biases', shape=[256], initializer=tf.constant_initializer(0.0))
        bias = tf.nn.bias_add(conv, biases)
        conv4 = tf.nn.relu(bias, name=scope.name)
        _activation_summary(conv4)
    pool4 = tf.nn.max_pool(conv4, ksize=[1, 3, 3, 1], strides=[1, 2, 2, 1], padding='SAME', name='pool4')

    with tf.variable_scope('fc5') as scope:
        dim = 1
        for d in pool4.get_shape()[1:].as_list():
            dim *= d
        reshape = tf.reshape(pool4, [BATCH_SIZE, dim])
        weights = _variable_with_weight_decay('weights', shape=[dim, 1024], stddev=0.02, wd=0.005)
        biases = tf.get_variable('biases', shape=[1024], initializer=tf.constant_initializer(0.0))
        fc5 = tf.nn.relu(tf.nn.bias_add(tf.matmul(reshape, weights), biases), name=scope.name)
        _activation_summary(fc5)

    with tf.variable_scope('fc6') as scope:
        weights = _variable_with_weight_decay('weights', shape=[1024, 256], stddev=0.02, wd=0.005)
        biases = tf.get_variable('biases', shape=[256], initializer=tf.constant_initializer(0.0))
        fc6 = tf.nn.relu(tf.nn.bias_add(tf.matmul(fc5, weights), biases), name=scope.name)
        _activation_summary(fc6)

    with tf.variable_scope('fc7') as scope:
        weights = tf.get_variable('weights', shape=[256, NUM_CLASSES], initializer=tf.truncated_normal_initializer(stddev=0.02))
        biases = tf.get_variable('biases', shape=[NUM_CLASSES], initializer=tf.constant_initializer(0.0))
        fc7 = tf.nn.bias_add(tf.matmul(fc6, weights), biases, name=scope.name)
        _activation_summary(fc7)

    return fc7

Loss

損失関数はcifar10のものと同様で、入力画像に対する出力と正解ラベルとのクロスエントロピー、そこに汎化性能向上のための正則化手法として(?)全結合層の最初と中間層のパラメータに適当な割合でweight decayを入れて それらを合計したものを最小化対象のtotal lossとしている。

Code
def loss(logits, labels):
    sparse_labels = tf.reshape(labels, [BATCH_SIZE, 1])
    indices = tf.reshape(tf.range(BATCH_SIZE), [BATCH_SIZE, 1])
    concated = tf.concat(1, [indices, sparse_labels])
    dense_labels = tf.sparse_to_dense(concated, [BATCH_SIZE, NUM_CLASSES], 1.0, 0.0)

    cross_entropy = tf.nn.softmax_cross_entropy_with_logits(logits, dense_labels)
    mean = tf.reduce_mean(cross_entropy, name='cross_entropy')
    tf.add_to_collection('losses', mean)
    return tf.add_n(tf.get_collection('losses'), name='total_loss')

Train

AdamOptimizer

学習にはGradientDescentOptimizerではなくAdamOptimizerというのを使ってみた。
ちゃんと比較はしていないのだけど、GradientDescentOptimizerよりも格段に早く(少ないstepで)lossが減少するように学習が進んでいるのは観測した。Learning Rateを減衰させて調整する、といったこともここでは行っていない。
ただ1500stepくらいまで行くとそれ以降1stepあたりの計算時間が4〜5倍かかるようになったのだけど これはAdamOptimizerのせいなのかな…?よく分かってない

Code
def train(total_loss, global_step):
    loss_averages = tf.train.ExponentialMovingAverage(0.9, name='avg')
    losses = tf.get_collection('losses')
    loss_averages_op = loss_averages.apply(losses + [total_loss])

    for l in losses + [total_loss]:
        tf.scalar_summary(l.op.name + ' (raw)', l)

    # Apply gradients, and add histograms
    with tf.control_dependencies([loss_averages_op]):
        opt = tf.train.AdamOptimizer()
        grads = opt.compute_gradients(total_loss)
    apply_gradient_op = opt.apply_gradients(grads, global_step=global_step)
    for var in tf.trainable_variables():
        tf.histogram_summary(var.op.name, var)
    for grad, var in grads:
        if grad:
            tf.histogram_summary(var.op.name + '/gradients', grad)

    # Track the moving averages of all trainable variables
    variable_averages = tf.train.ExponentialMovingAverage(MOVING_AVERAGE_DECAY, global_step)
    variables_averages_op = variable_averages.apply(tf.trainable_variables())

    with tf.control_dependencies([apply_gradient_op, variables_averages_op]):
        train_op = tf.no_op(name='train')
    return train_op

Graph

最終的に出来た全体像のグラフはこんなかんじ。

潰れてしまって全然読めないと思うけど、下の方からTFRecordsのファイルを読み込んで加工した画像とラベルのbatchを作って 真ん中の分岐で左側では畳み込みニューラルネットワークに通して出力を計算して 右側を通るラベルと結果を突き合わせてクロスエントロピーを出して損失を計算したりしている、っていう感じになってるはず。

Results


学習結果はこのように、数百〜千step程度でじゅうぶんに0に近い値までlossが減少した。
最初 いくら学習を繰り返しても全然cross entropyが減少しなくて、なんでだ!?と思ったら序盤の畳み込み層の初期値(の幅、具体的にはtf.truncated_normal_initializerに渡すstddev)が小さすぎて途中の出力がすべて同一の値になってしまっていたのが原因だったようで そこを適切に設定し直すことで上記のように上手く学習が進むようになった。

各段階で保存した変数を使って「学習に使っていない」データを使って評価した結果が以下。

1600stepくらいのところでようやく90%ラインに到達して、あとはそこから上がらず まぁ誤差の範囲内かな くらいに。


実際に試すWebアプリも少しアップデートして結果の表示方法をちょっと変えた。冒頭に載せたやつは上手くいった例。
https://momoclo-face-recognizer.herokuapp.com/

↓最新アルバムのジャケ写。百田さんが「有安」になってしまっている

↓前回にも使ったやつ。佐々木さんが「有安」に。

↓だいぶ昔のアー写。百田さんが「高城」に。

↓比較的最近のアー写。玉井さんが「有安」に。

…という具合に集合写真だとやっぱり5人中1人くらいは間違う感覚。

考察

問題設定を変えてしまったので比較しづらくなってしまったのだけど… 結局6クラス分類が75%に上がるのと5クラス分類が90%に上がるのはあまり変わらない気はする。。
けどまぁ少なくとも悪化はしていないはず。あとはやっぱり学習データ数かな、と。


なんとかもっとラクに大量の学習データを用意する方法は無いだろうか…

TensorFlowによるディープラーニングで、アイドルの顔を識別する

以前は MNISTの例を使って画像識別を試してみた けど、次はカラー画像についての識別を試してみる。

「アイドルなんてみんな同じ顔に見える」って 最近も言われてるのかどうか知らないけど、自分もつい5年前くらいまではそう思っていたわけで。その識別を機械学習でやってみよう という試み。
最近はほとんどライブに行かなくなってしまったけど大好きなももいろクローバーZちゃんを題材にしてみることに。
5人のメンバーの顔は機械学習によってどれくらい分類できるようになるのか??

CIFAR-10

CIFAR-10 という、32×32サイズのカラー画像を10種類のクラスに分類する識別課題があり、そのデータセットが公開されている。これを実際にTensorFlowで学習するための畳み込みニューラルネットワークのモデルや関数などがtensorflow.models.image.cifar10パッケージに同梱されているので、これを利用して学習させてみることにした。

画像収集

まず課題となるのが訓練用のデータセットの用意。教師あり学習を行うため、「顔の画像」と「それがどの人物の顔であるか(どう分類されるのが正解か)、を示すラベル」のセットが必要で、CIFAR-10では各6000枚の画像とラベルのセットが用意され提供されている。ももクロの5人の顔識別においては現実的にどれくらいの量が必要か分からないけど、最低でも各100くらいは用意したいところ。
ももクロちゃんはずっとアメブロを続けてきているので、そこに自撮り画像などはある程度蓄積されている。それを利用することにして、

というのを出来るwebアプリをまずrailsで作った。画像加工はRMagickでだいたいできるので便利。

face-collector
https://github.com/sugyan/face-collector

そしてこれを使って自動抽出された顔画像たちを目視で確認しながらラベル付け。これだけは残念ながら人力で行うしかない。集合知を上手く利用できればこのへんもある程度は自動化できるのかもしれないけど…。
とりあえず5人の各メンバーについて200点ずつくらいはすぐにデータを作ることができ(有安さんは安定の自撮りが多くて集めやすい、玉井さんは自撮り少なくて苦労した…あと高城さんは変顔が多くて判断に迷うことが多かったw)、メンバー以外のスタッフや共演者さんの顔画像なども幾つかあったのでそれらも「ももクロメンバーではない」という6つ目のラベルとして混ぜて、訓練用と評価用でデータセットを作成した。

管理上はユニークなデータとしていても同じ顔の写った同じ写真を複数のメンバーがブログに載せていたりもするので、実際には完全にユニークではなく数組ほぼ同じものが混ざっていたりもするかもしれない。

学習

これらを使って、 Tutorial とほぼ同様に学習させていく。CIFAR-10と同形式でファイルを用意しておけば、cifar10.input()の関数1つでファイルからのデータ読み込み、加工、キューイングまですべて済んだ入力データを得られるので便利。
実際に書いたコードはこれだけで、

cifar10.IMAGE_SIZE = 32
cifar10.NUM_CLASSES = 6
cifar10.NUM_EXAMPLES_PER_EPOCH_FOR_TRAIN = 1000
cifar10.INITIAL_LEARNING_RATE = 0.08

FLAGS = tf.app.flags.FLAGS

tf.app.flags.DEFINE_integer('max_steps', 10000,
                            """Number of batches to run.""")
tf.app.flags.DEFINE_string('train_dir', 'train',
                           """Directory where to write event logs """
                           """and checkpoint.""")

def train():
    # ops
    global_step = tf.Variable(0, trainable=False)
    images, labels = cifar10.distorted_inputs()
    logits = cifar10.inference(tf.image.resize_images(images, cifar10.IMAGE_SIZE, cifar10.IMAGE_SIZE))
    loss = cifar10.loss(logits, labels)
    train_op = cifar10.train(loss, global_step)
    summary_op = tf.merge_all_summaries()

    with tf.Session() as sess:
        saver = tf.train.Saver(tf.all_variables(), max_to_keep=21)
        summary_writer = tf.train.SummaryWriter(FLAGS.train_dir)

        # restore or initialize variables
        ckpt = tf.train.get_checkpoint_state(FLAGS.train_dir)
        if ckpt and ckpt.model_checkpoint_path:
            saver.restore(sess, ckpt.model_checkpoint_path)
        else:
            sess.run(tf.initialize_all_variables())

        # Start the queue runners.
        tf.train.start_queue_runners(sess=sess)

        start = sess.run(global_step)
        for step in xrange(start, FLAGS.max_steps):
            start_time = time.time()
            _, loss_value = sess.run([train_op, loss])
            duration = time.time() - start_time

            assert not np.isnan(loss_value), 'Model diverged with loss = NaN'

            print '%d: %f (%.3f sec/batch)' % (step, loss_value, duration)

            if step % 100 == 0:
                summary_str = sess.run(summary_op)
                summary_writer.add_summary(summary_str, step)
            if step % 500 == 0 or (step + 1) == FLAGS.max_steps:
                checkpoint_path = os.path.join(FLAGS.train_dir, 'model.ckpt')
                saver.save(sess, checkpoint_path, global_step=step)

cifar10.inferenceにバッチ入力を渡すことで畳み込みニューラルネットワークモデルとその出力が作られ、cifar10.lossにその出力と正解ラベルを渡すことで誤差を計算、それをcifar10.trainに渡すことで学習が行われる。分かりやすい。
いくつかの定数値は上書きして変更することができ、ここでは

  • distorted_inputsでは32x32サイズの画像をさらにランダムに24x24サイズで切り出して入力としていたが、今回は顔画像領域に既に切り出されているし不要と判断し32x32そのまま入力とする
  • 分類数はメンバー5人+それ以外、で6種類に
  • 何度か学習を試してみたが途中でlossが発散してしまうことがあったのでINITIAL_LEARNING_RATE0.1から0.08に少し下げた

など。

入力画像の種類が少ないこともあってか、数千stepでもう十分なくらいに学習が進む。VPS上でDocker立ち上げて回してみていたけど数時間で10000stepの学習が終了した。

で、学習に使っていない評価用テストデータを使って正答率を計測してみたところ

のようになり、75%くらいまで到達した後 それ以上はもう上がらないようだった。

学習結果を使ったWebアプリ

せっかくここまで作ったのなら、前回のように実際に誰でも試せるようにWebアプリにして公開してみよう、と。

TensorFlowでのMNIST学習結果を、実際に手書きして試す - すぎゃーんメモ のときと同様に、学習済みのデータを使って画像を受け取り判定結果を返すJSON APIを用意し、それを使って判定結果を描画する。
任意の画像をDrag and Dropで受け取るので、まずはそこから判定するための顔領域だけを切り出す必要があり、自作の顔検出器では遅すぎるので ここではLIMITED PREVIEW版の Cloud Vision API を使ってみている。
色んな画像を上げて試してみてください。

https://momoclo-face-recognizer.herokuapp.com/

考察

冒頭の画像では玉井さんが高城さんと誤判定されている以外は当たっている。テストデータでの評価は75%程度だしまぁこんなものかと。
実際色んな画像で試してみるともっと残念な結果になるものの方が多いかんじ。実用的なレベルにはまだまだ達しない。

  • 訓練用の画像1000枚ではまだまだ足りていないのかも?画質の良くない自撮りや変顔なんかも多いし、もうちょっとバリエーションがあった方が良さそう
  • 入力32x32では小さすぎる、というのもある?さすがにそのくらい縮小されると自分で目視しても分かりづらかったりするし誤判定されても仕方ない気はする
    • あと顔領域の切り出し方によっても結構判定結果が変わるようだったので、やはりランダムcropは使った方が良かったのかも
  • ニューラルネットワーク自体が単純すぎる?今回のtensorflow.models.image.cifar10パッケージのものだと2階層だけの畳み込み-プーリングでそこから全結合のものとなっている。もうちょっと深いものだとまた精度が変わったりするだろうか?

色々ためしてみたいところではある。他のアイドルさんの画像も集めて分類数も増やしていきたい。

Repository

AOJはじめました

「AIZU ONLINE JUDGE」通称(AOJ)という、"提出されたプログラムの正しさ・効率の自動判定を行うオンラインジャッジシステム"がある。

いわゆる競技プログラミングプログラミングコンテストの過去問題などが多数掲載されており、各問題に対してソースコードを提出すると その問題の入力に対する正しい出力が得られているか否かを自動で判定してくれる。


…ていうのを何となくは知っていたのだけど実際に触ったことはなくて。先日 チームラボVSドワンゴ!競技プログラミング勉強会@ドワンゴオフィス - connpass というイベントに参加したときにオンラインジャッジに関する解説などがあり 実際に数問やってみる、ということでユーザ登録して挑戦してみたので、その後も継続して挑戦してみることにした。


べつに競技プログラミングで強くなりたい、とかではなく 主に「思考力・実装力を鍛える」という目的で、特に早解きやコードゴルフ的なことは意識しないことにした。
方針としては

  • まずはC++で頑張って自力で解く。
  • グローバル変数はできるだけ使わず、関数の入出力で回答を生成できるように。
  • どうにも上手くいかないときは他人のコードを見たりググって調べたりしても良い。
  • でも最終的にはちゃんと自分でコードを書く。
  • 解けても、もっと良いやり方がありそうであればリファクタリングする。
  • ついでにRubyでも解いてみる。
  • コードコメントは書かないが、考え方をメモしてgithubに上げる

という感じでやってみている。ようやく10問くらいできたところ。

https://github.com/sugyan/aoj


問題は山ほどあるけど、1番目から順番に…というのもアレなので ランダムで問題を選択するスクリプト を適当に作って、それで出てきたものに挑戦する、ようにしている。
とりあえず問題だけ読んで、移動中の電車の中で実装を考えて ちょっと気分転換するタイミングで実際にコードを書いてみたり。それくらいの気軽さで。


どの問題も数十行くらいで解けるようなものなのだけど、実際にやってみるととにかく予想外に詰まることが多くて、自分の力の無さを痛感する。他の人の回答を見て目から鱗、なことも多い。あとC++で書いたものをRubyに移植してみると すごく短く簡潔に書けたり すごく処理時間が増大したりするのを実感できて面白い。

週に2〜3問くらいのペースかな、とりあえず出来るだけ続けていきたいと思ってるけど もうちょい継続するモチベーションが欲しい気もするw 身近で同じ問題に挑戦したりレビューしあえるような仲間がいると良いのかなぁ