EC2のGPU instanceでTensorFlow動かすのにもうソースからのビルドは必要ないっぽい?

めも。

aws ec2, g2-2xlarge, ubuntu14.04にtensorflowを導入する。 - Qiita などを参考にしてg2.2xlargeでの環境を作っていたけど、どうやら 0.8.0 くらいからは 「Cuda compute capabilityうんぬんの問題があるので TF_UNOFFICIAL_SETTING=1 ./configure指定してソースからビルドして」というところは必要なさそう…? 実際にCuda Toolkit 7.5、cuDNN v4だけ入れている状態から公式に配布されているpipパッケージでインストールして動かしてみたところ、tensorflow 0.7.1だとcompute capabilityがサポート外ってことでGPUが有効にならなかったが、0.8.0以降だと普通に動かせるっぽい。

Releases · tensorflow/tensorflow · GitHubだけ見てもよく分からないけど、0.7.1〜0.8.0の間の変更でそのへん対応されていたのかな…?

このへん?

github.com

最新のEC2 GPU instanceでのTensorFlowセットアップ記事はこのへんのようだ。

ただrunfileからCUDA 7.5をinstallする場合?はdriverが古いとhangしてしまうなど問題があるっぽいのでそこは注意する必要がありそう。

0.9.0が正式リリースされたらまた真っさらなUbuntuから環境つくりなおしてみようっと。

EC2 Spot Instanceの価格変動をターミナルでモニタリングする

というものを作った。こんな感じの。

f:id:sugyan:20160623232900p:plain

Repository : https://github.com/sugyan/spot-price-watcher

背景

TensorFlowを使ったDeep Learningでアイドルの顔識別なんかをずっとやってきたけど、やっぱりCPUマシンだけでやっていくのは厳しいな、と思い、

ということでAmazon EC2にてGPU instanceを使って動かすことにした。

g2.2xlargeインスタンスでTensorFlowを動かせるようにするのは、下記記事なんかを参考にしながらやってみたら何とか出来た。 ソースからビルドする必要があるのは時間かかってつらかったけど…

qiita.com

まぁ1回環境を構築してAMIを作ってしまえばその後は苦労せずに同じ環境を再現できるので便利ですね。

で、g2.2xlargeインスタンスを普通に作ると一番安いUSリージョンでも $0.65 per Hour かかるのだけど、これを出来るだけ節約したい。 今までEC2まともに使ったことなくて今回はじめて知ったのだけど、Spot Instancesというのがある。

スポットインスタンスでは、スポットインスタンスリクエストに入札価格を指定して、支払うことができるインスタンス時間あたりの価格を選択します。入札価格がその時点のスポット価格以上であれば、リクエストが受理されてインスタンスを実行できるようになります。このインスタンスの実行は、お客様がインスタンスを終了した時点と、スポット価格が入札価格を上回った時点のいずれか早い方までとなります。スポット価格は Amazon EC2 によって設定され、スポットインスタンス容量に対する需要と供給に応じて定期的に変動します。

製品の詳細 - Amazon EC2 スポットインスタンス - Amazon EC2 | AWS

これを使って、スポット価格が安い時間帯に実行すれば通常インスタンスよりかなり安く済ませられる。 ここ数日の大雑把な感覚だと、US regionでのg2.2xlargeだと $0.1 - $0.2 per Hour のときがあり、最大7〜8割お得になる計算。

ただこのスポット価格は刻一刻と変化しており、いつどのAvailability Zoneがどんな価格に変化するか分からないし、予測できない(これをDeep Learningで予測する、とかも課題としては面白いかもしれないw けどどんな要素が絡んでいるかもよく分からないしなぁ…)。

スポット価格が自分の入札価格を上回ってしまうとその時点でインスタンスは強制中断されてしまうので 中断されるのがイヤな場合はできるだけ高額の入札価格にしておけば良いのだけど、勿論そのスポット価格の分は課金されることになるわけで。

自分の場合は個人の趣味レベルでやってることだし アイドル顔認識モデルの学習なんかは数千step実行すれば十分で、g2.2xlargeインスタンスを使うとGPUの力で1stepあたり0.6秒弱、だから1時間あれば6000stepは進むのでそれくらいの間だけ動いてくれれば十分だったりする(CPUだと一晩以上かかっていた)。 たとえ最後まで終了する前に強制中断させられても「うーん残念。仕方ない」で割り切って またタイミングを見計らってやり直せばいいし、途中経過はちょいちょい書き出しているので実行中に様子を見つつそのcheckpointファイルを吸い出しておけば無駄になることは少ない。

なので、とにかく何も考えず「一番安くなっているもの」を使ってギリギリの入札価格で使いたい。 AMIさえコピーして転送しておけば使えるのでRegionもどこでも構わない。

で、EC2のWebコンソールからは「価格設定履歴」というのを開いて各Availability Zoneの価格の遷移を見ることができるのだけど、

f:id:sugyan:20160623234609p:plain

