はじめての電子工作: HelixPico

HelixPico キーボードキット | 遊舎工房

なんとなく興味は持っていたものの第一歩を踏み込めずにいたのだけど、Twitterに書いたら一気に物事が進んだ。

その日のうちに キーボードキット、はんだごて、ニッパー、などなど必要になりそうなもの一式をポチっと全部注文した。

数日で全部揃って、始められる。

素人なりにゆっくりと進めていったら、なんとか出来上がった

f:id:sugyan:20181017012011j:plain

f:id:sugyan:20181018010002j:plain

f:id:sugyan:20181018010027j:plain

すごい。かわいい。

今までずっとMBPかHHKBしか使ってなかったから左右分離型も格子状配列も初体験で全然慣れないけど、使っているうちにイイカンジになりそうな予感はある。 早速これを使ってこのブログを書いているけど、ようやく慣れて今までの60%くらいの速度で打てるようになったかな、くらいw コード書くと記号とかで戸惑ってさらに遅くなりやすい。まぁ慣れだとは思うけど…

小さくコンパクトなので持ち運びやすそう、っていうところが利点かなー。あとかわいい。やっぱり自分で作った、ってなると愛着はわく。

とにかく思い立ってゼロからスタートして1週間くらいで出来上がるお手軽さは思ってた以上で、もっと早くから手を出してみればよかったw という感想。

せっかく工具類を買ったわけだし もう何種類か組み立ててみるつもり。

学習済みMobileNetV2モデルによる推論をTensorFlow.jsとWebWorkerを使ってブラウザ上で実行

将棋駒画像分類の話の続き。

memo.sugyan.com

学習させたモデルでの分類結果を実際に試すときに Web上でもインタラクティブに出来ると便利そう、と思ってやってみた。

学習済みモデルの変換

まずは学習済みのモデルをTensorFlow.js用に変換する必要がある。ここまではPythonの領域。

普通に tf.train.Saver を使っていると、checkpoint形式でパラメータが保存される。

./logdir
├── checkpoint
├── events.out.tfevents.1537850055.****.local
├── graph.pbtxt
├── model.ckpt-1500.data-00000-of-00001
├── model.ckpt-1500.index
└── model.ckpt-1500.meta

このままではダメなので Frozen Model の形式に変換したいところ。 TensorFlow の freeze_graph を使って

$ freeze_graph --input_checkpoint logdir/model.ckpt-1500 --output_node_names 'MobilenetV2/Logits/output' --input_graph logdir/graph.pbtxt --output_graph output_graph.pb

のようにすれば Frozen Model の形式にはなるのだけど、前回の記事のように学習時に作ったモデルそのままだとダメらしい。

import tensorflow.contrib.slim as slim
from nets.mobilenet import mobilenet_v2


def train():

    ...

    inputs = ...
    class_count = ...
    with slim.arg_scope(mobilenet_v2.training_scope(is_training=True)):
        logits, _ = mobilenet_v2.mobilenet(inputs, num_classes=class_count)

のように training_scope() 上で作られたモデルは dropout や batch normalization などが入った training mode として動作するものになるので、このまま freeze_graph しても挙動は training mode のままになってしまうっぽい。

なので、一度 non-training mode として復元してから freeze するようにする。 freeze_graph コマンドも中では tensorflow.python.framework.graph_util を使って変数を定数に変換しているだけなので、これを利用する。

import tensorflow as tf
from tensorflow.python.framework import graph_util
from nets.mobilenet import mobilenet_v2

FLAGS = tf.app.flags.FLAGS
tf.app.flags.DEFINE_string('checkpoint_path', 'logdir/model.ckpt',
                           '''Path to checkpoint file''')
tf.app.flags.DEFINE_string("output_graph", 'output_graph.pb',
                           """Path to write the frozen 'GraphDef'""")


def main(argv=None):
    class_count = ...
    placeholder = tf.placeholder(tf.float32, shape=(None, 96, 96, 3))
    logits, _ = mobilenet_v2.mobilenet(placeholder, class_count)
    saver = tf.train.Saver()
    with tf.Session() as sess:
        saver.restore(sess, FLAGS.checkpoint_path)
        output = graph_util.convert_variables_to_constants(
            sess, tf.get_default_graph().as_graph_def(), ['MobilenetV2/Logits/output'])
    with open(FLAGS.output_graph, 'wb') as f:
        f.write(output.SerializeToString())


if __name__ == '__main__':
    tf.app.run(main)

training_scope() をつけずに呼ぶことで(もしくは明示的に training_scope(is_training=False) のscopeにしてもよい) 、 non-training mode でのモデルが出来る。 そこから tr.train.Saver で restore した上で graph_util.convert_variables_to_constants() を呼んで、serializeすれば目的の Frozen Model が出来上がる。

label情報も含める

上記の Frozen Model で一応「入力画像に対する推論結果」を得られるが、その出力は単に計算結果の logits としての数値列で、「最も数値の高かったindexは どのlabelに対応するか」という情報が無い。 別ファイルで保存してあればそれを読んで照らし合わせてやればいいけど、ここから変換してJavaScriptの世界で使おうとするところでそれは面倒。 label情報も Frozen Model に含めてやりたい。

labels.txt にlabel名が羅列してあるとしたら、それを "," 区切りで繋げた文字列とかを定数として定義して freeze 時に加えてやれば良い。

