KarabinerからHammerspoonへ

そろそろ新しいMacBook欲しい… けど El Capitan から Sierra になるとKarabinerが使えなくなるって話だし… とか色々不安があり躊躇していた。 代替、というかちゃんと設定すれば代替できそうなHammerspoonが良さげだったので それを使ってKarabinerからの脱却を試みてみた。

Hammerspoon

www.hammerspoon.org

Mac OSの様々な操作を自動化するためのツールで、ユーザがLuaスクリプトで設定を書くことでOSの様々な機能を使えるようにブリッジしてくれる。

Key Remap

基本的に 以下の記事のコピペで大丈夫そう。

qiita.com

自分は以下のように書いた。

local function keyStroke(mods, key)
  return function() hs.eventtap.keyStroke(mods, key, 0) end
end

local function remap(mods, key, fn)
  return hs.hotkey.bind(mods, key, fn, nil, fn)
end

-- global
remap({'ctrl'}, 'h', keyStroke({}, 'delete'))
remap({'ctrl'}, '[', keyStroke({}, 'escape'))
remap({'ctrl'}, 'j', keyStroke({}, 'return'))
remap({'ctrl', 'cmd'}, 'j', keyStroke({'cmd'}, 'return'))

-- disable when a specific application is active
local remapKeys = {
  remap({'ctrl'}, 'b', keyStroke({}, 'left')),
  remap({'ctrl'}, 'f', keyStroke({}, 'right')),
  remap({'ctrl'}, 'n', keyStroke({}, 'down')),
  remap({'ctrl'}, 'p', keyStroke({}, 'up')),
  remap({'ctrl'}, 'y', keyStroke({'cmd'}, 'v'))
}

local function handleGlobalAppEvent(name, event, app)
  if event == hs.application.watcher.activated then
    if name == 'iTerm2' or name == 'Code' then
      for i, key in ipairs(remapKeys) do
        key:disable()
      end
    else
      for i, key in ipairs(remapKeys) do
        key:enable()
      end
    end
  end
end

appsWatcher = hs.application.watcher.new(handleGlobalAppEvent)
appsWatcher:start()

hs.hotkey.bindで指定したキー操作時の振る舞いを指定できるので、hs.eventtap.keyStrokeで置き換えたいキー操作を指定してそれをemitさせるfunctionをセットする。pressedfn, releasedfn, repeatfnと3種類の振る舞いを定義できるので注意。リマップは基本的にpressedfn, repeatfnを同じkeyStrokeを起こすfunctionにしておけば良さそう。

あと上述記事にもある通りiTermやVSCodeなどのターミナルやエディタではそれぞれ独自にキーバインド設定できたりするし リマップによる変換があると逆にそれが災いして期待した動作にならなかったりするので、applicationの切り替え時にイベントを取得してdisable()/enable()するように。

別にすべてのリマップを無効にする必要は無かったりもするので、常に有効にしておきたいものとは別に切り替え対象のリマップのkeybindだけを保持しておいて それらだけを有効化/無効化の対象にした。

Sticky Shift

Karabinerでもう一つお世話になっていたのが、所謂sticky-shift的なものをセミコロン;で行う、というもの。

セミコロン;を押した後にキーを押す、という操作でShiftキー押しながらの操作を代替する。(別に使うのはセミコロンじゃなくてもいいのだけど 最も一般的なのがこれ、なのかな)

openlab.ring.gr.jp

AquaSKKを使っていると変換位置の指定の際にShiftキーを多用することになりつらいので、ずっとこの方法にお世話になっていたし、すっかりそれに慣れてしまっていた。

Karabinerでは AquaSKK で ; (セミコロン) を Sticky Shift に使う - Qiita のように設定して使っていたのだけど、これもHammerspoonでどうにか。

local stickyShift = false
local targets = {}
for i = 96, 122 do
  targets[hs.keycodes.map[string.char(i)]] = true
end

hs.hotkey.bind({}, ';', function()
  if hs.keycodes.currentMethod():find('AquaSKK') then
    stickyShift = true
  else
    hs.eventtap.keyStrokes(';')
  end
end)

keyTap = hs.eventtap.new({hs.eventtap.event.types.keyDown}, function(event)
  if stickyShift and targets[event:getKeyCode()] then
    event:setFlags({shift = true})
  end
  stickyShift = false
end):start()

stickyShiftという変数を持っておいて、入力ソースがAquaSKKのときに;が押されたらフラグを立てる。それ以外のときは普通にそのまま;keyStrokeをemitするだけ。

keyDownイベントを監視し、stickyShiftフラグが立っていて かつtargetのkeyCode(母音と主な子音だけで良いのだけど とりあえず全アルファベットを対象にしている)だったら、setFlagsを使ってshiftが押されている状態にイベントを書き換える。stickyShiftフラグは継続させる必要ないので強制的にfalseに戻す。

これで今まで通りに;を使ったsticky-shiftでの日本語入力ができるようになった。

ひらがなモードのときだけフラグを変えて ASCIIモードなら;はそのまま通す、とかも出来れば良いのだけど、入力ソースの判別までは出来ても 現在の入力モードが何か、までは取る方法が分からなかった(無い?)ので;を普通に打ちたい場合は普通に英字入力に切り替えるしかない。 まぁこれはKarabinerのときもそうだったし仕方ない。

あと どういうわけか、SierraだとVisual Studio Code上でAquaSKKがまったく効かなくて、ひらがなモードに入ることすらできないようだ。キー設定とかそういう問題じゃなさそうで どうにもならないんだけど、、他の人は普通に使えているのだろうか…?

まぁVSCodeはコード書くものだし日本語の文章書きたいときは他のアプリケーションを使えばいいか…ということで割り切る(この記事の下書きはAtomで書いてる)。

Launcher

あとよく使っていたのがhotkeyでのアプリ切り替え。cmd+ctrl+左手で打てるキー の組み合わせでターミナル/エディタ/ブラウザ/チャット/ツイッター などをサクサク切り替えたくて 以前はSlatePhoenix、もしくは有料アプリのShortcutsなんかを使っていたりもしたけど、これもHammerspoonで解決できるようになったのでLauncherアプリは不要になった。

local function launcher(mods, key, appname)
  hs.hotkey.bind(mods, key, function()
    hs.application.launchOrFocus('/Applications/' .. appname .. '.app')
  end)
end

launcher({'cmd', 'ctrl'}, 'q', 'iTerm')
launcher({'cmd', 'ctrl'}, 'w', 'Visual Studio Code')
launcher({'cmd', 'ctrl'}, 'e', 'Google Chrome')
launcher({'cmd', 'ctrl'}, 't', 'Twitter')
launcher({'cmd', 'ctrl'}, 's', 'Slack')

Key Repeat

あとそういえばkarabinerで便利だったのがキーリピート間隔の設定だったのだけど、 システム環境設定での設定可能範囲を超える場合はdefaultsコマンドで設定すれば良いだけ、というのをググって知ったので 問題なかった。