これだと

  • 選択しているRegionの中のAvailability Zoneでしか一覧できない
  • 高騰しているものも含めてすべて描画されて縮尺を変更できない
    • 最低価格付近だけを見たいのに…
  • 直近24時間より短い範囲で見られない

とか ちょっと不満があった。 何より、いちいちブラウザ開いてポチポチしないとチェックできないのは面倒。

  • ターミナルからコマンド一発で
  • 複数Regionで横断的に
  • 最低価格のあたりの直近の変動・値だけを見たい

と思ったので、そういうものを作った次第。

実装

まずコマンドラインからスポット価格の履歴を取得するのはAWS CLIで出来るし、

AWS コマンドラインインターフェイス | AWS

各言語から使うSDKも様々なものが公式から提供されている。

AWS のツール | AWS

どれを使うか迷ったけど、

  1. 各Regionから取得するのは並列で処理したい
  2. せっかくだからターミナル上にグラフを描画したい

1.はGoかNodeかな、という感じで 2.でbleesed-contribっていうのが以前話題になったのを思い出し、

https://github.com/yaronn/blessed-contrib

これを使ってみたかったというのと Goでも近いものでtermuiというものが実装されていたようだったけど こちらは複数データのLine Chartが未実装ということだったのでNodeを使うことにした。

最近ぜんぜんマトモにNode触っていなかったのでPromiseとかも初めて使ってみたくらいだったけど、勉強しながら書いてみました。 こういうのも公開されていて本当にありがたいです。

JavaScript Promiseの本

複数Regionから並列で各Availability Zoneの価格設定履歴を取得できれば、あとはそのデータを使って描画するだけ。 blessed-contribのバグも見事に踏んでしまったのでp-r送ったりもした。

せっかくなのでキー操作で縮尺・表示範囲を変えたり 最新データに更新できるようにしたりした。

f:id:sugyan:20160623232910g:plain

とりあえずこんなんで最新の価格と細かい値動きはターミナルから一覧できるようになって、便利。

もうちょっと汎用的に使えるように出来たかもしれないけど 多分こんなの自分しか使わないだろうし、ってことで対象RegionはUSの3つのみにしている(EU, APにもGPUインスタンスはあるけど常時高騰している)し 調べるのはg2.2xlargeの価格だけにしているし、AWS CLIのためのCredentialも既にaws configureとかで設定している前提で作っている。 ちょっと雑。

npmへのpublishは自重しておく。

TensorFlowによるDeep Learningでのアイドル顔識別モデルの性能評価と実験

以前から書いているDeep Learningによるアイドル顔識別の話の続き。

コツコツと顔画像収集とラベル付けを続けて、そこそこにデータが集まってきたので ここらでちゃんと性能評価をしてみよう、と。

データセットの作成

今回は、現時点で重複なく180件以上の顔画像が集まっている40人のアイドルを分類対象とした。

これらのアイドルに分類のラベルindexを振り(推してる順とかじゃなくてランダムにね)、それぞれから無作為に抽出した180件の顔画像をそれぞれラベルとセットでレコードを作り、シャッフルして30件ずつ6つのデータセットに分けて保存。

data-00.tfrecords
data-01.tfrecords
data-02.tfrecords
data-03.tfrecords
data-04.tfrecords
data-05.tfrecords

レコードは、以前の記事に書いた TFRecord 形式。 それぞれのファイルには 40人 × 30件 = 1,200 のデータが含まれることになる。画像サイズは112 x 112

中身を並べてみるとこんな感じ。

f:id:sugyan:20160611200418j:plain

まぁ40人、教室1クラスぶんの人数くらいだし、見慣れてる人が見れば簡単に見分けられますね。

作成したデータセットGitHubにも上げておきました。

Cross Validation での評価

学習データに対していくら正確に分類できても、過学習していて学習データ以外のものに対しては正確な分類ができない可能性もある。また学習データに偏りがあっては汎化性能は測れない。 そういったことが起きていないか確認し、精度を検証するための手法として"Cross Validation (交差検証)"というものがあるそうで。 ここでは K-fold Cross Validation と呼ぶようなやり方を試してみる。

上記のデータセットは合計7,200件の「顔画像・ラベル」のセットがあるが、各1,200件の6つのファイルに分けているので、これらを学習用:評価用で5:1に分割し

  • Case #0: data-01,02,03,04,05で学習し、data-00で性能評価
  • Case #1: data-00,02,03,04,05で学習し、data-01で性能評価
  • Case #2: data-00,01,03,04,05で学習し、data-02で性能評価
  • Case #3: data-00,01,02,04,05で学習し、data-03で性能評価
  • Case #4: data-00,01,02,03,05で学習し、data-04で性能評価
  • Case #5: data-00,01,02,03,04で学習し、data-05で性能評価

の6つの学習・評価パターンを作って それぞれ場合の結果を調べてみる。 どの場合も学習用データ6,000件/評価用データ1,200件(40人それぞれ学習用に150件、評価用に30件ずつ含まれることになる)となり、評価用データは学習データには無いものたちなので汎化性能が測れるし、もしデータセットに偏りがあればどこかのケースで異常な結果になったりするはず。