def main(argv=None):
    with tf.gfile.Open(FLAGS.labels) as f:
        labels = [line.strip() for line in f.readlines()]
    labels_str = tf.constant(list(','.join(labels).encode()), dtype=tf.int32, name='labels')

    placeholder = tf.placeholder(tf.float32, shape=(None, 96, 96, 3))
    logits, _ = mobilenet_v2.mobilenet(placeholder, len(labels))
    saver = tf.train.Saver()
    with tf.Session() as sess:
        saver.restore(sess, FLAGS.checkpoint_path)
        output = graph_util.convert_variables_to_constants(
            sess, tf.get_default_graph().as_graph_def(), ['MobilenetV2/Logits/output', 'labels'])
    with open(FLAGS.output_graph, 'wb') as f:
        f.write(output.SerializeToString())

tf.string の型にしてやれば良いだけかと思ったけど、次のconvert時に tf.string はsupportされていない、とエラーになってしまうので、仕方ないのでバイト列に変換したものを tf.int32 の1階Tensorとして無理矢理使う。 graph_util.convert_variables_to_constants() の第3引数 output_node_names は複数を指定できるので、 'MobilenetV2/Logits/output''labels' と2つ指定するよう変更。

Web format に変換

ここまで出来れば、あとは tensorflowjs_converter で変換するだけ。

$ pip install tensorflowjs
$ mkdir js
$ tensorflowjs_converter --input_format tf_frozen_model --output_node_names 'MobilenetV2/Logits/output,labels' output_graph.pb ./js
$ ls js
group1-shard1of3
group1-shard2of3
group1-shard3of3
tensorflowjs_model.pb
weights_manifest.json

といった感じで コマンド一発で変換されたファイルが生成される。 あとはこれらを静的に配信するようにしておけば、JavaScriptの世界で利用できるようになる。はず。

TensorFlow.jsで推論

$ npm install @tensorflow/tfjs

必要なのは上記のみ。 前節で生成したファイルを配信しているURLを指定して loadFrozenModel() を呼ぶことでモデルが読み込まれる。

import * as tf from "@tensorflow/tfjs";
import { loadFrozenModel } from "@tensorflow/tfjs-converter";

const MODEL_URL = "****/tensorflowjs_model.pb";
const WEIGHTS_URL = "****/weights_manifest.json";

loadFrozenModel(
    MODEL_URL, WEIGHTS_URL,
).then((model: tf.FrozenModel) => {
    const labelsTensor: tf.Tensor = tf.tidy(() => {
        return model.execute(tf.tensor([], [0, 96, 96, 3]), "labels") as tf.Tensor;
    });
    const labels: string[] = String.fromCharCode(...labelsTensor.dataSync()).split(",");
    labelsTensor.dispose();

    ...
});

計算して出力する tensor の名前を model.execute() の第2引数で指定できるので、まずは適当な空の入力(定数を取り出すだけなので入力は何でも良い) と "labels" を上記のように指定することで、label情報を取り出せる。

tf.Tensor まわりはちょっとクセがあるので注意する。

不要になった tensordispose() で明示的に破棄する、もしくは tf.tidy() の中で処理を書いてGPUが余分なメモリ消費を消費しないよう気をつける必要がある、ということらしい。 計算結果の中身は data()dataSync()TypedArray 形式で得ることができる。

ということで実際に画像データ(ここでは ImageData[])を受け取って推論した softmax 出力の上位3つの結果を得るのは以下のような感じ。 こっちは "MobilenetV2/Logits/output" を出力する tensor として指定。 tf.topk() というAPIもあるし それを使っても良かったかも…

loadFrozenModel(
    MODEL_URL, WEIGHTS_URL,
).then((model: tf.FrozenModel) => {

    ...

    const data: ImageData[] = ...
    const softmax: tf.Tensor = tf.tidy(() => {
        const tensors: tf.Tensor[] = data.map((d: ImageData) => tf.fromPixels(d));
        const inputs: tf.Tensor = tf.stack(tensors).toFloat().div(tf.scalar(255.0));
        const logits: tf.Tensor = model.execute(inputs, "MobilenetV2/Logits/output") as tf.Tensor;
        return tf.softmax(logits);
    });
    const resultData: Float32Array = softmax.dataSync() as Float32Array;
    softmax.dispose();

    // sort and get top-k
    const results = [];
    for (let i: number = 0; i < resultData.length / labels.length; i++) {
        const values: Iscored[] = [];
        resultData.slice(labels.length * i, labels.length * (i + 1)).forEach((score: number, index: number) => {
            values.push({ index, score });
        });
        values.sort((a: Iscored, b: Iscored) => b.score - a.score);
        results.push(values.slice(0, 3).map((value: Iscored) => {
            const label: string = labels[value.index];
            return { score: value.score, label };
        }));
    }

    ...
});

WebWorker経由で結果を得る

で、上記のようなのを実際に画像を入力して試してみると。 TensorFlow.jsはデフォルトで WebGL backend を利用して高速に計算してくれるのだけど、 どうしても初回の呼び出し時には 2000-3000ms ほどかかってしまう。 以下のような理由らしい。

  1. Why is the predict() method for inference so much slower on the first call than the subsequent calls?

The time of first call also includes the compilation time of WebGL shader programs for the model. After the first call the shader programs are cached, which makes the subsequent calls much faster. You can warm up the cache by calling the predict method with an all zero inputs, right after the completion of the model loading.

https://github.com/tensorflow/tfjs-converter/blob/master/README.md#faq

また、これは自分の使い方が悪いのかもしれないけど 1回の入力に使う画像数が 76枚くらいを超えると極端にそれが遅くなる… どっかでメモリ使い過ぎてるのかな。要調査。 [75, 96, 96, 3] くらいまでの入力なら大丈夫だけど それより多くなると 5000-6000ms とか一気に遅くなる。