$ defaults write -globalDomain KeyRepeat -int 1
$ defaults write -globalDomain InitialKeyRepeat -int 10

とかやって再起動すれば反映される(システム環境設定での変更ではKeyRepeat2InitialKeyRepeat15が最小値っぽい)

雑感

とりあえずKarabinerが無くても困らない程度の設定は出来たっぽい。他にもカスタマイズしたければinit.luaを頑張って書けば色々できそうではある。

しかしまぁKarabinerがSierraでも動いてくれるのが何よりだし このHammerspoonだって いつまたMac OSの変化で使えなくなってしまうか分からないし

こうしてMacの変更に振り回されて色々と苦労しないといかんのはどうにからならないもんかなぁ、と思ってしまう

Google Code Jam 2017: Qualification Round

プログラミングコンテスト Google Code Jam

以前にAOJを始めた ものの 情けないことに結局2週間くらいで止まってしまい、それ以来ずっとこれ系からは離れてしまっていたけど、Code Jamは何故か毎年チャレンジしている(毎回すぐ予選落ちしてるけど)ので、今年も挑戦してみた。

Qualification Roundは27時間のオンライン予選。その時間中に4問のうち幾つかを解いて25pt以上獲得すれば次に進める、というもの。

土曜の夜に時間を見つけて取り組んでみた。 C++でやりたかったけど、色々と思い出せる気がしなかったので全部Rubyでやった。

A. Oversized Pancake Flipper

長さSのパンケーキ列を与えられて、長さKのflipperを使ってすべてを表面にするための最低操作回数、もしくは不可能であるか、を求める

sample input:

3
---+-++- 3
+++++ 4
-+-+- 4

sample output:

Case #1: 3
Case #2: 0
Case #3: IMPOSSIBLE

とにかく端から順番に見ていって、裏(-)があればそこを起点にK個ひっくり返しカウントし、逆端まで到達する位置まで行ったら最後に全部表(+)になっているかどうか判定すれば答えは出せるはず、ループ回数もせいぜいS * Kでたいしたことない。

こんな感じで

t = gets.to_i
t.times do |i|
  s, k = gets.split(' ')
  s = s.split('').map { |c| c == '+' }
  a = 0
  (s.size - k.to_i + 1).times do |m|
    next if s[m]
    k.to_i.times do |n|
      s[m + n] = !s[m + n]
    end
    a += 1
  end
  ok = true
  k.to_i.times do |m|
    next if s[s.size - m - 1]
    ok = false
    break
  end
  puts "Case ##{i + 1}: #{ok ? a : 'IMPOSSIBLE'}"
end

普通にrangeを書いてeachを使うべきか…?

B. Tidy Numbers

与えられたN以下の数のうち最大の、「数字が左から昇順に並ぶ数」を求める。

sample input:

4
132
1000
7
111111111111111110

sample output:

Case #1: 129
Case #2: 999
Case #3: 7
Case #4: 99999999999999999

Nからスタートして順番にデクリメントしていって一つ一つ昇順になっているかどうか判定していってもいいけど、そうすると10^18になるlarge datasetで詰むのは目に見えている。

もう最初から数値だと思わずに、法則に従う数字の列だと思って操作していけばいいか、と。132129になる。1325だと1299になるし 13246だと12999が答えになるはず、とか考えると 「右から降順になっているかチェックして、違反していたらその桁の値を1つ小さくしてそこから右を全部9にする」を繰り返せば解に辿り着きそう。最上位桁が0になったら削る。 計算量も桁数 * 9回程度だから一瞬で出る。

ということで

t = gets.to_i
t.times do |i|
  n = gets.strip.split('').map(&:to_i)
  loop do
    ok = true
    (n.size - 1).times do |j|
      next unless n[j] > n[j + 1]
      ok = false
      n[j] = n[j].to_i - 1
      ((j + 1)..(n.size - 1)).each do |k|
        n[k] = 9
      end
      n.slice!(0) if n[0] == 0
      break
    end
    break if ok
  end
  puts "Case ##{i + 1}: #{n.join}"
end

C. Bathroom Stalls

問題を理解するのに時間かかった…。 要するに 並んでいる長さNのstallの列を選択によって分割したときの右と左ができるだけ長い列になるよう埋めていく、という法則に従ってK人が使用したときの最大値と最小値を求める、ということになる

sample input:

5
4 2
5 2
6 2
1000 1000
1000 1

sample output:

Case #1: 1 0
Case #2: 1 0
Case #3: 1 1
Case #4: 0 0
Case #5: 500 499

1000だったら1人目によって500499に分割され、次は2人目によって500が分割され250249になる、そうすると3人目は499249249で真っ二つに、…という感じの流れになる。 対象が奇数のときは(n-1)/2が2つ に分けられるけど、偶数のときはn/2n/2-1と違う長さに分けられることに気をつけないといけない。

最初は[N]の1要素配列から始めてshiftした値を分割して それを降順で後ろに突っ込むqueueみたいな形でK回操作を繰り返せば答えが出るのでは?と思ったけど、偶数の同じ値が続いたときに124, 124 -> 62, 61, 62, 61のように降順に並ばないことに気付き、苦し紛れに操作のたびにqueueをsort!するという暴挙に出たところsmall datasets 1はどうにか通ったけど、10^6くらいのdatasetだと当然のように計算が終わらなくて死ぬ。

同じ値が何度も登場することに気付いたので それぞれの出現回数を持って操作していけばいいのか!とすぐに気付いて{ N => 1 }から始まるHashを用意。keys.maxを取り出して、対応するvalueを key: (m-1)/2 や key: m/2-1 などに加える。というのを繰り返せばよい。取り出したvalueの総計がKを超えたら既にK人が使用した、ということで そのときの値が答えになる。

t = gets.to_i
t.times do |i|
  n, k = gets.strip.split(' ').map(&:to_i)
  a = { n => 1 }
  y = z = n
  total = 0
  loop do
    m = a.keys.max
    v = a.delete(m)
    y = m / 2
    z = m.even? ? m / 2 - 1 : m / 2
    a[y] = 0 unless a.include?(y)
    a[y] += v
    a[z] = 0 unless a.include?(z)
    a[z] += v
    total += v
    break if total >= k
  end
  puts "Case ##{i + 1}: #{y} #{z}"
end

問題文からはこういう形になることが全然想像つかなかったので、解いていて面白かった。

D. Fashion Show

一応問題は読んだけど、ちょっと時間があまり無かったのと 確実に解けるような気がしなかったので捨てた…

結果

A, B, C でlarge datasetまで正答で65ptで終了。

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

以前に試した、アイドル顔識別の性能評価。

memo.sugyan.com

それから半年以上も経ってデータ数も増えたし ちょっと確かめたいこともあったので、再び試してみた。

新データセット

前回は 40人×180件 で 計7,200件 を用意したけど、今回はもう少し多めにデータが集まっていたので(卒業などでもうアイドルではなくなってしまった子も居るけど…)、今回は 120人×200件 で 計24,000件 を抽出してデータセットを作成した。