評価結果

評価対象の分類モデルは、以前の記事で作った、4層の畳み込み&プーリング層と3層の全結合層からなるモデル。 入力は 96 x 96 x 3 のカラー画像で、以前と同様にランダムで切り出したり色味を変えたりして128サイズのmini batchを作り、cross entropyにweight decayを加えたものをAdamOptimizerでminimizeするよう学習する。 これをそれぞれのCaseで8,000 stepまで進めた。

各stepでの、評価データに対する正答率は以下の通りとなった。

step Case #0 Case #1 Case #2 Case #3 Case #4 Case #5 AVERAGE
0 3.667 5.083 2.250 2.500 2.500 4.750 3.458
1000 87.417 84.583 84.667 80.000 83.750 84.167 84.097
2000 91.833 90.583 90.417 89.000 91.500 90.917 90.708
3000 93.000 90.750 92.667 89.833 93.083 90.833 91.694
4000 93.083 94.000 93.417 92.583 92.583 94.500 93.361
5000 95.167 94.417 94.583 91.250 94.417 95.333 94.195
6000 96.083 93.667 94.500 93.583 94.667 94.500 94.500
7000 95.667 94.250 94.667 92.750 94.500 94.833 94.445
8000 95.333 94.750 94.667 93.583 95.583 96.333 95.042

グラフにするとこんな感じ。

f:id:sugyan:20160611235633p:plain

100%付近を大きめにすると

f:id:sugyan:20160611235639p:plain

まぁ95%よりちょっと下くらいに落ち着いているかな、と…。まだもう少し続ければ上昇しそうな傾向ではあるけど、こんなところで(CPUマシンだとめっちゃ時間かかるしキツい…)。

考察

どのケースの場合も同様の正答率に落ち着いているし、特にデータセットに依存するものではなく汎化能力として94%程度の正答率が得られる、と言える、はず。 Weight Decayのおかげなのか、少なくとも8,000 step程度では特に過学習となって評価用データに対する正答率が落ちたりはしないようだ。

この「約94%」という正答率を十分高いと捉えるか、まだ低いと捉えるか。 もうちょっと入力データを弄ったり(random distortの範囲を広げる、とか)モデルの構造を改良したり(畳み込みカーネルサイズを変える、とか 畳み込み層をもう1段深くする、とか)で改善する余地はあるかもしれない。 どういうデータに対して上手く分類できて、どういうものに対し失敗しているのか、などの傾向を調べてみると何か分かるかもしれない。

けど、とりあえず「1分類あたり150件の学習データ」で94%くらいの汎化性能があるならまずは十分かな、と。 出来ることならもっと多くのデータと分類数で試したかったけど現時点で用意できたのがこれだけだったので、もっとデータ数が増えればまた結果も少し変わってくるだろうし。 その上でもっと正答率を高めたい、となったときにまた改善方法を検討したい。

実験

現状のモデルがこれくらいの精度だということが分かったので、ここで入力やモデルを色々と変えて正答率がどう変化するか、などを試してみる。 上述のCross Validationでデータセットの偏りは無い、と判断したので(資源の都合もあり)ここから先はCase #0のみで試した。

1. パラメータを減らす

以前の記事で作った分類モデルは、その後ちょっとパラメータ数を変えて

  1. 96 * 96 * 348 * 48 * 32 の畳み込み&プーリング層
  2. 48 * 48 * 3224 * 24 * 48 の畳み込み&プーリング層
  3. 24 * 24 * 4812 * 12 * 72 の畳み込み&プーリング層
  4. 12 * 12 * 726 * 6 * 108 の畳み込み&プーリング層
  5. 3888 (= 6 * 6 * 108) * 1024 の全結合層
  6. 1024 * 256 の全結合隠れ層
  7. 256 * <number of classes> の全結合出力層

としていた。が、本当にこれだけのパラメータが必要なのか?もっと減らしても大丈夫なのでは?という疑問があったので 試してみることにした。

1.1 全結合層のユニット数

今使っている分類モデルはTensorFlow Tutorial の CIFAR-10 Modelを参考にして作ったのだけど、そこでは"sparsity"という値をsummaryで出力している。

  tf.scalar_summary(tensor_name + '/sparsity', tf.nn.zero_fraction(x))

これはtf.nn.zero_fractionつまり出力Tensorのうち値が0となっているものの割合を示している。 全結合層においては出力0のユニットは次の層への入力に影響を及ぼさない(次の層との接続の重みの値が幾らであっても0 * w0になるだけ)ので、存在しなくても結果は変わらない、はず。 このsparsityは当然 学習の過程で上下していくのだけど、(5.)最初の全結合層と次の(6.)隠れ層での値の遷移をみてみると

f:id:sugyan:20160613173237p:plain

f:id:sugyan:20160613173244p:plain