ともかく、例えば実行ボタンを押してから結果を表示させようとすると 数秒かかってしまうことがあるわけで その間 UIが止まってしまう。それは出来れば避けたい。

というわけで 計算のロジックを Web Workers に移すことを考えた。

worker-loader

今回はサーバサイドとフロントエンドを分離して開発していたので、できれば単一のJSでまとめてしまいたい。 ってことで webpack の worker-loader を利用。 これを使って { inline: true } を指定することで別ファイルでWorker用のJSを用意しなくてもイイカンジにやってくれるようだ。

$ npm install worker-loader

webpack.config.js は以下のような感じ。

module.exports = {
    entry: './src/index.tsx',
    module: {
        rules: [
            {
                test: /\.?worker\.ts$/,
                use: {
                    loader: 'worker-loader',
                    options: { inline: true }
                }
            },
            {
                test: /\.tsx?$/,
                use: 'ts-loader',
                exclude: /node_modules/
            }
        ]
    },
    resolve: {
        extensions: ['.tsx', '.ts', '.js']
    },
    ...
};

ts-loader と併用して使う場合は rules の順番に注意しないといけないようだ。

worker.ts に先程のような処理を以下のように書ける。

import * as tf from "@tensorflow/tfjs";
import { loadFrozenModel } from "@tensorflow/tfjs-converter";

const ctx: Worker = self as any;

...

loadFrozenModel(
    MODEL_URL, WEIGHTS_URL,
).then((model: tf.FrozenModel) => {
    ...

    ctx.addEventListener("message", (message: MessageEvent) => {
        ...

        ctx.postMessage(...);
    });
});

export default null as any;

入力データを addEventListener() で待ち受け、結果を postMessage() で返せば良い。

使う側(UI側)は、この Worker に対して postMessage() で入力画像を投げて addEventListener() で結果を待ち受ければ良い。 とはいえ連続で投げてしまうことも出来てしまうので、投げたものに対する結果を正しく得る必要がある。 単一のWorkerインスタンスを持つSingletonクラスのようなものを使って Promise を返す関数を提供するようにしてみた。

import Worker from "./worker";

export interface IpredictResult {
    score: number;
    label: string;
}

interface Iresponse {
    key: string;
    results: IpredictResult[][];
}

export default class WorkerProxy {
    public static predict(inputs: ImageData[]): Promise<IpredictResult[][]> {
        const worker: Worker = WorkerProxy.getInstance().worker;
        const key: string = Math.random().toString(36).slice(-8);
        return new Promise<IpredictResult[][]>((resolve) => {
            const listener = (ev: MessageEvent) => {
                const data: Iresponse = ev.data;
                if (data.key === key) {
                    resolve(data.results);
                    worker.removeEventListener("message", listener);
                }
            };
            worker.addEventListener("message", listener);
            worker.postMessage({ key, inputs });
        });
    }
    private static instance: WorkerProxy;
    private static getInstance(): WorkerProxy {
        if (!this.instance) {
            WorkerProxy.instance = new WorkerProxy();
        }
        return WorkerProxy.instance;
    }
    private worker: Worker;
    private constructor() {
        this.worker = new Worker();
    }
}

これによって、UI側では複数の画像も非同期にリクエストして結果を得ることができる。

import WorkerProxy, { IpredictResult } from "./worker-proxy";

...

const inputs: ImageData[] = ...
inputs.forEach((data: ImageData, i: number) => {
    WorkerProxy.predict([data]).then((results: IpredictResult[][]) => {
        ...
    });
});     

これで、冒頭のTweetみたいな感じにUIを止めずに バックグラウンドでのWebWorkerによる計算を使って推論結果を順次表示していける。

問題点

…ということで実現できたけど、これ、すごい遅いぞ…? 1枚の画像に対する処理でも 300ms前後かかるし、複数枚をまとめて渡すと線形に処理時間が倍増していく。

なんと、 WebWorker 内でのTensorFlow.jsの計算は、GPUを使ってくれないらしい。 確認したら WebWorker 内では tf.getBackend() の結果が cpu になっていた。

今現在、まさに進行中の話のようで 近いうちに解決してくれるのかもしれない。

warm up と 分割?

と、こういう内容を調べながらこの記事を書いていて思ったけど、結局 webgl backend を使えれば、「ある程度の決まったサイズの入力なら 初回以外は高速に処理できる」ということが分かったので、

  • [10, 96, 96, 3] くらいの入力を受け取るよう固定
    • 1個の入力([1, 96, 96, 3]) に対しては残り [9, 96, 96, 3] のダミーデータを連結して埋める
    • 11個以上の入力は分割して処理する
  • modelのload直後にはやっぱり空の [10, 96, 96, 3] を入力してwarm upして それから使うようにする

というようにすれば、この程度の計算なら もしかしてわざわざWebWorkerを使って非同期に処理しようとしなくても十分に高速に(UIを止めることなく)結果を得ることが出来るのでは… と思ったのでした。

結論

ぜんぜんわかってねぇ

追記

試しに WebWorker を使わずにフォアグラウンドで webgl を使って固定長入力に分割して warm up して処理時間を計測してみた。 入力画像は 81。