実際にラベル付けしたデータから抽出してみると、元が同じ画像なのに加工や顔検出器のブレなどで別の顔画像として登録されてしまっているもの、明らかに同じ日・同じ場所で連写していて「ほぼ同じ顔画像」と思われるもの などの重複が結構あることに気付いて、頑張って出来る限り排除した。

前回もある程度は人力でチェックしていたけど、今回は学習済みモデルに食わせた中間層出力の分布距離の近いもの、などを調べて機械的に検出した。それでも完全には排除できていないかもしれないけど…。

中身をザッと眺めるとこんな感じになる。

f:id:sugyan:20170220233000j:plain

120人。ほぼ自分でラベル付けしたものなので僕はだいたい見分けがつくけど、普通の人にはちょっと難しいかもしれないですね。

果たして自作の分類モデルはどれだけ見分けられるようになるのか。

Cross Validation で改めて評価

今回は用意したデータセットを5つに分割し、120人それぞれの160件ずつを学習に用い 40件ずつを評価に用いる、という分け方で前回同様に複数パターンで試してみた。 ちょっと時間と金がかかるので使ったのは5パターン全部ではなく3パターンだけ…

前回と同じ構造・同じパラメータ数のモデルを使って学習させ、評価データにおける正答率の変化を調べた。

f:id:sugyan:20170221205722p:plain

右上の方を拡大すると

f:id:sugyan:20170221205727p:plain

40,000 stepくらいで93%くらいには到達するものの、そこから先は続けても94%には到達せず…というところ。 前回のときより僅かに下がったか…?

lossに使っているcross entropyはある程度まで確実に減少している。

f:id:sugyan:20170221205732p:plain

まぁ重複精査したのもあるし、1人あたりの学習件数はそんなに変わらないのに3倍の分類数になっても同程度の精度は出ている、ってことで。

学習時の入力画像distortion再考

今のモデルは、CIFAR-10用のモデルを参考に 入力画像は96x96のサイズだがデータセットは一回り大きく112x112のサイズで用意しており、そこからtf.random_cropを使ってランダムに96x96の領域で切り取って使う、という形式 (評価時は常に中央部分を切り取って使う)。 なので、データセットに使う顔画像収集・管理ツールでも 検出された顔領域が96x96に収まるサイズに拡大・縮小した上で、そこを中心として周辺を含む112x112で切り取ってデータセット用に保存している。

しかし CIFAR-10のような一般分類タスクと違って、この顔識別タスクでは一応分類すべき領域が既に分かっているわけで (一応、というのは 自作顔検出器の精度がそれほど高いわけではなく実際にはそれほど正確に中央の96x96領域に顔がすっぽり収まっているデータばかりではない、というのがある)。 このtf.random_cropを利用せずに学習時も常に中央の領域だけを使う、としたときにどれだけ精度が落ちるか(もしくは意外と落ちないのか)も検証してみた方が良かったかな…(今回はしていない)。

で、違う方法としてtf.image.sample_distorted_bounding_boxを利用できると思ったのでやってみた。 これは物体位置特定タスク(object localization)などの学習なんかに使うためのもののようで、「与えられた画像サイズ(shape)」に対し「指定した矩形領域(bounding boxes)」の領域を一定以上の割合で含む新しい領域をランダムで決定し、それを切り取るための情報を出力してくれる。

つまり[height, width, channels][112, 112, 3]の画像に対し [y_min, x_min, y_max, x_max][8.0/112.0, 8.0/112.0, 104.0/112.0, 104.0/112.0]顔部分があるはずの矩形領域を指定することで、その領域を必ず指定した割合以上含むランダムな領域を得られる。 割合は1.0を指定すれば必ずその指定した矩形以上の大きさの領域が得られるが、それでは全体的に大きく切り取り過ぎてしまうためtf.random_cropのときに最も外れて切り取られた場合と同等の(80.0*80.0)/(96.0*96.0)の値を指定することにした。

縦横比もレンジを指定して制限することができ、デフォルトでは3:4-4:3となっているが 今回の顔画像データセットにおいては縦横の歪みは殆ど無いはずなので9:10-10:9と指定した。 最終的にはこうして得られたランダム領域で切り取った僅かに歪んだ画像を96x96にresizeして学習時の入力とする。

    bounding_boxes = tf.div(tf.constant([[[8, 8, 104, 104]]], dtype=tf.float32), 112.0)
    begin, size, _ = tf.image.sample_distorted_bounding_box(
        tf.shape(image), bounding_boxes,
        min_object_covered=(80.0*80.0)/(96.0*96.0),
        aspect_ratio_range=[9.0/10.0, 10.0/9.0])
    image = tf.slice(image, begin, size)
    image = tf.image.resize_images(image, [96, 96])

例として

f:id:sugyan:20170221211750p:plain

こんな112x112の画像を使ってdistortionをかけてみる。濃い色の部分が顔部分が入っているべき中央の96x96の領域となる。

従来のtf.random_cropを使った場合は

f:id:sugyan:20170221211757j:plain

という感じで、tf.image.sample_distorted_bounding_boxを使った場合は

f:id:sugyan:20170221211803j:plain

という感じになる。

同じ縮尺で場所だけ変えて切り取る従来のものより、様々なサイズのブレを吸収して学習できそうに思える。

結果

従来のtf.random_cropを適用させていた部分をtf.image.sample_distorted_bounding_boxを適用するよう書き換えて 同様にそれぞれ3パターンのデータセットで学習させ、評価データにおける正答率の変化を調べた。 結果は…

f:id:sugyan:20170221205739p:plain

んん…?

3パターンそれぞれの平均値を取って元のものと比較して…

f:id:sugyan:20170221205747p:plain

!!!特に大きな変化なし!!!

むしろ92〜93%くらいに到達するまでが遅くなってしまっている(計算時間は大きな違いは無さそうだった)。

うーん、distortionの方式はあんまり関係なかったか… もうちょっと明らかな改善になることを期待していたのだけど…

誤答の詳細を調べる

では93%程度の精度が出ている場合、残りの7%はどんな入力に対して誤答をしてしまっているの?

あるデータセットで誤答した画像を幾つか抽出してみた。

f:id:sugyan:20170222014440j:plain

簡単な傾向としては、

  • 眼鏡や手指などで隠れてしまっている部分があるもの
  • あまり正面向きではないもの、傾いてしまっているもの
  • 目を瞑っているもの

なんかはやはり間違いやすいのかな…。

実験: 正答評価方法の変更

前述のように顔検出器の精度がそれほど良くないために単純に変な大きさで切り取られているっぽいものも幾つかあり、それらは評価の微調整で解決できそうな気もした。

評価時はデータセット112x112の画像からtf.image.resize_image_with_crop_or_padを使って中央部分96x96を切り取って分類器への入力としていたけど、同時に88x88, 104x104の、「一回り小さく切り取ったもの」「一回り大きく切り取ったもの」も用意し、それぞれ96x96にリサイズして3種類の画像を分類器に入力する。 その3種類の入力に対する出力で「最大の値を得たもの」を分類結果として使うとどうだろうか。