となっていて、特に最初の全結合層では徐々に上昇して最終的に0.96くらいにまで上がる。 これはつまり学習が進むにつれてこの層で出力が必要なユニット数が減っていっていて ある程度まで進んだ段階では多くのユニットが0しか出力しなくなっている、ということ。 (5.)の全結合層では1024のユニットを用意していたけど、結局次の層の入力で使われるのは1024 * (1 - 0.96)41個程度、ということ。 こんなにたくさん用意する必要ないのでは…。てことで、(一応余裕を持たせて)96まで減らしてみる。

同様に(6.)の隠れ層も、256ユニット用意していたけれど そのうち0.75くらいは出力が0になる、ってことなのでこれも半分くらいまで減らしても問題なさそう、ということで128に減らす。

1.2 畳み込み&プーリング層のchannel数

前半の畳み込み&プーリング層の方も同様に無駄な部分あれば減らしたいところだけど、構造が全結合とは違うので単純にsparsityだけで判断するのは良くないかな、と。 こちらは2次元の出力ではなく12 * 12 * 726 * 6 * 108のように3次元から3次元への変換となるので、各層でそれぞれのchannelごとに出力を見てみることにした。

学習済みモデルにおける、ある入力画像に対しての各層での各channelの出力を適当に可視化してみた結果。

1層目

f:id:sugyan:20160613175908p:plain

2層目

f:id:sugyan:20160613175918p:plain

3層目

f:id:sugyan:20160613175929p:plain

4層目

f:id:sugyan:20160613175937p:plain

ちょっと分かりづらいけど、2, 3層目では幾つかの四角が真っ黒になっていて完全に出力が0になっている。 小さくて全然分からないけど4層目は特に、半分くらいのものが黒くなっていて108も要らなそうだったので前段の層と同じchannel数72に減らしてみた。

ということで

  1. 96 * 96 * 348 * 48 * 32 の畳み込み&プーリング層
  2. 48 * 48 * 3224 * 24 * 48 の畳み込み&プーリング層
  3. 24 * 24 * 4812 * 12 * 72 の畳み込み&プーリング層
  4. 12 * 12 * 726 * 6 * 108 の畳み込み&プーリング層
    • 6 * 6 * 72に削減
  5. 3888 (= 6 * 6 * 108) * 1024 の全結合層
    • 2592 (= 6 * 6 * 72) * 96に削減
  6. 1024 * 256 の全結合隠れ層
    • 96 * 128に削減
  7. 256 * <number of classes> の全結合出力層
    • 128 * <number of classes>に削減

という変更をしてみる。

結果

正答率の変化を 前述のCross Varidationの全ケース平均と比較すると

f:id:sugyan:20160614154655p:plain

となり、ほぼ同様の変化を示して最終的に94%程度まで上がった。sparsityの変化は

f:id:sugyan:20160614154709p:plain f:id:sugyan:20160614154721p:plain

となり、当然ながら分母が減っているので より小さな値で落ち着くようになった。

効果としては、別に精度が上がるわけではないけれど より少ないパラメータ数で同程度の分類性能を出せるようになる、ということ。 CPUマシンで試していた限りでは計算速度にはほとんど差異は感じられなかった(計算グラフには変化が無いから?)が、パラメータを保存・復元するcheckpointファイルのサイズは元々が67MBほどだったものが5.5MBと、1/10以下まで軽くなった。これはちょっと嬉しいかもしれない。

2. 色情報を落とす

入力にはRGBカラー画像を使っていたけど、これがもしグレースケールの画像だったらどうなるだろうか。

f:id:sugyan:20160614102323j:plain

自分で目視するぶんには、グレースケールでも輪郭や髪型、表情などの情報は十分に得られるし、顔としての識別はできる。 機械学習だとそのあたりはどうなるだろうか?

データセットから入力mini batchを作る際に、tf.image.decode_jpegJPEGバイナリから画像を復元するが、その際にchannelsオプションを1に指定することでグレースケール画像として読み込むことができる。

あとはdistort系の処理でhue, saturationを変化させるものはグレースケールには適用できないので省くことになるが、これだけの変更で入力を[batch_size, height, width, 1]Tensorに変更できる。

             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.image.decode_jpeg(features['image_raw'], channels=1)
             image = tf.cast(image, tf.float32)
-            image.set_shape([Recognizer.IMAGE_SIZE, Recognizer.IMAGE_SIZE, 3])
+            image.set_shape([Recognizer.IMAGE_SIZE, Recognizer.IMAGE_SIZE, 1])

             # distort
-            image = tf.random_crop(image, [Recognizer.INPUT_SIZE, Recognizer.INPUT_SIZE, 3])
+            image = tf.random_crop(image, [Recognizer.INPUT_SIZE, Recognizer.INPUT_SIZE, 1])
             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)
+            # image = tf.image.random_hue(image, max_delta=0.04)
+            # image = tf.image.random_saturation(image, lower=0.6, upper=1.4)

分類モデル側は最初の畳み込み層のweightが変わるだけ。

         with tf.variable_scope('conv1') as scope:
-            kernel = tf.get_variable('weights', shape=[3, 3, 1, 32], initializer=tf.truncated_normal_initializer(stddev=0.08))
+            kernel = tf.get_variable('weights', shape=[3, 3, 3, 32], initializer=tf.truncated_normal_initializer(stddev=0.08))
             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)