input [1 * 96 * 96 * 3] * 81 loop => warm up: 2088ms, execute: 1520ms
input [2 * 96 * 96 * 3] * 41 loop => warm up: 2086ms, execute: 960ms
input [3 * 96 * 96 * 3] * 27 loop => warm up: 2093ms, execute: 761ms
input [4 * 96 * 96 * 3] * 21 loop => warm up: 2135ms, execute: 640ms
input [5 * 96 * 96 * 3] * 17 loop => warm up: 2149ms, execute: 557ms
input [6 * 96 * 96 * 3] * 14 loop => warm up: 2270ms, execute: 527ms
input [7 * 96 * 96 * 3] * 12 loop => warm up: 2299ms, execute: 496ms
input [8 * 96 * 96 * 3] * 11 loop => warm up: 2146ms, execute: 444ms
input [9 * 96 * 96 * 3] *  9 loop => warm up: 2271ms, execute: 394ms
input [10 * 96 * 96 * 3] * 9 loop => warm up: 2204ms, execute: 452ms
input [11 * 96 * 96 * 3] * 8 loop => warm up: 2256ms, execute: 381ms
input [12 * 96 * 96 * 3] * 7 loop => warm up: 2203ms, execute: 399ms
input [14 * 96 * 96 * 3] * 6 loop => warm up: 2373ms, execute: 359ms
input [17 * 96 * 96 * 3] * 5 loop => warm up: 2310ms, execute: 332ms
input [21 * 96 * 96 * 3] * 4 loop => warm up: 2383ms, execute: 328ms
input [27 * 96 * 96 * 3] * 3 loop => warm up: 2410ms, execute: 316ms
input [42 * 96 * 96 * 3] * 2 loop => warm up: 2410ms, execute: 395ms

なるほど適切な数に分割して処理することで 初回実行のWarmUpではどうしても約2秒ほど止まってしまうけど、それ以降はかなり高速に計算が終わる感じにはなる。

ISUCON8 予選敗退した

ISUCON8。 今年は予選日程の両日に奈良で開催のHackathonイベントのお手伝いをする用事が入ってしまい、参加不可能だな〜という雰囲気だったのだけど、 イベントの初日は午後からで最初はチームビルディングとかの時間で出番無いはずだし多少は動けるかも…?ということで 一緒にHackathonサポートで奈良に来ている @kazuki-maさん と そういう事情で片手間参加のチームでも大丈夫と言ってくださった @overlastさん と3人で挑戦した。こちら2人は奈良、1人は東京。

事前に会って話す機会もなく Slackで軽く打ち合わせる程度。共通で得意な言語もなく (Go,Python,TypeScriptのひと / Java,Go,Perlのひと / Python,Perlのひと) とりあえず皆さわったことあるのはPerlか… という感じでPerlに。 前日夜に慌てて自分のPCにperl-5.28をinstallして、その他の準備は一切なしで当日に挑んだ。

当日

午前中は奈良組と東京でLINE通話を繋いで喋りながら作業。 慣れないながらサーバ入って各自環境を整えつつ Webアプリの挙動を見て方針を相談したり。 まずはこのへんから改善していこうか、と進めはじめたのが12:00頃。

まずは / のトップページとか無駄にすごい数のクエリを発行しているところを remains だけ出すのなら各rankのreservationを COUNT で数えればいいだけやろ、ってことで減らすのをやった。 Perlでハッシュリテラルってどうやって書くんだっけ!? { hoge => 'fuga' } か なるほど思い出した! ってなったのが12:20頃。やばい。

13:00になってHackathonイベント始まるので移動。LINE通話は切ってあとはSlackのみでやりとり。 ちょいちょい中断は挟みつつも作業を続ける。

変更してはbenchmarkがfailしハマり 戻したり原因さぐりながら少しずつ修正。

1台nginx+db, 残り2台でapp動かして流すという構成にしよう、と overlastさんにやっていだたいて それらがようやく動いたのが15:00過ぎ。score 3,000ちょいで 28位

events tableで各rankのremainsを管理するようcolumnで持って、ていう変更をkazuki-maさんが終えて 16時くらい?