image = tf.image.decode_jpeg(...)
image1 = tf.image.resize_image_with_crop_or_pad(image, 88, 88)
image1 = tf.image.resize_images(image1, [96, 96])
image2 = tf.image.resize_image_with_crop_or_pad(image, 96, 96)
image2 = tf.image.resize_images(image2, [96, 96]) # これはリサイズする必要ないのだけど
image3 = tf.image.resize_image_with_crop_or_pad(image, 104, 104)
image3 = tf.image.resize_images(image3, [96, 96])
inputs = tf.stack([
    tf.image.per_image_standardization(image1),
    tf.image.per_image_standardization(image2),
    tf.image.per_image_standardization(image3)
])
logits = tf.nn.softmax(model.inference(inputs))
values, indices = tf.nn.top_k(logits)

という形で3つの画像をまとめて分類器に入力し、その結果のtf.nn.top_kを取ると それぞれの分類結果の値とインデックスが得られる。 通常はこのindicesの真ん中のもの、すなわち96x96を入力した場合の出力のインデックスが分類結果として 正解ラベルと合っているか否か、を見るのだけど、 もしかすると他のサイズで切り取ったものの方が 違う結果としてより高いスコアを出しているかもしれない。

_, top_value_indices = tf.nn.top_k(tf.transpose(values))
answer = tf.gather(indices, tf.squeeze(top_value_indices))

ということでtf.nn.top_kから得られたvaluesを転置させて最大の値を出力しているのが何番目かを求め、indicesからそれを抽出する。 これを分類結果のラベルとして使って正解と照らし合わせる。 勿論96x96のもので最大の出力が得られていればそのまま使われるし、それが微妙な値で誤答していても他のサイズで高い値の正解を導く可能性がある。

という形で評価してみたところ、あるデータセットで学習したモデルで評価用データに対し

4527/4800 (94.312 %)

から

4573/4800 (95.271 %)

と、正解率が僅かに上昇した。さらに細かく92x92100x100も加えて5種類に入力を増やすと4584/4800 (95.500 %)に。

…しかしまぁ確かにある程度は正解が増えたけど 分類に余計な計算の手間がかかるだけだし、どちらかというと顔検出の精度を上げて出来るだけ同じサイズで学習・評価をできるようにした方が良さそうな気がする。

今のOpenCVを使った簡易顔検出ではまだまだ誤検出も多いし、ここを改善するのが先決かな、と。

まとめ・考察

  • 改めてデータセットを作成し、やはり93〜94%程度の精度であることを確認した
  • 学習データの加工は変えても結局あまり効果は無かった
  • どんな画像に対し誤答するかの傾向を少し調べた
  • 評価データの顔サイズのバラつきは評価方法を工夫することで少しは吸収できる見込み

眼鏡や指などで顔の一部が隠れているものなんかは、もっとデータを増やすことで誤答を減らせそうな気はする。 目を瞑っていたり極度の変顔とか、あまりに普段の表情と違うものはそれでも難しいかもしれないけれど…。

出来るだけ同じサイズの顔画像になるようデータセットを作っていたつもりではあったけど、まだバラつきがあってその影響を受けているらしいことが分かった。 もっと精度を上げるにはより高精度に顔領域を検出してデータセットも改善していく必要がありそうだ。

しかし、誤答例を見てるとまだ「これは普通に正しい大きさで分かりやすく写ってるし 正答してくれても良さそうなのに…」と思うものもある。 もう少し誤答の傾向は調べてみる価値がありそうな気もする。

その他

今回使ったデータセットも公開はしないですが 欲しい方がいらっしゃったら個別に連絡いただければと思います。

TensorFlow 0.12 のEmbedding Visualizationを動かす

=== 追記 2017.06 ===

書いてから時間が経ち 情報が古くなってしまっていましたが、最近の変更に合わせた補足を含む記事を書いていただきました。ありがとうございます!

fuchami.hatenadiary.jp

=== 追記ここまで ===

というTweetをしたところ、結構な反応があったので せっかくなので記事にしておく。

元々自分が知ったのはこの記事からだったのですが。 qiita.com

どうやらTensorFlow 0.12(現時点ではまだRC0)にはTensorBoardに"Embedding Visualization"というのが追加されたそうで。

https://www.tensorflow.org/versions/r0.12/how_tos/embedding_viz/index.html

これに従って可視化したい変数を保存しておけば、tensorboardでそれらを可視化できる、ということで 自分のこれまでに作ってきたアイドル顔画像の分類器とデータセットを使って冒頭のTweetの画面は出来たのだけど、誰もがそういうイイカンジの分類器やデータを持っているわけじゃなくてすぐには触れないかも、と思ったのでデモ用のリポジトリを作っておきました。

github.com

中身

TensorFlowは学習済みのモデルも提供していて、1000クラスの画像分類を行うInception-v3なんかを簡単に動かして試せるようになっている。

https://www.tensorflow.org/versions/master/tutorials/image_recognition/index.html#usage-with-python-api

ので、今回はそれを使ってみることにした。

tensorflow.models.image.imagenetclassify_image.pyというのが入っていて、これを使うと学習済みのモデルをダウンロードして使えるようになる。

from tensorflow.models.image.imagenet import classify_image

classify_image.maybe_download_and_extract()
classify_image.create_graph()

with tf.Session() as sess:
    sess.run(...)

便利!

で、そのclassify_image.pyに以下のようなコメントが書いてある。

    # Some useful tensors:
    # 'softmax:0': A tensor containing the normalized prediction across
    #   1000 labels.
    # 'pool_3:0': A tensor containing the next-to-last layer containing 2048
    #   float description of the image.
    # 'DecodeJpeg/contents:0': A tensor containing a string providing JPEG
    #   encoding of the image.
    # Runs the softmax tensor by feeding the image_data as input to the graph.

つまり'DecodeJpeg/contents:0'に入力のJPEGバイナリを入れて、'pool_3:0'を取り出せばその画像の特徴成分が抽出できるはず、と。 普通にImageNetの画像を入れて分類結果を見ても面白くないので、なんか違った画像を入力して特徴を可視化してみよう。

というところで自分のスマホを漁ってみたら、何故かうどんの写真がいっぱい出てきたので 今回のデモにはそれらを同梱しておきました。(ラーメンも1枚混ざってる)

f:id:sugyan:20161205015626p:plain

で、これらを入力してsess.run(pool_3)みたいに実行するとその画像に対する2048個の中間層出力が得られるので、これらを集めることで可視化対象の2D Tensorを得られる。 これを変数に入れてtf.train.Saverで保存すれば良いだけ、らしい。

embedding_var = tf.Variable(tf.pack([tf.squeeze(x) for x in outputs], axis=0), trainable=False, name='pool3')
sess.run(tf.variables_initializer([embedding_var]))
saver = tf.train.Saver([embedding_var])
saver.save(sess, <checkpoint path>)

これで点は表示されるけど、ラベルとか画像は別のファイルに書き出して関連付ける必要があるらしい。