これで学習させてみる。

結果

正答率の変化を 前述のCross Varidationの全ケース平均と比較すると

f:id:sugyan:20160614154738p:plain

と。 カラー画像の場合に対しそれほど大きく劣ることなく正答率が出せているようだ。もう少し追試すると有意差がハッキリ出るかもしれないけど…。

まぁ既にカラー画像のデータが用意できているのならわざわざ色情報を落とす必要は無いだろうな、とは思うけれども。 例えば学習データを集める段階からデータサイズを小さく済ませるよう設計したい、とかの場合に これくらいの顔識別タスクに対しては必ずしもカラー画像で用意しなくても それなりの精度は出る、と言えるかもしれない。

最初の畳み込み層へのchannel数が減っているのとdistortの処理が少し減っている影響か、計算時間はカラー画像の場合よりは少し短縮されていたようだった。これはこれで利点かもしれない。

3. 画像サイズを縮小する

ここまではすべて96 x 96サイズに切り出したものを使ってきたけれど、もっと縮小されると どれくらい精度に影響が出るだろうか?

80 x 80

f:id:sugyan:20160614161201j:plain

64 x 64

f:id:sugyan:20160614161210j:plain

48 x 48

f:id:sugyan:20160614161218j:plain

32 x 32

f:id:sugyan:20160614161225j:plain

感覚的には、64 x 64サイズくらいなら96 x 96とそんなに変わらない感じで見分けられそうで、48 x 48だとちょっと小さくて分かりづらいなー、32 x 32はなかなか厳しそうだ…という感じ。

画像のリサイズはtf.image.resize_imagesでmini batch丸ごと変更できるので、それぞれの場合に1行加えるだけで試すことができる。

モデル定義の方はまったく変更する必要がない。

結果

各入力サイズでの正答率の変化を 前述のCross Varidation全ケース平均と比較すると

f:id:sugyan:20160614163537p:plain

となった。 最も小さい32 x 32でも一応85%弱くらいまでは上がる。48 x 48以上だと90%超えるくらいまでは上がるようだ。 当然ながら縮小しない96 x 96のものが最も精度が高いが、64 x 64くらいに縮小しても致命的なほど精度が下がるわけではない、と言える ような気がする。

サイズ縮小は計算時間には結構大きく影響していて、32 x 32なんかだとCPUマシンでもサクサクと計算が進んだので、どうしても計算量を減らして早く結果を得たい!というときにはこういう縮小もアリかもしれない。

まとめ

  • アイドル顔識別に使えるデータセットを作成した
  • 自作した分類モデルを評価して、94%程度の正答率を得た
  • パラメータを減らしても同程度の精度のものが出来ることを確認した
  • 他にも色々と試して遊んでみた

顔画像データが蓄積されて より大きなデータセットが作れたら再び評価してみたいし、他に何か試したいことを思い付いたら挑戦してみようと思います。

Repository : https://github.com/sugyan/tf-face-recognizer/tree/experiment

TensorFlowによるDCGANでアイドルの顔画像生成

アイドル顔識別のためのデータ収集 をコツコツ続けて それなりに集まってきたし、これを使って別のことも…ということでDCGANを使ったDeep Learningによるアイドルの顔画像の「生成」をやってみた。


まだだいぶ歪んでいたりで あまりキレイじゃないけど…。顔画像を多く収集できているアイドル90人の顔画像からそれぞれ120件を抽出した合計10800件をもとに学習させて生成させたもの。


分類タスクとは逆方向の変換、複数のモデル定義などがあってなかなか理解が難しい部分もあったけど、作ってみるとそこまで難しくはなく、出来上がっていく過程を見るのが楽しいし とても面白い。

DCGANとは

"Deep Convolutional Generative Adversarial Networks"、略してDCGAN。こちらの論文で有名になった、のかな?

あとは応用事例として日本語の記事では以下のものがとても詳しいのでそれを読めば十分かな、と。

一応あらためて書いておくと。

顔識別のような分類タスクは

  • 入力は画像: 縦×横×チャネル数(RGBカラーなら3)で各ピクセルの値
  • 出力は<分類クラス数>次元ベクトル: 最も高い値を出力しているクラスが推定結果となる

といった分類器を作って学習させるだけだが、DCGANではそういった分類器を"Discriminator"として使い、それと別に"Generator"というモデルを構築し使用する。Generatorの役割は

  • 入力は乱数: -1.0〜1.0 の値をとるn次元のベクトル
  • 出力は画像

というものであり、この出力が最終的な「機械学習による画像生成」の成果物となる。
原理としては、

  • Discriminatorに「Generatorによって乱数ベクトルから生成された画像」と「学習用データ画像(生成させたい画像のお手本となるもの)」の両方を食わせ、それぞれの画像が「Generatorによって生成されたものであるか否か」の判定をさせる
  • Discriminatorは正しく判定できるよう学習させ、GeneratorはDiscriminatorを欺いて誤判定させる画像を生成するよう学習する