/, /api/users/***/ がようやく少しまともになって、今度は /api/event/*** あたりがbottleneckになってきているのは見えてきたが、もう既に17時付近、ぜんぜん時間が足りてない。 ここも全sheetsなめずに値を返すようにどうにか変えたい…!と思ったけどなかなか上手く動かず 終了直前にどうにか動いたっぽくて 17:57 無理矢理pushしてbench通ったけど別にスコアは上がらず そのまま4,000弱くらいでフィニッシュ。

感想

サーバ上の作業にしろコード変更にしろ、とにかく作業が遅くて時間が足りなかった、かなぁ

いちいち小さなことで躓いて時間をロスしたり手戻りすることが多かったりしていて、「リモートだったから」「別のイベントもあって片手間だったから」というのを差し引いても、あと3倍は早くコード書き換えていける力が無いとダメだったように思う。

せめて基本的で明らかなbottleneckは潰して 劇的改善のために動いていく、くらいまで辿り着きたかったけど そこにすら届かなかった〜、という感じ。

来年はどうなるか分からないけど、せめてもうちょっと善戦できるよう頑張りたい

Hammerspoonを使ってキーボードショートカットでTwitter LiteにFocusする

Sierra以降、KeyRemapとLauncherを兼ねて Hammerspoon を使っている。

memo.sugyan.com

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')

のようにして + Ctrl + 何か で一発でApplicationのFocusを切り替えて使っていて 今も愛用しているのだけど、 TwitterMac版デスクトップアプリであるTwitter.app は残念ながら先日 終了してしまった。

それで今は Chromeの Desktop PWAで Twitter Lite を起動するようにしている。

これをやはり一発のキー操作で切り替えられるように設定を追加した。

local function launcherPWA(mods, key, windowname)
  hs.hotkey.bind(mods, key, function()
    local chrome = hs.application.get('com.google.Chrome')
    if chrome then
      local target = chrome:findWindow(windowname):focus()
      if target then
        target:focus()
      end
    end
  end)
end
launcherPWA({'cmd', 'ctrl'}, 't', 'Twitter')

これでいつでも一発でTwitter Liteに切り替えられて便利。

斜めに写った画像をCanvasで矩形に補正する

将棋駒画像分類の話の続きのような、あんまり関係もないような。

memo.sugyan.com

memo.sugyan.com

結局、素材を組み合わせて自動で生成しただけの駒画像ではやはりデータが足りていないようで、「やはりもっと様々な画像から人力でラベル付けしてデータセットを作っていく必要がありそう」ということになった。

とはいえ、インターネットから画像を拾ってこようと思うと、例えば以下のような感じで

f:id:sugyan:20180903172533j:plain

f:id:sugyan:20180903172543j:plain

f:id:sugyan:20180903172555j:plain

(引用元: フリー写真素材ぱくたそ)

多少ならともかく 斜めの角度から写っているものは、そのまま矩形に切り出して学習用画像データに利用するのは難しそう。 これらはうまいこと変形して使いたい。 いわゆるperspective projectionの逆変換のような操作が必要になる。

JavaScriptを使ったCanvas APIでの変換では簡単な拡大・縮小などの変換は可能だけど こういったperspectiveなtransformationは無理っぽい。OpenCVなどの画像処理ライブラリを使えば可能そうだけど、できればやっぱりJSでグリグリ動かしながら出来ると嬉しいな、と思って探してみた。

OpenCVEmscriptenを使ってビルドすればJSで利用できるのだけど、

今回みたいなちょっとした変換だけのためにわざわざこれをやるのも過剰だよね…

ということで探し当てたのが、glfx.js

WebGLなどを上手いこと使って様々な画像変換処理をしてくれるライブラリ、のようだ。 perspective というAPIが含まれていて、デモもある。

よーし これを使うぞ!と思ったものの 最終更新が5年前とかで TypeScriptで利用するためのdefinitionsも無い。 仕方ないので見様見真似で自分が使いたいAPIの部分だけ書いてみた。

declare module 'glfx' {
    function canvas(): Canvas;

    export class Canvas extends HTMLCanvasElement {
        draw: (texture: Texture, width?: number, height?: number) => Canvas;
        perspective: (before: number[], after: number[]) => Canvas;
        update: () => Canvas;
        getPixelArray: () => Uint8Array;
        texture: (image: HTMLImageElement) => Texture;
    }
    export class Texture {
    }
}

これで、TypeScriptからでも

import * as fx from "glfx";

...

const canvas: fx.Canvas = fx.canvas();
const texture: fx.Texture = canvas.texture(img);
canvas
    .draw(texture)
    .perspective(src, dst)
    .update()

のような形で Image elementからtextureを読み込み、変形させた結果を描画できる。 getPixelArray()Uint8Array を取り出し new ImageData(new Uint8ClampedArray(data), size, size)ImageData オブジェクトを作ってやれば その内容を別のcanvas内にコピーするようなこともできる。

こうして最初の例の画像は以下のように矩形に補正された盤面画像を得ることが出来るし、

f:id:sugyan:20180903205513p:plain

マウス操作でグリグリ動かしながら変形パラメータを調整することが出来る。 パフォーマンス的にも不満は無い。

f:id:sugyan:20180903210714g:plain

あとは指定した数で分割すればマスごとの画像を取得できるので、

f:id:sugyan:20180903211908p:plain

(↑の右下部分が分割結果)

こうして得た一つ一つのマスの画像にラベルを付けていけばデータセットが作りやすいんじゃないだろうか(まだそこまでは出来ていない)。

実際に動かして試せるデモはこちら

TOKYO IDOL FESTIVAL のタイムテーブル画像化ツール 2018

2年前に作り始めたのがきっかけで、今年もTIFのタイムテーブルが公開されたので作ってみた。

memo.sugyan.com

2016年は画像を生成するだけであとはそれを自分で保存して勝手に使ってくれ、くらいの感じだったけど、

2017年では生成した画像と生成後のURLをTwitterでシェアできる機能を追加し、わりと多くの方々に使っていただけた。 出演者さんがブログで紹介してくれたりしたのも良い思い出。

今年も自分はTIF行かないし 作るモチベーションもあまり無かったけど、多少は需要ありそうだし一応作るか… と

データの取得とUIの細かい部分だけ変更すれば昨年の使い回しでも良かったのだけど、折角だから色々アップデートしておくか、とほぼイチから作り直してみた。

  • Rails 5.1 → 5.2
  • Webpack 3.2 → 4.16
  • Bootstrap 3.3 → 4.1
  • JS → TypeScript化

バックエンドはほぼ変更なしで問題なかったけど、フロントエンドをTS化するのは思った以上に大変だった。 元々の.jsx.tsxにリネームして : any とかつけていけば何とかなるやろ〜 と気軽に始めてみたら全然うまく動かなかったり やっぱりちゃんと型を書かないと気が済まなくなったりして、 結局8割くらい書き直した感じになった気がする。3日ほどかかってしまった。 でもおかげで幾らか?見通しも良くなったし、スッキリした、と思う。

フロントエンドは相変わらず色々と変化はあるけど以前より使いやすくなって整理されてきているとは思うし TypeScriptも大変だけど「ちゃんとcompile通るように書けばちゃんと動く」という感覚はあって嫌いじゃない。 Bootstarp 4もほぼ始めて触ったけど割と融通ききやすくなっていて悪くない印象だった。

将棋駒画像分類をMobileNetV2で最初から学習させる

前回の将棋駒画像分類の話の続き。

memo.sugyan.com

TensorFlow Hubの学習済みモデルを利用して 最終層にあたる部分だけ(?)を再学習させることで簡単に特定ドメインの画像分類のモデルを作成した。 …が、結果としてあまり精度が良くなくて、特に未学習の画像に対してかなりの高確率で誤分類してまっていた。

やはりもっと色々なバリエーションの駒画像を用意して学習させる必要があるか… と思ったが、その前にもう一つ試しておきたかったのでやってみた。

「学習済みモデルは本当にこのドメインの分類に適した特徴を学習できているのか?」

TensorFlow Hub で公開されている学習済みモデルは ILSVRC-2012-CLS 用のデータセットを用いて1000クラス分類のために学習してある。 けど、これが必ずしも自分がやろうとしている将棋駒画像の分類に適した特徴を抽出できるようになっているとは限らない。 もしかしたら、自分の用意したデータに適するよう全層を最初から学習させたら 学習済みモデルをretrainしたものより良くなることもあるのでは…?

というもの。

MobileNet V2

TensorFlow Models のrepositoryを見てみると、MobileNetV2モデルについてのコードや説明が載っている。

V1やV2の違いはよく分かっていないけど、とりあえず読んでみると tensorflow.contrib.slim を使ってモデルの構造が記述されていて、簡単に利用できるようになっているようだ。

from nets.mobilenet import mobilenet_v2

with tf.contrib.slim.arg_scope(mobilenet_v2.training_scope()):
    logits, endpoints = mobilenet_v2.mobilenet(input_tensor)

という形で training_scope() の中で呼ぶことで学習モードとしてモデルを定義できるらしい。 なのでこれに合わせて入力と学習手続きを定義していけば良さそう。

Inputs

とりあえず前回の記事で使った retrain.py に倣って学習用画像データを保存したディレクトリから 一定の割合で "training" と "validation" で別々のデータに分かれるようにしてリストを取得。

def create_image_lists(image_dir, validation_percentage):
    result = collections.OrderedDict()
    sub_dirs = [d for d in tf.gfile.ListDirectory(image_dir) if tf.gfile.IsDirectory(os.path.join(image_dir, d))]
    for sub_dir in sub_dirs:
        file_list = []
        dir_name = os.path.basename(sub_dir)
        file_glob = os.path.join(image_dir, dir_name, '*.jpg')
        file_list.extend(tf.gfile.Glob(file_glob))
        training_images = []
        validation_images = []
        for file_name in file_list:
            base_name = os.path.basename(file_name)
            # https://github.com/tensorflow/hub/blob/master/examples/image_retraining/retrain.py
            hashed = hashlib.sha1(tf.compat.as_bytes(file_name)).hexdigest()
            percentage_hash = int(hashed, 16) % 100
            if percentage_hash < validation_percentage:
                validation_images.append(base_name)
            else:
                training_images.append(base_name)
        result[dir_name] = {
            'training': training_images,
            'validation': validation_images,
        }
    return result

Datasets

取得した画像のリストを使って、入力データを作成する。 今回はせっかくなので tf.data APIを利用してみることにした。

基本的には、 tf.data.Datasetfrom_...Dataset instanceを作り、そこからiteratorを取得、その tf.data.Iterator instanceから get_next() を呼ぶことで入力Tensorを作ることができる、というものらしい。 画像分類のためのデータセットを作る場合は、対応するimages, labelsのセットを用意して使えば良い。 画像のファイルパスから内容を展開するなどの中間処理をする必要がある場合は Dataset.map で処理を書く。

def parser(file_path, label_index):
    image = tf.image.decode_jpeg(tf.read_file(file_path), channels=3)
    image = tf.image.convert_image_dtype(image, tf.float32)
    image = tf.image.resize_images(image, [96, 96])
    return image, tf.to_int64(label_index)

image_files, labels = [...], [...]
dataset = tf.data.Dataset.from_tensor_slices((image_files, labels))
dataset = dataset.map(parser)
dataset = dataset.repeat()
dataset = dataset.batch(FLAGS.batch_size)

iterator = dataset.make_initializable_iterator()
inputs, labels = iterator.get_next()

このようにして入力のbatchを作成できる。

今回は traing用のデータセットと validation用のデータセットを明示的に分けようと思って、 retrain.py と同様に一定の割合で振り分け、それぞれのデータを元に別々の Dataset, Iterator を返すようにした。

中身が違うだけで同shapeの入力を扱う場合には "reinitializable iterator" というのがあって、単一のiteratorから各Dataset用にinitializerを作ってそれを実行することで get_next() で得られる値を切り換える、という仕組みもあるようなのだけど、trainingとvalidationの切り替えを頻繁に行う場合はそのたびにinitializeすることになり、そうなるとshuffleする際に大きめの buffer_size を確保する必要がありそうで、そうすると学習開始時にかなり大きくメモリ確保してバッファリング処理をするようになって ちょっと微妙だなーと思って 結局ここでは "reinitializable iterator" は使わず 別々の Dataset として処理するようにした。 shuffleの重要度、validationの頻度などによっては普通に便利に使用できるのかもしれない。ちょっとよく分からない。

def shogi_inputs(image_lists):
    class_count = len(image_lists.keys())
    t_count, v_count = 0, 0
    for l in image_lists.values():
        t_count += len(l['training'])
        v_count += len(l['validation'])

    def generate_dataset(category):
        images = []
        labels = []
        label_names = []
        for label_index in range(class_count):
            label_name = list(image_lists.keys())[label_index]
            label_names.append(label_name)
            category_list = image_lists[label_name][category]
            for basename in category_list:
                images.append(os.path.join(FLAGS.image_dir, label_name, basename))
                labels.append(label_index)
        zipped = list(zip(images, labels))
        random.shuffle(zipped)
        return tf.data.Dataset.from_tensor_slices((
            [e[0] for e in zipped],
            [e[1] for e in zipped]))

    def parser(file_path, label_index):
        image = tf.image.decode_jpeg(tf.read_file(file_path), channels=3)
        image = tf.image.convert_image_dtype(image, tf.float32)
        image = tf.image.resize_images(image, [96, 96])
        return image, tf.to_int64(label_index)

    t_dataset = generate_dataset('training')
    t_dataset = t_dataset.map(parser)
    t_dataset = t_dataset.repeat()
    t_dataset = t_dataset.shuffle(FLAGS.batch_size * 10)
    t_dataset = t_dataset.batch(FLAGS.batch_size)

    v_dataset = generate_dataset('validation')
    v_dataset = v_dataset.map(parser)
    v_dataset = v_dataset.repeat()
    v_dataset = v_dataset.batch(FLAGS.batch_size * 5)

    return [
        t_dataset.make_initializable_iterator(),
        v_dataset.make_initializable_iterator(),
        t_count,
    ]

Training and Validation

それぞれの Dataset を使って入力画像とラベルのTensorを得られるので、実際にモデルに入力して得た結果を使って training operation と validation accuracy を作る。 trainingのあたりは models/research/slim/nets/mobilenet_v1_train.py という MobileNetV1用のスクリプトがあったのでそれを真似している。

t_iter, v_iter, training_count = shogi_inputs(image_lists)
t_inputs, t_labels = t_iter.get_next()
v_inputs, v_labels = v_iter.get_next()
with slim.arg_scope(mobilenet_v2.training_scope()):
    t_logits, _ = mobilenet_v2.mobilenet(t_inputs, num_classes=class_count)
    v_logits, _ = mobilenet_v2.mobilenet(v_inputs, num_classes=class_count, reuse=True)

# training
tf.losses.sparse_softmax_cross_entropy(t_labels, t_logits)
total_loss = tf.losses.get_total_loss(name='total_loss')
num_epochs_per_decay = 2.5
decay_steps = int(training_count / FLAGS.batch_size * um_epochs_per_decay)
learning_rate = tf.train.exponential_decay(
    0.045,
    tf.train.get_or_create_global_step(),
    decay_steps,
    _LEARNING_RATE_DECAY_FACTOR,
    staircase=True)
update_ops = tf.get_collection(tf.GraphKeys.UPDATE_OPS)
with tf.control_dependencies(update_ops):
    train_tensor = slim.learning.create_train_op(
        total_loss,
        optimizer=tf.train.GradientDescentOptimizer(learning_rate))

# validation accuracy
indices = tf.argmax(v_logits, axis=1)
correct = tf.equal(indices, v_labels)
accuracy = tf.reduce_mean(tf.to_float(correct))

これが出来たら、あとは学習を回すだけ。 ここも mobilenet_v1_train.py では slim.learning.train というのを使っていたので、それを真似してみた。

slim.learning.train はどうやらtrain tensorを渡すと定期的に記録しながら指定したstep数の学習を回してくれるようなのだけど、例えば50 stepごとにvalidationを回して記録したい、とか思うと train_step_fn で各stepで何をするかを定義してやる必要があるようだ。 正直ここまでやるんだったら slim.learning.train は使わずに普通にfor loop回すだけでも良いような気もする…。

# train step function
def train_step(sess, train_op, global_step, train_step_kwargs):
    start_time = time.time()
    total_loss, np_global_step = sess.run([train_op, global_step])
    time_elapsed = time.time() - start_time
    # validation
    if np_global_step % 50 == 0:
        logging.info('validation accuracy: %.4f', sess.run(accuracy))

    if 'should_log' in train_step_kwargs:
        if sess.run(train_step_kwargs['should_log']):
            logging.info('global step %d: loss = %.4f (%.3f sec/step)',
                         np_global_step, total_loss, time_elapsed)
    if 'should_stop' in train_step_kwargs:
        should_stop = sess.run(train_step_kwargs['should_stop'])
    else:
        should_stop = False
    return total_loss, should_stop

# start training
g = tf.Graph()
with g.as_default():
    init_op = tf.group(t_init, v_init, tf.global_variables_initializer())
    slim.learning.train(
        train_tensor,
        FLAGS.checkpoint_dir,
        graph=g,
        number_of_steps=FLAGS.number_of_steps,
        save_summaries_secs=FLAGS.save_summaries_secs,
        save_interval_secs=FLAGS.save_interval_secs,
        local_init_op=init_op,
        train_step_fn=train_step,
        global_step=tf.train.get_global_step())

Results

実際このスクリプトを回して学習開始してみると、最初は当然3%くらいの正答率で そこから徐々にlossが減少し正答率が上昇していくのが観測できる。 1,000 step前後で正答率98%〜 と、十分に高い精度まで上がるようだった。

f:id:sugyan:20180707000650p:plain

f:id:sugyan:20180707000700p:plain

さすがにCPUだと1 stepにも数秒かかるくらいのスピードでそれなりに時間がかかる。 EC2 P3 instanceを使って回してみたところ、1,000 step程度ならものの数分で終了するようだった。

Evaluation

この学習済みモデルを使って、実際に前回記事と同じ画像を与えてどう識別されるかを確認してみる。

1200 stepほど学習した後のcheckpointファイルからモデルを復元し、局面図の画像を分割してそれぞれ識別してみる。

1. 学習データに使った素材で作ったもの

f:id:sugyan:20180501012958p:plain

前回の結果:

-KI * +TO * -OU * +TO+TO+NY
 * -FU *  *  * +FU *  *  * 
+RY-TO *  * -FU-FU * -FU-KY
 * +KE+KE-GI-KI-TO *  * +RY
-UM-NK+KY+TO * -TO *  *  * 
 * +FU *  *  * -GI-GI+FU * 
 *  * +FU * +TO-GI *  * +KE
 *  *  *  * +TO * +FU * +KY
+UM *  *  *  *  * +KI * -KI

今回の結果:

-KI * +TO * -OU * +TO+TO+NY
 * -FU *  *  * +FU *  *  * 
+RY-TO *  * -FU-FU * -FU-KY
 * +KE+KE-GI-KI-TO *  * +RY
-UM-NK+KY+TO * -TO *  *  * 
 * +FU *  *  * -GI-GI+FU * 
 *  * +FU * +TO-GI *  * +KE
 *  *  *  * +TO * +FU * +KY
+UM *  *  *  *  * +KI * -KI

さすがにこれは全部正解するようだ。

2. Shogipicで生成された局面図

f:id:sugyan:20180501013010p:plain

前回の結果:

-KI * +TO * -OU * +TO+TO+NY
 * -UM *  *  * +FU *  *  * 
+RY-TO+UM+UM-UM-FU ? -UM-KY
 * +KE+KE-KA-KI-TO ?  * +RY
-UM-KA+KY+TO * -TO *  *  * 
 * +FU ?  ?  * -KE-KE+FU * 
 *  * +FU ? +TO-KE ?  * +KE
 *  *  *  * +TO * +FU * +KY
+UM *  *  *  *  * +KI * -KI

今回の結果:

-KI * +TO * -OU * +NK+NK-NG
 * -FU *  *  * +FU *  *  * 
+RY-TO *  * -FU-FU * -FU-KY
 * +KE+KE-GI-KI-TO *  * +RY
-UM-NK+KY+TO * -TO *  *  * 
 * +FU *  *  * -GI-GI+FU * 
 *  * +FU * +TO-GI *  * +KE
 *  *  *  * +TO * +FU * +KY
+UM *  *  *  *  * +KI * -KI

何故か1段目の「と金」が「成桂」になっていたりといった誤答はあるが、前回のように駒の無いはずのところで駒があると判別してしまうような間違いは無くなっているようだ。

3. 激指 14の局面スクリーンショット

f:id:sugyan:20180501013026p:plain

前回の結果:

-KI * +TO * -OU * +TO+TO+NY
 * -FU *  *  * +FU *  *  * 
+RY-TO *  * -FU-FU * -FU-KY
 * +HI+HI-GI-KI-TO *  * +RY
-UM-NG+KY+TO * -TO *  *  * 
 * +FU *  *  * -GI-GI+FU * 
 *  * +OU * +TO-GI *  * +HI
 *  *  *  * +TO * +FU * +KY
+RY *  *  *  *  * +KI * -KI

今回の結果:

-KI *  *  * -OU *  *  * +NK
 * -FU *  *  * +FU *  *  * 
+RY-TO *  * -FU-FU * -FU-KY
 * +KE+KE-GI-KI-TO *  * +RY
-UM-NK+KY *  * -TO *  *  * 
 * +FU *  *  * -GI-GI+KY * 
 *  * +FU * +TO-GI *  * +KE
 *  *  *  *  *  * +KY * +KY
+RY *  *  *  *  * +KI * -KI

攻方の「と金」が空白として識別されて消えてしまっていたり、「歩兵」が「香車」と認識されていたり、間違いが多い…

4. Shogi.ioの局面スクリーンショット

f:id:sugyan:20180501013041p:plain

前回の結果:

-NG * +TO * -FU * +TO+TO+UM
 * -FU *  *  * +NK *  *  * 
-UM-TO ?  * -FU-FU * -FU-NG
 * +KE+OU-GI-NG-TO *  * +KE
-NG-UM+NG+TO * -TO *  *  * 
 * +NK ?  *  * -GI-GI-UM * 
 *  * +NK * +TO-GI *  * +KE
 *  *  *  * +TO * -UM * +NG
-UM *  *  *  *  * +NG * -NG

今回の結果:

-NG *  *  * +OU *  *  * -TO
 * -NK *  *  * +NY *  *  * 
-UM-TO *  * -NK-NK * -NK-NG
 * +KE+KE-NG-NG-TO *  * +GI
-UM-NG-UM *  * -TO *  *  * 
 * +NY *  *  * +RY-HI+NY * 
 *  * +NG *  * +RY *  * +KE
 *  *  *  *  *  * +NY * +FU
-KE *  *  *  *  * +FU * -NG

前回のも全然合ってなくてひどかったが、今回のはもっとひどい…

まとめ

TensorFlowでMobileNetV2を最初から学習させることができた、けど別にそれが出来たからといって性能が改善するわけでもない。

ラクして生成したものだけを使って…ではなく、ちゃんと様々な学習用データセットを用意して 学習させていくしかなさそう。

Repository