config = projector.ProjectorConfig()
embedding = config.embeddings.add()
embedding.tensor_name = embedding_var.name
summary_writer = tf.train.SummaryWriter(<checkpoint dir>)

...

embedding.metadata_path = metadata_path
embedding.sprite.image_path = image_path
embedding.sprite.single_image_dim.extend([100, 100])
projector.visualize_embeddings(summary_writer, config)

ラベルについてはTSVとのことで、今回はファイル名だけを付けておいた。TensorBoard上でクリックすると表示されるはず。

f:id:sugyan:20161205021832p:plain

サムネ画像については、順番に並べたsprite画像を用意する必要がある、とのことで今回のように入力の元画像が全部分かっていればあらかじめ作っておくこともできるけど、 いちおうtf.concatとかで画像を表現するtensorを繋げて作ったりもできる。

デモ用画像は300x300で用意したけど、リサイズして100x100を繋げたものを生成するようにした。

thumbnail = tf.cast(tf.image.resize_images(tf.image.decode_jpeg(jpeg_data), [100, 100]), tf.uint8)

...

size = int(math.sqrt(len(images))) + 1
while len(images) < size * size:
    images.append(np.zeros((100, 100, 3), dtype=np.uint8))
rows = []
for i in range(size):
    rows.append(tf.concat(1, images[i*size:(i+1)*size]))
jpeg = tf.image.encode_jpeg(tf.concat(0, rows))
with open(image_path, 'wb') as f:
    f.write(sess.run(jpeg))

これで、以下のような画像が生成されるはず。

f:id:sugyan:20161205021851j:plain

で、めでたくデータが生成されて保存されればtensorboardで動かせる。 10数点しかないと寂しい感じになってよく分からないけど、雰囲気くらいは味わえるのでは…

あんまり特徴から分布を上手く作れている気もしないけど、T-SNEで次元削除した場合は釜揚げうどんが近いものになっているのは確認できた。

f:id:sugyan:20161205021838p:plain

注意点

Qiitaの参照記事にも書いてあるけど、0.12rc0ではまだちょっとバグもあるようで たぶんpython3だと上手く動かなくて、lib/python3.5/site-packages/tensorflow/tensorboard/plugins/projector/plugin.pyとかを書き換えてやる必要がありそう。

既に報告されていて修正も進んでいるようなので次のリリースでは直っていると思うけども…

アイドル顔識別のLINE BOTを作りました

f:id:sugyan:20161128123639p:plain:w260 f:id:sugyan:20161128123706p:plain:w260

記事要約

機械学習によるアイドル顔識別のLINE BOTを作りました。アイドル分かる人は是非 友だち追加して アイドル画像を送りつけて遊んだりしてみてください。

むしろ識別対象のアイドルさん御本人にも使ってもらえたら最高

友だち追加

BOT概要

TensorFlowによるアイドル顔識別器の話 - 2016.9.28 TensorFlow勉強会 - Qiita

にまとめている、自作のアイドル顔識別器の 「入力した画像に対し、写っているのが『どのアイドル(人物)か』を機械学習により自動判定する」 という機能を実際に試すためのインタフェース。

トーク(1:1、Room, Group、いずれでも可)で画像を投稿されると、その画像に写っている人物の顔を識別してCarouselメッセージで結果を返す。

11月末現在の時点で、識別対象のアイドルは851人となっています。

顔識別API

元々 実際に学習結果を試すため画像を入力して結果を出力するインタフェースは TensorFlowによるディープラーニングで、アイドルの顔を識別する - すぎゃーんメモ あたりのときから作っていて、TensorFlowで学習したパラメータをtf.train.Saverでcheckpointファイルとして出力しておき、それを使って復元したモデルで入力画像に対する識別結果を返すだけのJSON APIFlaskで用意して使っている。

学習をするわけでなく結果を使って計算するだけならHerokuでもこれくらいは動かせる。

tf-face-recognizer/web.py at master · sugyan/tf-face-recognizer · GitHub

Twitter

なのでBOTを作る場合はそのAPIに繋ぎ込むだけでよくて、まずはMentionに反応するTwitter BOTで作ってちょいちょい動かしてみていたのだけど、

  • 画像が4つまでしか添付できない
  • 140文字までしかテキストを入力できない

の制限のため、1画像から5つ以上の顔が識別されると切り抜いても投稿できないし、そもそも識別結果の人物名・グループ名を入力すると3〜4人で140文字に達してしまうなどの問題があった。

あとどうしてもpublicなtimelineに流れることになってしまい(まぁDMに対応すれば良かったのかもしれないけど)、間違った結果などが投稿されて御本人のエゴサ結果を汚してしまう、などの問題もあり。

LINE版

LINE Messaging API を使った Template Message では、Carousel を使うことで1メッセージで5件まで結果を投稿できるし、1つずつ別個にテキスト情報を付与できるので表示がより分かりやすくなる。 ついでにその人物のプロフィールページ(今は取得元のTwitterプロフィールページにしてる)があれば URI action で開いたりもできるし。

実装

今回もGo SDKを使って作っている。動かす場所はどこでも良かったけど とりあえずHerokuで。

顔識別応答に必要な機能は

  • Image Message を受け取ったら、その画像データを取得し、
  • 自作の顔識別APIにその画像をそのまま投げ、
  • 結果のJSONをparseし、
  • それぞれの検出された顔・識別結果を Carousel Column にセットし、
  • 出来上がった Template Message を Messaging API に投げる

というだけではあるのだけど、Twitter BOTの場合とは画像の投稿まわりが異なるのでちょっと考えないといけない。

TwitterUploading Media API があって、そこにまずPOSTして そのmedia_idをリクエストに載せれば画像投稿が出来るのだけど、LINE Messaging API の場合は画像系の投稿は「画像URL」をリクエストに載せることになるので、その画像のホスティングは自分でどうにかしないといけない。

今回の顔識別BOTの場合は、検出された顔領域を切り抜いたものをthumbnailImageUrlとして Carousel Column に載せたいけど、これらを静的配信するだけのためにS3 bucketとか作って管理したくないなぁ、ということで 動的に生成しつつキャッシュでどうにかする戦略で作った。

検出された領域の抽出

顔識別APIでは、入力した画像に対し検出された顔の位置座標と傾き角度を返す(これは Cloud Vision API のレスポンスをそのまま使っている)ので、「どの部分に写っている顔がどう識別されたか」を示すために、検出された領域をそれぞれ切り抜きたい。せっかく傾きも判っているのならばそれも補正して真っ直ぐにした状態で。

以前の記事 で、 ImageMagick-distort SRTオプションでの変換を使った切り抜き方は把握していたけど、せっかくだから今回はPure Goだけでもやってみよう ということで 画像のScale, Rotate, Translateが可能なライブラリを探して、github.com/disintegration/giftが良さそうだったのでそれを使ってみた。(他にも良いのがあれば教えていただきたいところ…)