これを繰り返してお互いに精度を上げることで、ランダム入力から学習データそっくりの画像を生成できるようになる、というもの。


(http://qiita.com/mattya/items/e5bfe5e04b9d2f0bbd47 より引用)

言葉にしてみるとまぁなるほど、とは思うけどそんな上手く双方を学習できるのか、という感じではある。そのへんをBatch Normalizationを入れたりLeaky ReLUを使ったりして上手くいくようになったよ、というのが上記の論文のお話のようだ。

TensorFlowでの実装

先行のDCGAN実装例は既に結構ある。

TensorFlowによる実装も既にあったので、それを参考にしつつも自分で書いてみた。

Generator

乱数ベクトルから画像を生成するモデルは下図のようになる。

(arXiv:1511.06434より引用)

分類器などで使っている畳み込みの逆方向の操作で、最初は小さな多数のfeature mapにreshapeして、これを徐々に小数の大きなものにしていく。"deconvolution"と呼んだり呼ばなかったり、なのかな。TensorFlowではこの操作はtf.nn.conv2d_transposeという関数で実現するようだ。

各層間の変換でW(weights)を掛けてB(biases)を加え、このWとBの学習により最終的な出力画像を変化させていくことになる。あと論文にある通りReLUにかける前にBatch Normalizationという処理をする。これはTensorFlow 0.8.0からtf.nn.batch_normalizationが登場しているのかな?ここにtf.nn.momentsで得るmeanとvarianceを渡してあげれば良さそう。

ということでこんな感じのコードで作った。

def model():
    depths = [1024, 512, 256, 128, 3]
    i_depth = depths[0:4]
    o_depth = depths[1:5]
    with tf.variable_scope('g'):
        inputs = tf.random_uniform([self.batch_size, self.z_dim], minval=-1.0, maxval=1.0)
        # reshape from inputs
        with tf.variable_scope('reshape'):
            w0 = tf.get_variable('weights', [self.z_dim, i_depth[0] * self.f_size * self.f_size], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
            b0 = tf.get_variable('biases', [i_depth[0]], tf.float32, tf.zeros_initializer)
            dc0 = tf.nn.bias_add(tf.reshape(tf.matmul(inputs, w0), [-1, self.f_size, self.f_size, i_depth[0]]), b0)
            mean0, variance0 = tf.nn.moments(dc0, [0, 1, 2])
            bn0 = tf.nn.batch_normalization(dc0, mean0, variance0, None, None, 1e-5)
            out = tf.nn.relu(bn0)
        # deconvolution layers
        for i in range(4):
            with tf.variable_scope('conv%d' % (i + 1)):
                w = tf.get_variable('weights', [5, 5, o_depth[i], i_depth[i]], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
                b = tf.get_variable('biases', [o_depth[i]], tf.float32, tf.zeros_initializer)
                dc = tf.nn.conv2d_transpose(out, w, [self.batch_size, self.f_size * 2 ** (i + 1), self.f_size * 2 ** (i + 1), o_depth[i]], [1, 2, 2, 1])
                out = tf.nn.bias_add(dc, b)
                if i < 3:
                    mean, variance = tf.nn.moments(out, [0, 1, 2])
                    out = tf.nn.relu(tf.nn.batch_normalization(out, mean, variance, None, None, 1e-5))
    return tf.nn.tanh(out)

入力は乱数なのでtf.random_uniformを使えば毎回ランダムな入力から作ってくれる。逆畳み込みはchannel数が変わるだけなのでfor loopで繰り返すだけで定義できる。最後の出力にはBatch Normalizationをかけずにtf.nn.tanhで -1.0〜1.0 の範囲の出力にする。

Discriminator

こちらは以前までやっていた分類器とほぼ同じで、画像入力から畳み込みを繰り返して小さなfeature mapに落とし込んでいく。最後は全結合するけど、隠れ層は要らないらしい。出力は、既存のTensorFlow実装などでは1次元にしてsigmoidの出力を使うことで「0に近いか 1に近いか」を判定にしていたようだけど、自分はsigmoidを通さない2次元の出力にして、「0番目が大きな出力になるか 1番目が大きくなるか」で分類するようにした(誤差関数については後述)。
また各層の出力にはLeaky ReLUを使うとのことで、これに該当する関数はTensorFlowには無いようだったけど、tf.maximum(alpha * x, x)がそれに該当するということで それを使った。

また、Discriminatorは「学習用データ」と「Generatorによって生成されたもの」の2つの入力を通すことになるのでフローが2回繰り返されることになる。けどこれは同じモデルに対して入出力を行う、つまり同じ変数を使い回す必要がある。こういうときはtf.variable_scopereuse=Trueを指定すると2回目以降で同じ変数が重複定義されないようになるらしい。いちおう、初回の呼び出しか否かを使う側が意識する必要がないようPython3のnonlocalを使ってクロージャ的な感じで書いてみた。

ということでこんなコード。

def __discriminator(self, depth1=64, depth2=128, depth3=256, depth4=512):
    reuse = False
    def model(inputs):
        nonlocal reuse
        depths = [3, depth1, depth2, depth3, depth4]
        i_depth = depths[0:4]
        o_depth = depths[1:5]
        with tf.variable_scope('d', reuse=reuse):
            outputs = inputs
            # convolution layer
            for i in range(4):
                with tf.variable_scope('conv%d' % i):
                    w = tf.get_variable('weights', [5, 5, i_depth[i], o_depth[i]], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
                    b = tf.get_variable('biases', [o_depth[i]], tf.float32, tf.zeros_initializer)
                    c = tf.nn.bias_add(tf.nn.conv2d(outputs, w, [1, 2, 2, 1], padding='SAME'), b)
                    mean, variance = tf.nn.moments(c, [0, 1, 2])
                    bn = tf.nn.batch_normalization(c, mean, variance, None, None, 1e-5)
                    outputs = tf.maximum(0.2 * bn, bn)
            # reshepe and fully connect to 2 classes
            with tf.variable_scope('classify'):
                dim = 1
                for d in outputs.get_shape()[1:].as_list():
                    dim *= d
                w = tf.get_variable('weights', [dim, 2], tf.float32, tf.truncated_normal_initializer(stddev=0.02))
                b = tf.get_variable('biases', [2], tf.float32, tf.zeros_initializer)
        reuse = True
        return tf.nn.bias_add(tf.matmul(tf.reshape(outputs, [-1, dim]), w), b)
    return model
Input Images

Discriminatorには[batch size, height, width, channel]の入力を与える前提で作っていて、学習用の画像データはその形のmini batchが作れれば良い。以前から 顔画像データはTFRecordsのファイル形式で作っていて それを読み取ってBatchにする処理は書いているので、それをほぼそのまま利用できる。

def inputs(batch_size, f_size):
    files = [os.path.join(FLAGS.data_dir, f) for f in os.listdir(FLAGS.data_dir) if f.endswith('.tfrecords')]
    fqueue = tf.train.string_input_producer(files)
    reader = tf.TFRecordReader()
    _, value = reader.read(fqueue)
    features = tf.parse_single_example(value, features={'image_raw': tf.FixedLenFeature([], tf.string)})
    image = tf.cast(tf.image.decode_jpeg(features['image_raw'], channels=3), tf.float32)
    image.set_shape([INPUT_IMAGE_SIZE, INPUT_IMAGE_SIZE, 3])
    image = tf.image.random_flip_left_right(image)

    min_queue_examples = FLAGS.num_examples_per_epoch_for_train
    images = tf.train.shuffle_batch(
        [image],
        batch_size=batch_size,
        capacity=min_queue_examples + 3 * batch_size,
        min_after_dequeue=min_queue_examples)
    return tf.sub(tf.div(tf.image.resize_images(images, f_size * 2 ** 4, f_size * 2 ** 4), 127.5), 1.0)

元々は分類タスクの教師データなのでlabel_idとセットになっているデータセットだけど、ここではJPEGバイナリ部分だけ取り出して使うことになる。distort系の処理はとりあえずほぼ無しで、random_flip_left_right(ランダム左右反転)だけ入れている。あと分類タスクでは最後にtf.image.per_image_whiteningを入れていたけど、これをやると元の画像に戻せなくなってしまうと思ったので 単純に 0〜255 の値を -1.0〜1.0 の値になるよう割って引くだけにしている。

Training

で、GeneratorとDiscriminatorが出来たらあとは学習の手続き。それぞれに対して最小化すべき誤差(loss)を定義して、Optimizerに渡す。前述した「Discriminatorは正しく判定できるよう学習させ、GeneratorはDiscriminatorを欺いて誤判定させる画像を生成するよう学習する」というのをコードに落とし込む。
Discriminatorによる分類を「0なら画像はGeneratorによるもの、1なら学習データのもの」と判定する関数D(x)と定義し、Generatorから生成した画像をG()、学習データの画像をIとすると

  • Generatorは、D(G())がすべて1になるのが理想
  • Discriminatorは、D(G())をすべて0にし D(I)をすべて1にするのが理想

なので、そのギャップをlossとして定義することになる。Discriminatorのような排他的な唯一の分類クラスを決める場合の誤差にはtf.nn.sparse_softmax_cross_entropy_with_logitsを使うのが良いらしい。
ということでこんな感じのコード。

def train(self, input_images):
    logits_from_g = self.d(self.g())
    logits_from_i = self.d(input_images)
    tf.add_to_collection('g_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_g, tf.ones([self.batch_size], dtype=tf.int64))))
    tf.add_to_collection('d_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_i, tf.ones([self.batch_size], dtype=tf.int64))))
    tf.add_to_collection('d_losses', tf.reduce_mean(tf.nn.sparse_softmax_cross_entropy_with_logits(logits_from_g, tf.zeros([self.batch_size], dtype=tf.int64))))
    g_loss = tf.add_n(tf.get_collection('g_losses'), name='total_g_loss')
    d_loss = tf.add_n(tf.get_collection('d_losses'), name='total_d_loss')
    g_vars = [v for v in tf.trainable_variables() if v.name.startswith('g')]
    d_vars = [v for v in tf.trainable_variables() if v.name.startswith('d')]
    g_optimizer = tf.train.AdamOptimizer(learning_rate=0.0001, beta1=0.5).minimize(g_loss, var_list=g_vars)
    d_optimizer = tf.train.AdamOptimizer(learning_rate=0.0001, beta1=0.5).minimize(d_loss, var_list=d_vars)
    with tf.control_dependencies([g_optimizer, d_optimizer]):
        train_op = tf.no_op(name='train')
    return train_op, g_loss, d_loss

論文によるとAdamOptimizerのパラメータはデフォルト値ではなくlearning_rate0.0002beta10.5を使うとのことだったけれど、Qiitaでの先行事例ではlearning_rateはさらに半分の0.0001としていて 実際大きすぎると最初の段階で失敗してしまうことがあったので0.0001にしておいた。
あと効くかどうか分からないけど一応すべてのweights分類タスク で使っていたWeight Decayを入れておいた。

Generating Images

こうして学習のopsも定義できたらあとはそれを実行して繰り返していれば少しずつ「ランダムな出力」から「顔らしい画像」になっていく。はず。
で、その成果物を確かめたいのでやっぱり画像ファイルとして書き出したいわけで。Generatorからの出力を取得して変換かけて、scipypylabなどを使って画像として出力できるみたいだけど、そのあたりも実はTensorFlowだけで出来るんですね。
Generatorからの出力は[batch size, height, width, channel]の、 -1.0〜1.0 の値をとるTensorなので、まずはそれらをすべて 0〜255 の整数値に変換する。
で、それをbatch sizeにsplitしてやると、それぞれ[height, width, channel]な画像データになるわけで。これらはtf.image.encode_pngとかにかければPNGバイナリが得られる。
せっかくなので複数出力された画像をタイル状に並べて1つの画像として出力させたいじゃん、って思ったらtf.concatで縦に繋げたり横に繋げたりを事前に入れておくことでそれも実現できる。

def generate_images(self, row=8, col=8):
    images = tf.cast(tf.mul(tf.add(self.g(), 1.0), 127.5), tf.uint8)
    images = [tf.squeeze(image, [0]) for image in tf.split(0, self.batch_size, images)]
    rows = []
    for i in range(row):
        rows.append(tf.concat(1, images[col * i + 0:col * i + col]))
    image = tf.concat(0, rows)
    return tf.image.encode_png(image)

これで、得られたopsをevalして得たバイナリをファイルに書き出すだけで1つのbatchで生成された複数の画像出力を並べたものを一発で得ることができる。便利〜。

計算量を減らす(?)

今回は64x64でなく96x96の画像を生成させようとしていて(学習データが112x112で収集しているし 折角ならそれなりに大きく作りたい!)、元の論文では各層のchannel数が 1024, 512, 256, 128 になっていて(Qiitaの記事ではすべて半分にしていた)、そのパラメータ数で手元のCPUマシンで計算させる(僕はケチなのでGPUマシンとか持ってない…)と、 1step に 50sec とかとんでもなく時間がかかってしまい ちょっと絶望的だわ…と思い 少しでも計算量が減るよう 250, 150, 90, 54 という数字に変えた(50sec -> 18sec)。そしてbatch sizeも 128 から、半分の 64 だと流石に無理そうだったので 96 に(18sec -> 13sec)。一応このパラメータ数で200stepほど回してみたところ ちゃんとランダム出力から顔っぽいものに変わっていっているのが観測できたので これでやってみた。


少しずつ顔っぽいものが現れてきて、それぞれが個性ある顔に 鮮明に写るようになっていく変化がみてとれるかと。
こうして丸2日以上かけて、7000stepくらい回した結果 得たのが冒頭の画像になります。まだちゃんとした顔にならなかったりするのは、単に学習回数が足りていないのか パラメータが足りなすぎてこれ以上キレイにならないのか、はもうちょっと続けてみないと分からないけど 多分まだ回数が足りていないだけなんじゃないかな… もうちょっと続けてみます。

今回は「顔」っていうとても限定的な領域での生成だし、これくらいで大丈夫だろう、と かなり勘でパラメータ数を決めてしまっているので 本当はもっと理論的に適切な数を導き出したいところだけど…。
前回までの分類器だとsparsityというのを計測していたのでそれを元に削れるだろうな、と思っているのだけど、今回の場合すべてにBatch Normalizationが入っているのでそれも意味なくて、なんとも難しい気がする。それこそGPUで何度もぶん回して探っていくしかないのかなぁ。

任意の画像の生成…

DCGANによる生成ができると、今度は入力の乱数ベクトルを操作することで任意の特徴をもつ画像をある程度狙って生成できるようになる、とのことなのでそれも試してみたいと思ったけど まだ出来ていないのと 長くなってしまったので続きは次回。

画像内から検出した顔領域を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を防ぐことができる。