giftの画像変換はちょっとクセがあって、回転の中心を指定したり座標の移動などはできなくて しかも回転後の画像がすべて収まるよう画像サイズが再計算されるので、

  • まず傾き角度に合わせて画像全体をRotateし、
  • 元の画像で検出されていた顔領域座標を回転後の画像の座標に変換し、
  • その回転後の座標で領域部分をCropする

という感じに。回転行列とか使って座標をゴニョゴニョと変換する必要があった。

f:id:sugyan:20161126204211p:plain

抽出画像のキャッシュ

ともかくこうして抽出した顔画像を返すURLを生成してthumbnailImageUrlに渡すことでCarousel TemplateのMessageを送れるのだけど、このURLはMessageを受け取ったユーザがLINEで開くたびに取得しようとGETリクエストを送ってくることになる。

S3とかにアップロードして配信していればあまり問題ないけど、今回それを使わずBOTのWebアプリケーションから配信するようにするために、JPEGバイナリをRedisでキャッシュすることにした。thumbnailの画像は無駄に大きい必要もないので302x200サイズに加工して(縦横1:1.51という比は固定のようで正方形で返すと上下が切り取られてしまうので左右を埋めて作った)、そうすると10KBにも満たない容量だしheroku-redishobby-devプラン(Memory 25MB)でも余裕で1000枚分くらいはキャッシュできるはず。

で、普通に考えると一番最初に Template Message をPOSTした瞬間が一番アクセスされることになる。特にRoomやGroupで複数人が居る場合は、それぞれのユーザがトーク画面を開いていると投稿した瞬間に同時に複数クライアントからGETリクエストが来るはず。なので、送信するthumbnailImageUrlの各画像JPEGバイナリは事前に生成してから投稿する。

そして、大抵のMessageは流れていくのでそれほど長期間保存しておく必要もないと思っていて、数時間〜数日保持してあれば問題なく。ただ遡ったときにまったく見えなくなっていると困るのでキャッシュが切れていたときは再び元画像を取得しなおして作れるように「元画像のmessage IDと、抽出すべき領域の情報」を復元できるURLにしておく。(任意のmessage IDを指定して画像取得されても困るので それは防ぐためにmessage IDは暗号化した文字列を使うようにしている)

Get content API での取得も、一定期間が経過すると削除されてしまって復元不可能になるけど、まぁそれくらい過去のものは諦めてください、ってことで。

これで、重い処理は極力避けつつも単一のアプリだけで画像の提供もできるBOTアプリを動かすことができた。

別機能 (推論フィードバック)

元々、自分で学習データを管理する用として Messaging APIを使ったLINE Botでアイドル顔画像管理 - すぎゃーんメモ という記事でも書いたとおり そういうBOTを作っていて、これはこれで独立した機能として上述の識別BOTに同居させられるな、と思い。

少し改良して

  • 1:1トークで 識別対象アイドルの名前(の一部でもOK)をテキスト入力すると、分類器によってその人物と推論された顔画像を返す
  • それらに対して「○」か「×」だけを選択できるようにして Postback 送信

というのを、自分だけでなく BOTを友だち追加した人が誰でもフィードバックを送ることができるようにした。これはRoomやGroupでは誰が送ったものか判別できないので1:1トークのみ対応の機能となる。

f:id:sugyan:20161127203707p:plain:w260

対象はあくまでもこちらの管理アプリが持っているデータに対してのみで、識別対象に入っていない限りは推論結果としても現れないのだけど、それでもフィードバックを送ってもらうことで学習データの増加と改善に役立つので、もし知っているアイドルが識別対象に入っているようであれば試していただいて、フィードバックを送っていただきたいです。

少しでも学習データの増加に繋がると嬉しい…。

Repository

余談

今回のBOTを作るにあたり、GoをずっとEmacsで書いていたのだけど、突如思い立って開発環境をAtomに乗り換えてみた。今この記事の下書きもAtomMarkdown Preview しながら書いている。 atomic-emacs の他、Goの開発用には go-plus を入れて、あとは適度に自分好みにkeymapを弄ったり。

ちょっとKarabinerにキーを奪われて上手く設定できなかったり Emacsと比較して dired-mode みたいなのが無くてファイル操作がしにくいな、とか 多少の不満はあるけれど、以外と馴染んで普通に使えるような気はしている。 他の言語(Java以外)での開発もこれでやっていくよう練習してみるつもり。

Messaging APIを使ったLINE Botでアイドル顔画像管理

先日の LINE DEVELOPER DAY 2016 で発表された Messaging API

LINE Developers - Messaging API - Overview

Template Messageという、Botインタラクティブなやりとりができるタイプのメッセージがあり、

https://devdocs.line.me/ja/#template-message

LINE 6.7.0以降の対応ということで iOS版はなかなか使えなかったけど、昨日ついにそれがリリースされ 自分のiPhoneでも使えるようになったので、それを使った自分用Botを作ってみた。

www.youtube.com

Template Message とは

ドキュメントに書いてある通りだけど、"Buttons", "Confirm", "Carousel"という3つのテンプレートが用意されていて、そこに画像URLやActionを指定することで 普通のテキストや画像のみのメッセージとは違う、ちょっとリッチなメッセージを送ることが出来る。

"Confirm"は確認のalertダイアログのようなもの、"Buttons"は画像とテキストを組み合わせたものを表示し、その下に複数のActionボタンを配置できる。"Carousel"はその"Buttons"と同等のものをさらに横に連ねてスクロールで操作できる。

ボタン押下の際のActionには"Postback", "Message", "URI"の3つがあり。"URI"は指定したリンクを開くもの、"Message"は指定されたテキストを発言するもの、そして"Postback"はBot用のPostback Eventを送信するだけ(同時にテキストを送信することもできるけど)、というもの。

入力インタフェースの変化

どういうことかと言うと、自分で何かメッセージを送らなくても その「ボタンを押す」という操作だけでBotに任意の情報を通知できる、ということ。

従来のChat botって、大抵はテキストのメッセージを「コマンド」として利用して、例えばユーザからある文字列を入力された場合だけ(Bot側でそれを正規表現とか使ったif文やswitch文で判別して)指定された動作を行う、という感じだったと思うのだけど そういうのをPostbackを使うとイベント通知で置き換えることが出来るようになるわけで。

ユーザが「(テキストなどの)入力を行う」のではなく「ボタンを押す」という操作だけでBotに対して命令や情報を送ることが出来るようになる、というのがとても便利で面白い。Botとのやり取りをするインタフェースが大きく変わるぞ〜 と思った。

アンケートを取る、とか ユーザに何か選択させるようなものは格段にやりやすくなったんじゃないでしょうか(選択肢が多い場合は厳しいだろうけど)。

個人ツールへの応用

memo.sugyan.com

とか

qiita.com

とか でも書いているけど、いま個人の趣味でやっているアイドル顔画像のデータ収集では、手動でラベル付けするのがあまりにも大変なので、最近はラベル付け済みのデータを使って一通りモデルを学習させて そのモデルに未知の顔画像を推論してもらってそれを確認する、という作業を続けていて。

そのへんもずっと自作のWebアプリを使ってぽちぽちとやっていたのだけど、スマホからだと微妙に操作しづらく、もうちょっと見易く簡単に出来ないかなぁと思っていたところに ちょうど良くLINE Botのインタフェースが使えそうだったので、作ってみた次第。Web操作を代替するための、( 基本的に)自分だけがやり取りするBot

既存のWebアプリにJSON APIとtoken認証の仕組みを加えて、あとはBot用のWebアプリをherokuで動かして繋ぎ込むだけで出来た。

  • 推論結果を取得し、Carouselで同時に5つ表示
    • 情報源となっているURIを開くButton
    • 推論が合っている場合の承認Button
  • 承認ButtonからPostbackを受け取り更新処理
    • 更新結果をConfirmで通知
      • 更新の確認できるURIを開くButton
      • 間違っていた場合の取り消し処理(実装していないので何もしない)

という感じ。

冒頭のデモ動画の通り、起動のトリガーとして任意のテキストメッセージを受け取ったら作動、にしているけど(これは定時Pushとかにしても良いはず) それ以降は自分でメッセージを送信したりすることなく、ボタン操作のみで確認、更新などの作業ができている。 Chat logとして時系列で操作記録も残るし便利!

https://github.com/sugyan/face-manager-linebot

Bot SDK

公式からSDKも提供されているので簡単に作れました。

今回のは諸事情でGoで作ったけど、本日Python版もリリースされたので、例えばTensorFlowで機械学習させたものを動かしつつLINE Botに組み込んで インタラクティブに分類や生成を試したり、とかも出来そうで 夢が拡がりますね!

TensorFlowによるDCGANでアイドルの顔画像生成 その後の実験など

memo.sugyan.com

の続編。

あれから色々な変更しつつ実験してみたりしたのでその記録。 結論を先に書くと、これくらい改善した。

f:id:sugyan:20161012032542j:plain

DCGAN ざっくりおさらい

  • Generator: 乱数の入力から画像を生成する
  • Discriminator: 入力した画像がGeneratorが生成したものか学習データのものかを判別する

という2種類のネットワークを用意し、お互いを騙す・見破るように学習を行うことで Generatorが学習データそっくりの画像を生成できるようになる、というもの

学習用画像の増加

前回の記事では90人の顔画像データから生成していたけど、あれから収集を続けて もう少し多く集まったので、今回は260人から集めた顔画像100点ずつ、計26,000件を学習に使用した。

Feature matching

openai.com

の記事で紹介されている "Improved Techniques for Training GANs" という論文を読んで、使われたコードも読んでみまして、正直何やっているのか分からない部分が多く理解できていないことだらけなのだけど その中の "3.1 Feature matching" のところは分かりやすく効きそうだったので取り入れてみた。

原理としては、「Discriminatorの中間層出力には分類のための特徴(feature)が含まれるはずなので、それがGeneratorによるものと学習データ由来のものとで似たようなものになっていれば(学習データに近いものがGeneratorから生成されている、ということになるので)より良いはず」ということのようだ。

なので、Discriminatorの最終出力(入力画像が学習データのものか否かを判定するもの)の1つ前の、4回の畳み込みを行った段階での出力をそれぞれ(Generator由来の画像を入力した場合/学習データの画像を入力したとき)で取得し、各mini batchごとの平均値の差分が少なくなるよう 適当な倍率を掛けてGeneratorのloss値として加えた。

    def build(self, input_images,
              learning_rate=0.0002, beta1=0.5, feature_matching=0.0):
        """build model, generate losses, train op"""
        generated_images = self.g(self.z)[-1]
        outputs_from_g = self.d(generated_images)
        outputs_from_i = self.d(input_images)
        logits_from_g = outputs_from_g[-1]
        logits_from_i = outputs_from_i[-1]
        if feature_matching > 0.0:
), feature_matching))
            features_from_g = tf.reduce_mean(outputs_from_g[-2], reduction_indices=(0))
            features_from_i = tf.reduce_mean(outputs_from_i[-2], reduction_indices=(0))
            tf.add_to_collection('g_losses', tf.mul(tf.nn.l2_loss(features_from_g - features_from_i), feature_matching))
        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))))

後述するけれど、従来の方法だと 学習を続けていくと生成画像が全体的に白っぽく薄くなる、という現象があって、おそらくこれはDiscriminatorが画像を判別する際に全体の色合いなどは注視しないからなのではないかと思われ(以前の記事で実験しているように、人間の感覚とは全然違う特徴抽出しているようだ)、 それを防ぐためにも このfeature matchingと同様のものを最終出力の画像にも適用してみた。

         logits_from_g = outputs_from_g[-1]
         logits_from_i = outputs_from_i[-1]
         if feature_matching > 0.0:
+            mean_image_from_g = tf.reduce_mean(generated_images, reduction_indices=(0))
+            mean_image_from_i = tf.reduce_mean(input_images, reduction_indices=(0))
+            tf.add_to_collection('g_losses', tf.mul(tf.nn.l2_loss(mean_image_from_g - mean_image_from_i), feature_matching))
             features_from_g = tf.reduce_mean(outputs_from_g[-2], reduction_indices=(0))
             features_from_i = tf.reduce_mean(outputs_from_i[-2], reduction_indices=(0))
             tf.add_to_collection('g_losses', tf.mul(tf.nn.l2_loss(features_from_g - features_from_i), feature_matching))

比較結果が以下の動画。左側が従来の普通のDCGAN、右側がfeature matchingを加えたもの。


DCGAN with feature matching

左側は全体的にずっとガチャガチャと目まぐるしく変化していて落ち着かない感じなのが、右側は比較的早い段階から顔っぽいものが出来てゆるやかに安定していくように変化している様子が伺える。また左は14,000stepくらいから全体的に白っぽく薄くなっていっているのが 右側では起こらなくなっているのも確認できる。

Learning rate, Batch size

しかし上記の方法でもどうにも限界があるようで ある程度まではキレイに顔っぽいものが生成するようになっても、まだまだ崩れたものになってしまう場合も多い。 変化を観察していると10,000stepくらいでそれなりのクオリティになって、そこからは30,000stepくらいまで続けてもあまり変化が見られない、という感じだった。

どうにかもっと良い画像が生成されるように改善されないか、とlearning_rateをデフォルトより小さめにしてみたり、batch_size128よりもっと大きくしてみたりもしたけど、結局どれもそれほど効果は無さそうだった。

Discriminatorの出力を見る

とはいえ Generatorは無限の乱数入力から無限のパターンを生成するわけで、すべてがキレイな顔画像になるわけがない というのは当たり前といえば当たり前。ならば複数生成されるものから上手くいったものだけ自動で抽出できれば良いのでは?

ということで学習済みのGeneratorとDiscriminatorを使って、mini batchで生成される複数の画像をDiscriminatorに通した結果のsoftmax値の高い順に表示してみた。

    # 乱数mini batchから画像を生成する
    images = sess.run(dcgan.g(dcgan.z)[-1])
    # discriminatorの出力にsoftmaxかけたものを反転してtop_kを抽出
    # `0`を高く出力したもの `1`を高く出力したもの 上位10件ずつの値とindexが取れる
    values, indices = tf.nn.top_k(tf.transpose(tf.nn.softmax(dcgan.d(images)[-1])), 10)
    for x in sess.run([values, indices]):
        print(x.tolist())
    # top_kで得たindicesを使って生成画像から抽出し、縦横に連結
    rows = []
    for cols in tf.split(0, 2, tf.gather(images, indices)):
        rows.append(tf.concat(3, tf.split(1, 10, cols)))
    result = tf.squeeze(tf.concat(2, rows), [0, 1])
    # 余計な次元を削減してjpeg画像に変換して出力
    img = tf.image.encode_jpeg(tf.image.convert_image_dtype((result + 1.0) / 2.0, tf.uint8))

    filename = os.path.join(FLAGS.images_dir, 'out.jpg')
    with open(filename, 'wb') as f:
        print('write to %s' % filename)
        f.write(sess.run(img))

f:id:sugyan:20161012011133j:plain

上段が、Discriminatorのsoftmax出力が0で高かったもの上位。自分のDCGAN実装ではこれはDiscriminatorがGeneratorによる画像だと判定したもの。下段が、softmax出力が1で高かったもの上位 すなわち学習データと判定されたもの(うまく騙せたもの)、となる。

うーん、確かに下段のものの方がキレイに出来ているものが多いような気もするけど、別に全部が良いわけでもないし 上段にもそれなりのものが出てきてたりするし… これも以前の記事で確認した通り、モデルが判別する特徴は人間の感覚と全然ちがうからあまり当てにはならない、のかも知れない…。

Web UIで入出力を調べる

ならば入力の値を弄ってどうにかすることはできないか、と思ったのだけど Generatorはブラックボックスすぎて「どんな値を入力すると どんな画像が生成されるか」が直感的にはまったく分からない。

ので、入力値を色々変えて実験できるよう こんなWeb UIを作ってみた。

f:id:sugyan:20161012012411p:plain

入力乱数を16次元の数値として、それらは実際には小数値なのだけど分かりやすいように0-255の整数値に置き換えてスライダーなどで操作できるように。それらの値に応じてAPI経由でその入力値からGeneratorによる顔画像生成を行い結果を描画。そのときの入力値を32文字のhex stringで表現して再現に使えるようにする。

というもの。Reactとか勉強しながらMaterial-UIで作ってみた。

入力値を操作する

このUIで色々とランダムな入力で試してみると、例えばすごく崩れる顔がどんな入力から生まれるのかが把握できる。

82763953b2740fef4d321dde7af002f7 f:id:sugyan:20161012014750p:plain

75cd0382329c4a341e296530c615b674 f:id:sugyan:20161012014758p:plain

54795b1ef616f55d2cd32f288a3f41f2 f:id:sugyan:20161012014800p:plain

という感じに。 これらを幾つか抽出して雑に平均を取ってみると

samples = %w(
  82763953b2740fef4d321dde7af002f7
  75cd0382329c4a341e296530c615b674
  54795b1ef616f55d2cd32f288a3f41f2
  b21ce031415c8abb73d8200bc115476c
  b0064a3e5ec757ae09898814edd94264
  2e790129bd66adfc8796201ff947259a
  097c5a73700498603e43ab439a854a83
  2a57676c479b4953d1694c45074229a2
  584bcb88c0609c61161ef62cab740b98
  725f3fe61612a4becf920302e936b022
  618b23b2189ea810998968b7dc60e4b5
  0f254708a300a458f504d97fcba07442
)

lists = samples.map do |hex|
  hex.scan(/.{2}/).map(&:hex)
end
avg = lists.transpose.map do |a|
  a.inject(&:+).to_f / a.size
end
puts avg.map { |e| format('%02x', e) }.join
$ ruby average.rb
5b605461755d86826d6b6348b168598d

というのが得られ、これを入力として使ってみると…

5b605461755d86826d6b6348b168598d f:id:sugyan:20161012015218p:plain

と、およそ顔とも分からないようなすごいのが出力されることが分かる。

逆に、そこそこキレイに上手く生成されたものを集めて平均を取ってみると

samples = %w(
  e7a8fea0affc366aa0fc77911c54201a
  c5c3ede294e988a1e8ebb7941def3297
  a1b3f8d8be647c6775cd94e184bb4f08
  75dcabe39c9f8b7e908ecd88546e2c9d
  a582debdcf74d579b990ce7123a48675
  ede6a4fc6cdab7828677e7dd6a998880
  d6e1a99db03f44a29fc49c9427c70569
  fcd35bd348836cc7a18d92d29367196c
  e789ec78b2cf2ba5e5bd9f87723e913f
  f788ded2733f4eb7e7fa9acc2a4aae26
  b2c5b8d3a6b54bd5e7e1cc90838774cf
  dcd9b5bb87a86ec8c3dbace763a65d5c
  c8f8a9e199e244e0f59ab4db62466783
)

lists = samples.map do |hex|
  hex.scan(/.{2}/).map(&:hex)
end
avg = lists.transpose.map do |a|
  a.inject(&:+).to_f / a.size
end
puts avg.map { |e| format('%02x', e) }.join

cbbfc4c798a26ba1babeaeaf53865766 f:id:sugyan:20161012015859p:plain

と、とても自然なイイカンジの画像が生成されることが確認できる。

この「良い例」と「悪い例」の差分を取って、「悪い入力」から「良い入力」へ向かうベクトルを乱数入力値にオフセットとして加えてやれば、より良い結果が生まれやすいのでは!? ということでやってみた結果がこれ。

f:id:sugyan:20161012030450j:plain

入力値の範囲が狭められたことで ちょっと似たり寄ったりなものが多いような気もしないでもないけど、より高確度で比較的安定した顔画像が得られるようになった。 一応ちゃんとそれぞれ髪型や顔の角度・表情は違っているし、悪くないと思う。

この「入力値にオフセットを加える」手法で、例えば「左向きの顔が出力される入力」「右向きの顔が出力される入力」を調べて差分ベクトルを入力値に加えることで右向きばかりのものが生成されるようになったり、表情や髪・肌の色とか 色んな要素を調節しつつ生成できるようになることが期待できる。(まだそこまでは出来ていない。そういった特徴を抽出するのもなかなか面倒…。)

今後の展望

もうちょっと、入力値の良い取り方などは研究してみたいところ。金髪とかショートとか離れ目とか笑顔とか、様々な成分を指定して自由に自分好みの顔を生成できるようになる、のが目標かな。

あとは複数の顔をモーフィングで遷移するアニメーションとか作ると面白そうだと思っているので、そういうのも生成するUIを作ってみたいと思っている。

そもそもDCGANでの生成がこれが限界なのかどうか。さらに改良する方法や、またDCGANではない生成方法も調べて試してみたいところ。

Repository