Advent of Code 2019 に挑戦している

f:id:sugyan:20191223224220p:plain

Advent of Code というのがある。

https://adventofcode.com/

日本ではまだあまり 知っている人/やっている人 は多くないかもしれない。検索してみても、日本語の紹介記事はこれくらいしか見つからなかった。

Advent of Code の紹介 - Qiita

僕も、去年 元同僚の @ExAdamu に教えてもらうまでは存在すら知らなかった。

どういうものか、っていうのは上に貼った記事でも書かれている通りで、12/1 〜 12/25 まで 毎日1つずつ、プログラミングを使うパズル問題が出題される、というもの。 puzzle input の入力値が与えられ、それに対する回答を自分の書いたコードで計算し、出力値を submitして正解すれば星が貰える。

入力値とそれに対する正解はどうやらユーザごとに異なるものになっているようで、誰かに正解を訊く みたいなものは出来ないようになっている。 重要なのは正解に辿りつくためのコード、ということになる。

問題は毎日 part1 / part2 と分かれていて、part1はだいたい問題文に書いてある通りに正しく実装すれば答えが出せるような感じになっている。 part2 は、使う入力値はpart1と一緒なのだけど 求められるものがより複雑になり、ちょっと難しくなる。それなりに正しくアルゴリズムとデータ構造を駆使しないと解けなかったり、ある程度は数学的な知識が必要になってきたりするようだ。

すべての日程で part1 / part2 すべて正解すれば星が50個集まる、ということになる。

去年は存在を知っただけで全然挑戦していなかったのだけど、今年はちょっと腕試しと練習を兼ねて、ということで Rustで挑戦してみることにした。 現時点で 23日まで出題されていて、46個中41個まで星を集めた状態。

GitHub - sugyan/adventofcode

別にこれはコンテストとか競技のものではないので 他の人の回答方法を見てはいけないわけではない(早解き上位を目指す人とかは別だろうけど)。

出来る限り自分で考えてみて、分からなかったら reddit で他の人のコードや考え方を見ても良い。 僕も幾つかは詰まってredditの他の人のアイデアを参考にさせてもらったりもした。

とにかく、やってみると、これはとても面白い。

問題がとてもよく出来ていて、スッキリ解けたときの爽快感がすごい。

あと特徴として、毎回恒例なのか今年のが特別なのかは知らないけど シリーズものになっている問題もある。 奇数日は IntCode という 整数値列を使ったレジスタマシンのようなものを使用することになり、このインタプリタを実装する問題が day5, day7, day9 あたりで出されている。このへんは順番にやっていないと解けない。

でも ちゃんと動かせると迷路を出現させたりブロック崩しのゲームが動いたりして、これは感動した

偶数日は逆に他の日とは全然関係ないので まったく予備知識なくても挑戦して解くことが出来るはず。


プログラミングが好きな人や数学が好きな人、是非とも挑戦してみると良いんじゃないかな、と思います。


僕も今年どこまで出来るか分からないけどもうちょっと頑張ってみるし、来年もあったら絶対また挑戦してみたいと思っている。

ISUCON9 本選12位だった

ISUCON9 予選敗退した って書いたんだけど、アレがアレして色々あって、やっぱり本選に出場できることになったので参加してきた。

「失敗から学ぶISUCONの正しい歩き方」チーム、最終結果は12位でした。

言語は予選のときと同じRubyで挑戦。

10:09 初期状態 → 0

初期実装の状態では遅すぎてスコアが出ない、というところからスタート。当然ながらRuby実装に切り替えてもスコアは出ない。

まずはせめてスコアが出るくらいにはしてスタート地点に立たないとね、ってことで諸々サーバ側の準備を進めたりマニュアル読み込んだりしながら 最低限あるべきindex貼って対応していこうね、と作業開始

11:34 ようやくスコア出る → 1,429

さらにindexを貼っていくと、今度はFailするようになる。 実際にブラウザから確認してみると、どうもindexを貼ることによって検索結果の順番が変わってしまうらしい。 primary keyもORDER BY指定もなくて順番が保証されてないやん… 有り得ない、なんだこのクソアプリは!と憤りつつも ORDER BY でそれっぽい順番で返すようにしようとして train_name を指定し、今度はこれが数値に見せかけた文字列だったために 1 の次に 10, 100 が来るというバグを踏み抜き タイムロス。

13:46 ようやくバグが直った → 4,478

一通りindex貼り終わったものが動いて どうにか安心。 そういえば AVAILABLE_DAYS ってのがあるんだよね、ってことで ためしに 1030 に上げてみたところスコア変わらず。 うーん、じゃあ 90 だとどう? → 5,900 まで上がるが500errorなどが出てキツそう。とりあえず 90 で進めることにする。

14:47 複数台構成 → 6,517

id:Soudai さんにお任せしてMySQLをdockerから剥がしてもらって appサーバとdbサーバで分ける構成が動いた。負荷が分散されてスコアが上がる。たまたまこの瞬間で暫定1位の記録になったので慌ててスクショを撮った。

f:id:sugyan:20191007234341p:plain

15:15 get_available_seats 軽減 → 6,672

唯一と言える、僕がまともにコード書いた部分。 /api/train/search 内のN+1はとても複雑で、完全に排除するのは難しそうだったが 最も処理が重くて負担になっているのは get_available_seats を 「premium か否か」「is_smokingか否か」で計4回呼んでいる部分。この中で多数のJOINなどもあるし せめて1回の呼び出しで結果を取得して コード内でカウントすれば多少は早くなるだろう、と id:kamipo さんの提案を受けて実装。

https://github.com/soudai/isucon9-final/pull/5/files

特にバグも無く動いたは動いたが、残念ながらスコアにはあまり寄与しなかった。

そことは別に、なんか benchmarkerの /api/train/search リクエストで 500 errorを多く返していて、ログを見ると departurearrival が検索できず nil になるのにその値を参照しようとしてぬるぽで落ちているものが多数あって、かと言ってそれを応答から消したりエラー握り潰して誤魔化したりしてもどうやってもbenchmarkerに怒られて、どうしようもなくてハマり続けた。これは出題側のバグなんじゃないかなぁとボヤきながら色々試したが結局最後まで解決できず。悔いが残る。

15:39 deadlockの発見

このへんでbenchmarkerがFailしまくるようになり、どうも POST /api/user/reservations/*/cancel で500 errorを頻発しているようだが これも複雑な処理なので 500を返している箇所も複数あり 詳しい原因がよく分からない。

予選のときに得た教訓で、「泥臭いprintデバッグでも活用する」という精神で 画期的なcommitを入れた。

Add 500 error · soudai/isucon9-final@a8afb24 · GitHub

これにより journalctlcancel error!!!!!!!!! 6 を発見し deadlockによるものだというのがすぐに判明し 解決をkamipoさんに丸投げすることが出来た。 これが今回の最大の貢献だったかもしれない……

16:01 deadlock解決(?) → 12,056

これでようやくそこそこのスコアになったが

そこから先 細かいチューニングをするも 結局これより高いスコアが出ることもなく 最後の数十分はひたすら AVAILABLE_DAYS の数値を上げたり下げたりしてFailしないギリギリくらいに…と調整するくらいになってしまった。

しかし 90180くらいで試していて 初期の 10 付近の数値では試そうともしていなかった… もしかしたら敢えてこの数字を下げることでもっと良いスコアが出たかもしれなかったのかなぁ

18:00 競技終了 最終スコア 7,462

10位以内くらいには入りたかったが そこまでもいけなかったなぁ…

反省点

予選のとき以上にコードでの貢献が出来なかった。 1つの処理で何百行もあって複雑なloopのnestがあるような、言ってしまえばクソコード的なものを前に しっかり内容を読み取ってキレイにリファクタリングしていくだけの力が無かった。

どちらかというと方針の相談や 他のメンバーが詰まりそうになったときに横から一緒にみて解決する、みたいな役回りになってしまっていた。それはそれで貢献だったとは思うけど、それだけではどうしても手が足りないし 自分の役目をもっと果たすように動くべきだった、のかもしれない。

POST /api/user/reservations/*/cancel のところは最後まであまり解決できなかったのだけど、外部リクエストの部分を非同期化して まず200を返してしまってから後でゆっくり処理する、などの解法を聞いて、その発想は全然なかったなぁと思った。

感想

同じRubyで挑戦した白金動物園チームが見事に優勝して感動があったし、1人参加の学生さんが2位で驚愕だったし、多くの刺激を受けて とても楽しめたISUCONでした。

今年も本当にありがとうございました!

参照

soudai.hatenablog.com

TensorFlow 2.0 時代の Keras API での画像分類器

TensorFlowを初期の頃から触っていて define-and-run の流儀にはそれなりに慣れてしまっていたけど、そろそろTensorFlowも2.0がreleaseされそうだし(2019.09時点で 2.0rc1) 新しいinterfaceも触っておかないと、と思って勉強してみた。

Effective TensorFlow 2.0 を読むと、major changesとして "Eager execution"、recommendationsとして"Keras layers and models"が紹介されている。 これからの時代はKeras APIを使ってEager executionでやっていく必要がありそうだ。

お題: 将棋駒画像の分類

昨年くらいから将棋の画像認識をやろうと思って 駒の画像データセットを作成 していた。今回はこれを使う。

各駒14種の先手・後手で28種、空白マスを加えて計29 classesを対象として、各classにつき約200〜300枚くらいずつ 96x96 のカラー画像 をラベル付きで用意してある。

f:id:sugyan:20190916230405p:plain

datasetの準備

ラベル付きの画像たちを一定の割合で training, validation, test のdatasetに分割する。 今回は約 8:1:1 で分割して、

  • training: 6277
  • validation: 816
  • test: 745

のdatasetが用意できた。

後述する tf.keras.preprocessing.image.ImageDataGenerator で使いやすいよう、各datasetを各label毎のディレクトリ以下に展開。

dataset
├── test
│   ├── BLANK
│   │   ├── 0de35ef1668e6396720e6fd6b22502b9.jpg
│   │   ├── 15767c0eb70db908f98a9ac9304227c8.jpg
│   │   ├── 18b0f691ac7b1ba6d71eac7ad32efdd5.jpg
│   │   ...
│   ├── B_FU
│   │   ├── 01aad8b7a32ca76e1ed82d72d9305510.jpg
│   │   ├── 04bc08425fb859f883802228e723c215.jpg
│   │   ├── 080950c64fb64840da3835d67fb969b8.jpg
│   │   ...
│   ├── B_GI
│   ...
├── training
│   ├── BLANK
│   ├── B_FU
│   ...
└── validation
    ├── BLANK
    ├── B_FU
    ...

transfer learning

まずは簡単に、学習済みの MobileNetV2 のモデルを使った転移学習をしてみる。

tf.keras.applications packageにはpre-trained modelが幾つか同梱されているので、それを呼び出すだけで簡単に使うことができる。

TensorFlow Hub にも同様に学習済みモデルが公開されていて再利用できるのだけど、現状では TensorFlow 2.0 向けのものは少なくて、 MobileNetV2 のものは input size が 224x224 に制限されているなどでちょっと使いづらい。 今後もっと整備されていくのかもしれないけど 今回は tf.keras.applications.MobileNetV2 を使うことにする。

INPUT_IMAGE_SIZE = (96, 96)

tf.keras.applications.MobileNetV2(
    input_shape=INPUT_IMAGE_SIZE + (3,),
    include_top=False,
    pooling='avg',
    weights='imagenet')

input_shapeのサイズは 96, 128, 160, 192, 224 のどれかで指定できて(defaultは224)、それに応じたimagenetでの学習済みモデルがダウンロードされて使われるようだ。

optionを指定しないと1000 classesの分類用logitsが出力されるが、転移学習にはそれは不要でその前段階の特徴量だけ抽出できれば良いので include_top=False を指定。 input_shape=(96, 96, 3) だと(None, 3, 3, 1280) の特徴量が出力されるようになる。 これをさらに pooling='avg' を指定することで平均値を取るpoolingにより (None, 1280) の2D Tensorが出力されるようになる(pooling='max'と指定することもできる)。 この1280個の値を特徴量ベクトルとして利用して結合層の部分だけ学習させて最適化していく。

feature extraction

base networkとなるMobileNetV2を固定させたまま使う場合は 入力画像に対する特徴量の出力は常に固定になるはずなので、先に全画像に対する特徴量を抽出しておくことが出来る。

Eager executionのおかげで単純に out = model(images).numpy() といった形で呼び出して出力の値を取得できる。

directoryを走査して画像データを読み込んでモデルへの入力値を作り、出力された特徴量ベクトルと対応するlabel indexをセットにして保存。どうせ後でshuffleして使うので順番は気にしない。 ここではnumpy arrayにしてnpzで保存する。

def dump_features(data_dir, features_dir):
    with open(os.path.join(data_dir, 'labels.txt'), 'r') as fp:
        labels = [line.strip() for line in fp.readlines()]

    model = tf.keras.applications.MobileNetV2(
        input_shape=INPUT_IMAGE_SIZE + (3,),
        include_top=False,
        pooling='avg',
        weights='imagenet')
    model.trainable = False

    features, label = [], []
    for root, dirs, files in os.walk(data_dir):
        for filename in files:
            image = tf.io.read_file(os.path.join(root, filename))
            image = tf.io.decode_image(image, channels=3)
            image = tf.image.convert_image_dtype(image, dtype=tf.float32)
            features.append(model(tf.expand_dims(image, axis=0)).numpy().flatten())
            label.append(labels.index(os.path.basename(root)))
    np.savez(os.path.join(features_dir, 'out.npz'), inputs=features, targets=label)

流石にCPUだとそこそこ時間がかかるが、数千件程度なら数分待てば完了する。

>>> import numpy as np
>>> npz = np.load('features/training.npz')
>>> npz['inputs']
array([[0.19352797, 0.        , 0.        , ..., 0.        , 1.0640727 ,
        2.7432559 ],
       [0.        , 0.        , 0.        , ..., 0.        , 1.4123839 ,
        3.057496  ],
       [0.18237096, 0.        , 0.04695423, ..., 0.33184642, 0.8117143 ,
        1.5288072 ],
       ...,
       [0.        , 0.        , 0.06801239, ..., 0.        , 0.13248181,
        1.6491733 ],
       [0.2550986 , 0.        , 0.10878149, ..., 0.        , 0.0785673 ,
        0.0311639 ],
       [0.        , 0.        , 0.        , ..., 0.        , 0.        ,
        2.0744066 ]], dtype=float32)
>>> npz['inputs'].shape
(6277, 1280)
>>> npz['targets']
array([24, 24, 24, ..., 14, 14, 14])
>>> npz['targets'].shape
(6277,)

あとはこの入出力に最適化するように結合層を学習させていけば良い。

tf.data.Dataset による学習データ準備

def dataset(category):
    npz = np.load(os.path.join(features_dir, f'{category}.npz'))
    inputs = npz['inputs']
    targets = npz['targets']
    size = inputs.shape[0]
    return tf.data.Dataset.from_tensor_slices((inputs, targets)).shuffle(size), size

training_data, training_size = dataset('training')
validation_data, validation_size = dataset('validation')

training用と validation用でそれぞれ tf.data.Dataset.from_tensor_slicesinputstargets のtupleを渡すことで、Modelに与える訓練用入力データの準備ができる。

>>> for images, labels in training_data.batch(32).take(1):
...     print(images.shape, labels.shape)

(32, 1280) (32,)

tf.keras.Sequential によるModel定義

学習させるModelは tf.keras.Sequentialtf.keras.layers.Layer のlistを渡して記述していく。

with open(os.path.join(args.data_dir, 'labels.txt')) as fp:
    labels = [line.strip() for line in fp.readlines()]
classes = len(labels)

model = tf.keras.Sequential([
    tf.keras.layers.InputLayer((1280,)),
    tf.keras.layers.Dropout(rate=0.2),
    tf.keras.layers.Dense(
        classes,
        activation='softmax',
        kernel_regularizer=tf.keras.regularizers.l2(1e-4)),
])
model.summary()

入力となる1280の特徴量ベクトルに対しDropoutを入れつつDense layerで 29 classesの分類になるようにしているだけ。最終層のactivationはsoftmaxに。

Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
dropout (Dropout)            (None, 1280)              0
_________________________________________________________________
dense (Dense)                (None, 29)                37149
=================================================================
Total params: 37,149
Trainable params: 37,149
Non-trainable params: 0
_________________________________________________________________

summaryでモデルの概要が簡単に分かって便利。

学習

まずはlossやmetricsの定義など。 Model.compile() で何を計測して何を減少させていくか、などを決定する。

model.compile(
    optimizer=tf.keras.optimizers.RMSprop(),
    loss=tf.keras.losses.SparseCategoricalCrossentropy(),
    metrics=[tf.keras.metrics.SparseCategoricalAccuracy()])

この転移学習用に用意したdatasetではtargetsのlabelはindexを表す単一のint値なので、one-hot vectorに変換していない場合は tf.keras.losses.SparseCategoricalCrossentropy のように Sparseとついたものを使う。 one-hotな表現の値を使いたい場合はtf.keras.utils.to_categorical を利用して変換することも出来るようだ。 optimizerは RMSpropがデフォルトで使われるらしい。

ここまで準備できたら学習開始。 Model.fit() を呼ぶだけ。

history = model.fit(
    training_data.repeat().batch(batch_size),
    steps_per_epoch=training_size // batch_size,
    epochs=100,
    validation_data=validation_data.batch(batch_size),
    validation_steps=validation_size // batch_size,
    callbacks=[tf.keras.callbacks.TensorBoard()])
print(history.history)

与える学習データには上で作った training_data を使う。これは繰り返し使うので .repeat()を指定。 .batch() で処理するのでそれぞれ data_sizeをbatch_sizeで割ったsteps数を指定している。

callbacks で各epochが終わったときなどに特別な処理を挟むことが出来て、ここでは tf.keras.callbacks.TensorBoard() を渡すことで ./logs以下にTensorBoardで確認する用のlogデータを書き込んでくれるようになる。

Train for 98 steps, validate for 12 steps
Epoch 1/100
2019-09-16 00:13:08.160855: I tensorflow/core/profiler/lib/profiler_session.cc:184] Profiler session started.
98/98 [==============================] - 1s 14ms/step - loss: 2.2567 - sparse_categorical_accuracy: 0.3463 - val_loss: 1.4933 - val_sparse_categorical_accuracy: 0.5755
Epoch 2/100
98/98 [==============================] - 0s 3ms/step - loss: 1.2355 - sparse_categorical_accuracy: 0.6362 - val_loss: 1.0762 - val_sparse_categorical_accuracy: 0.6966
Epoch 3/100
98/98 [==============================] - 0s 3ms/step - loss: 0.8975 - sparse_categorical_accuracy: 0.7380 - val_loss: 0.8647 - val_sparse_categorical_accuracy: 0.7630
Epoch 4/100
98/98 [==============================] - 0s 3ms/step - loss: 0.7226 - sparse_categorical_accuracy: 0.7943 - val_loss: 0.7586 - val_sparse_categorical_accuracy: 0.7943
Epoch 5/100
98/98 [==============================] - 0s 3ms/step - loss: 0.6036 - sparse_categorical_accuracy: 0.8364 - val_loss: 0.6855 - val_sparse_categorical_accuracy: 0.8073
...

Dense Layerの部分だけの学習なのでとても速く、CPUでも1epochあたり0.3秒程度で あっという間に学習が進む。lossが減少し sparse_categorical_accuracyが増加していくのが見てとれる。

とりあえず 100 epoch回して TensorBoardで確認すると

loss: f:id:sugyan:20190916231225p:plain

sparse_categorical_accuracy: f:id:sugyan:20190916231243p:plain

橙がtraining, 青がvalidation。training_dataに対してはどんどん正答率が上がるが validation_dataに対しての結果は90%に届くか届かないか…程度のところで止まってしまうようだ。 まぁImageNetで学習したMobileNetV2が将棋駒画像に対してどれだけの特徴を捉えられているかというのを考えるとそんなものかな、という気はする。

学習後のModelを保存

学習したDense Layerを使って、base networkのMobileNetV2と繋げてまた新しく tf.keras.Sequential を作る。こうすることで、今度は (96, 96, 3)の入力に対して (29)の出力をする画像分類器として動くModelになる。

classifier = tf.keras.Sequential([
    tf.keras.applications.MobileNetV2(
        input_shape=INPUT_IMAGE_SIZE + (3,),
        include_top=False,
        pooling='avg',
        weights='imagenet'),
    model,
])
classifier.trainable = False
classifier.summary()
Model: "sequential_1"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
mobilenetv2_1.00_96 (Model)  (None, 1280)              2257984
_________________________________________________________________
sequential (Sequential)      (None, 29)                37149
=================================================================
Total params: 2,295,133
Trainable params: 0
Non-trainable params: 2,295,133
_________________________________________________________________

簡単に繋ぎ直して使うことが出来て便利…。

あとはこのModelを丸ごと保存。.save() を呼ぶだけで良い。

classifier.save('transfer_classifier.h5')

test_dataでの評価

保存したModelを使って、学習には使っていない testのデータを使って精度を評価してみる。

feature extractionしたときと同様にディレクトリを走査し 画像ファイルとlabel indexをzipして tf.data.Datasetを生成する。

保存したModelは tf.keras.models.load_model() で読み込める。学習時と同様に loss に SparseCategoricalCrossentropy, metricsに SparseCategoricalAccuracy を指定してcompileして、評価で見るべき値を定める。

def evaluate(data_dir, model_path):
    with open(os.path.join(data_dir, 'labels.txt'), 'r') as fp:
        labels = [line.strip() for line in fp.readlines()]
    label_to_index = {label: index for index, label in enumerate(labels)}

    def load_image(image_path):
        image = tf.io.decode_jpeg(tf.io.read_file(image_path), channels=3)
        return tf.image.convert_image_dtype(image, tf.float32)

    image_paths = pathlib.Path(os.path.join(data_dir, 'test')).glob('*/*.jpg')
    image_paths = list(image_paths)
    label_index = [label_to_index[path.parent.name] for path in image_paths]
    images_ds = tf.data.Dataset.from_tensor_slices([str(path) for path in image_paths]).map(load_image)
    labels_ds = tf.data.Dataset.from_tensor_slices(label_index)
    test_data = tf.data.Dataset.zip((images_ds, labels_ds)).shuffle(len(image_paths))

    model = tf.keras.models.load_model(model_path)
    model.trainable = False
    model.compile(
        loss=tf.keras.losses.SparseCategoricalCrossentropy(),
        metrics=[tf.keras.metrics.SparseCategoricalAccuracy()])
    model.summary()

    test_result = model.evaluate(test_data.batch(1))
    print(test_result)
745/745 [==============================] - 13s 17ms/step - loss: 0.4405 - sparse_categorical_accuracy: 0.9114
[0.44054528336796983, 0.9114094]

今回のtransfer learningでのModelの、全745件のtest_data画像への正答率は 91.14% となることが分かった。

fine tuning

transfer learningでは90%前後の精度が限界のようだが、base networkのMobileNetV2部分も学習対象に含めて訓練していくとどうなるか。

Model定義

model = tf.keras.Sequential([
    tf.keras.applications.MobileNetV2(
        input_shape=INPUT_IMAGE_SIZE + (3,),
        include_top=False,
        pooling='avg',
        weights='imagenet'),
    tf.keras.layers.Dropout(rate=0.1),
    tf.keras.layers.Dense(
        len(labels),
        activation='softmax',
        kernel_regularizer=tf.keras.regularizers.l2(1e-4)),
])
model.summary()
Model: "sequential"
_________________________________________________________________
Layer (type)                 Output Shape              Param #
=================================================================
mobilenetv2_1.00_96 (Model)  (None, 1280)              2257984
_________________________________________________________________
dropout (Dropout)            (None, 1280)              0
_________________________________________________________________
dense (Dense)                (None, 29)                37149
=================================================================
Total params: 2,295,133
Trainable params: 2,261,021
Non-trainable params: 34,112
_________________________________________________________________

MobileNetV2Denseで繋げただけというModelの構造自体はで変わらない。 ただ今度はMobileNetV2の部分も学習対象とするので Trainable params37,149 がら 2,261,021 に激増している。

tf.keras.preprocessing.image.ImageDataGeneratorを使ってaugmentation

tf.keras.preprocessing というmoduleがあって、ここにはデータの前処理のためのutitityがある。

画像に対しても、以前は tf.image の様々なoperationを自分で組み合わせて作っていたdata augmentationの処理をまとめてやってくれるclassがある。

training_datagen = tf.keras.preprocessing.image.ImageDataGenerator(
    rotation_range=2,
    width_shift_range=2,
    height_shift_range=2,
    brightness_range=(0.8, 1.2),
    channel_shift_range=0.2,
    zoom_range=0.02,
    rescale=1./255)

このような形で、augmentationをかける際のパラメータ、レンジを指定して ImageDataGenerator を作る。 回転、縦横への移動、拡大縮小や明るさ変更など、色々指定できる。

このGeneratorに対して データを流し込んでいくことで iteratorが作られる。 既に展開してあるデータがあれば flow で、directoryだけ指定してそこから画像ファイルを読み取ってもらう場合は flow_from_directory で。

training_data = training_datagen.flow_from_directory(
    os.path.join(data_dir, 'training'),
    target_size=(96, 96),
    classes=labels,
    batch_size=batch_size)

こうすると、training_dataから読み出すたびに ImageDataGenerator生成時に指定したパラメータに従って変換をかけた画像たちがbatchで生成されて出力されるようになる。

極端にパラメータを大きくしてみると 以下のような感じで、傾いてたり白飛びしてたり様々。 このへんはどういう画像分類タスクを対象とするかによって適切なパラメータが異なるものになりそう。

f:id:sugyan:20190916231310p:plain

ともあれ、この ImageDataGenerator からの出力を使って学習させていく。

model.compile(
    optimizer=tf.keras.optimizers.RMSprop(),
    loss=tf.keras.losses.CategoricalCrossentropy(),
    metrics=[tf.keras.metrics.CategoricalAccuracy()])

ImageDataGenerator.flow_from_directoryclass_mode='categorical' がdefaultになっていて、これはtargetsの出力がone-hotな状態になる。ので、これを学習に使う場合は Sparse ではない CategoricalCrossentropy, CategoricalAccuracy を使うことになる。

そして学習。 training_dataImageDataGenerator のものなので、Model.fit ではなく Model.fit_generator を使う。

history = model.fit_generator(
    training_data,
    epochs=100,
    validation_data=validation_data,
    callbacks=[
        tf.keras.callbacks.TensorBoard(),
        tf.keras.callbacks.ModelCheckpoint(
            os.path.join(weights_dir, 'finetuning_weights-{epoch:02d}.h5'),
            save_weights_only=True),
    ])

callbackstf.keras.callbacks.ModelCheckpoint() を追加してみた。epochごとにmodelの状態を保存してくれる。save_weights_only にすればmodel定義は無視して変数の値だけを保存してくれるようになる。

Epoch 1/100
99/99 [==============================] - 53s 539ms/step - loss: 0.9509 - categorical_accuracy: 0.7492 - val_loss: 4.0943 - val_categorical_accuracy: 0.3885
Epoch 2/100
99/99 [==============================] - 52s 520ms/step - loss: 0.2898 - categorical_accuracy: 0.9246 - val_loss: 8.5777 - val_categorical_accuracy: 0.1973
Epoch 3/100
99/99 [==============================] - 51s 515ms/step - loss: 0.2019 - categorical_accuracy: 0.9498 - val_loss: 7.5333 - val_categorical_accuracy: 0.2512
Epoch 4/100
99/99 [==============================] - 52s 521ms/step - loss: 0.1509 - categorical_accuracy: 0.9651 - val_loss: 11.6409 - val_categorical_accuracy: 0.0907
...

これは流石にCPUではかなりの時間がかかってしまう。手元のMacBookProだと 200s/epoch くらい。 Google Colaboratory の GPU Runtime を使うと 52s/epoch くらいに短縮できるようだ。 ちなみに TPU Runtime では TensorFlow 2.0をまだ使えなくて、そもそも2.0rc時点ではまだTPUをsupportできていないらしい

ともあれ、どうにか 100 epoch回してみると

loss: f:id:sugyan:20190916231344p:plain

categorical_accuracy: f:id:sugyan:20190916231402p:plain

training(橙)は初期から順調にlossが減少してaccuracyも上昇するが、validation(青)はどうにも最初の数epochのうちは全然安定しない。 が、辛抱強く 30〜40epochくらいまで回していると突如としてlossの減少が始まり どんどん精度が上がってくる。 これはちょっとよく分からないけど fine-tuningでbase networkも学習するっていうのはこういうことなのかなぁ…。

ともかく、最終的にはかなり良い精度になっていそう。

評価

transfer learningのときと同じように model.save() で学習後のモデルを保存し、同じ評価scriptを使って evaluate を実行。

745/745 [==============================] - 14s 19ms/step - loss: 0.0542 - sparse_categorical_accuracy: 0.9906
[0.054189728634688225, 0.99060404]

転移学習より圧倒的に高い、 99.06% の精度が出た。すごい。 むしろ何を間違えているのかというと…

745件中 7件

f:id:sugyan:20190916231550j:plain (正解:△成香, 推論:△と金)

f:id:sugyan:20190916231607j:plain (正解:△香車, 推論:△金将)

f:id:sugyan:20190916231618j:plain (正解:△香車, 推論:△歩兵)

f:id:sugyan:20190916231630j:plain (正解:▲成香, 推論:▲成銀)

f:id:sugyan:20190916231641j:plain (正解:▲成香, 推論:▲と金)

f:id:sugyan:20190916231653j:plain (正解:▲成香, 推論:▲成銀)

f:id:sugyan:20190916231713j:plain (正解:▲歩兵, 推論: △成銀)

香車、成香はバリエーションあるわりにはデータあまり集めることができていなくて確かに間違うかもな、という感じ。とはいえ向き(先手か後手か)はだいたい見分けることが出来ている… と思ったら最後のやつは歩兵を後手の成銀と全然見当違いな結果になっていて謎。まぁ成銀もあまりデータ多くないので変なところで特徴を見てしまうのかも。

と考察できるくらいにはいいかんじに分類器として動いているようだ。

ここからは このModelを使ってJavaScriptやMobileAppで推論を動かしていく、というのをやっていくつもり

Repository

ISUCON9 予選敗退した

ISUCON9。今年は縁あって声かけていただき id:Soudai さんと id:kamipo さんと、「失敗から学ぶISUCONの正しい歩き方」というチームで出場した。

練習会

インターネット上でよく知っている人たちとはいえ 実際に一緒に仕事をしたことも無ければチームを組んで一緒に何かをしたことがあるわけでもない。 予選の2週間前に一度集まって、1日かけて前年の予選問題を再現しつつ試し解きしてみる、という会をした。 言語は3人で共通した得意なものがあるわけではなかったので とりあえずRubyで、ということにした。

そのときの感覚では「やっぱり色んなところで躓くこともあるかもしれないけど 正しく計測して早めに大きな方針を決めて改善をしていけばそれなりの成績には辿り着けるだろう」という感じ。 DB関連はとにかく2人が強いので、自分はアプリケーションコードを如何に正確に早く書いていけるかが勝負になる、と。

当日

練習会のときはそれぞれのMBPで作業したけど コード書くのは共有画面あった方が良さそう、と思い 当日はモニタを1台かりて映しながら作業するようにした。あと自作の Claw44 も持参。 作業環境は万全だった

11:19 初期セットアップ → 2,110

インフラまわりはSoudaiさんに完全丸投げしていたので サーバが立ち上がるまで僕とkamipoさんはマニュアルを読み込む。1台だけ立ち上がったら即git pushしてローカルで動かせるようにしたりしつつ コードを読んで動作を把握する作業。 想像してた以上にすごい作り込まれている クオリティの高いWebサービスになっていて絶句した。

トラブルなどもあったけど 11:19 ようやく初期状態での1回目のベンチマークが回った。スコアは 2,110

11:25 Rubyに実装切り替え → 2,310

まずは初期実装のGoからRubyへの変更。今回はスコアのブレは少なそうだ。 そこからaccess logを切り替えて alp で傾向を分析するといった作業をSoudaiさんに任せつつ、kamipoさんとアプリケーションの動作を追い続ける。

あと明らかにCPUの負荷は高いし3台で動作させて分散させるのはやっていこう、ということでそのへんの作業もSoudaiさんに一任。

13:07 campaign を上げてみる → 4,900

どうやら campaign という値をいじると負荷の挙動も変わってきそうだ、ということで試しに上げて動かしてみる。 タイムアウトなども発生するが どうにかボトルネックを解消して捌けるようにしつつこの数値を上げていければ良さそう、というのは分かってきた

14:03 3台構成稼動 → 2,510

3台でappを起動して 1台は nginx + mysql をメインにして集約、といった構成がやっと動いた。

14:07 index追加、campaign を上げる → 6,410

明らかにindexがなくて遅くなっている items.created_at のあたりにindexを貼って 多少はやくなるはず、ってことで campaign2 に上げてみた。 ようやく改善の効果が出てきて大事なボトルネックも見えてくる。ということで昼飯食べつつ今後の作戦会議。

kamipoさんはloginまわりの負荷軽減、Soudaiさんはstaticファイル配信まわりやdeploy環境まわりの整備など、で僕は不変な categories 情報をDBから引かずにapp内で持つように変更する、という方針で動き始める

16:04 get_category_by_id の改善 → 6,730

上記の通り categories 情報をapp内の変数から出すよう変更したものが動かせた。が 思ったほどスコアは改善しなかった…

16:57 login まわり改善 → 8,970

kamipoさんの改善が効いた。

9,650

その後 細々した修正でスコアを上げるも 10,000 までは届かず。そのまま最終スコア 9,650 でフィニッシュ。

最終的な予選通過ラインが 10,000 前後だったようだが そこまでは届かず予選敗退に終わった…

反省点

僕は早く正確にコードを変更していくのが役割だったはずなのに、結局そこで価値を出せなかった。 configure 内で categories情報を settings に入れて get_category_by_id は素早く改善できたものの、その後 /settings のresponseのところも変更しようとして

-      categories = db.xquery('SELECT * FROM `categories`').to_a
+      categories = settings.categories.map do |_, category|
+        category.delete('parent_category_name')
+        category
+      end

という変更を入れてFailするようになってしまい、revertして 動作確認して また失敗して、と原因の切り分けに時間がかかってしまい 40分くらいロスしてしまった… (なんてことはない、parent_category_name情報をresponseから省こうとしてdeleteした結果破壊的な変更になってsettings全体の値が変わってしまうという初歩的なバグ。。)

あとは外部リクエストを複数投げているところはどうにか並行化したいところだったけど GoやNodeならわりと簡単にできそうでもRubyでやるのは知見が無くて簡単には手が出しづらく、踏み込めなかった。 限られた時間内では「確実に出来そうな変更」にとどめざるを得なく、そこ以外のはやく出来そうなところから優先して手をつけることになり、最終的に残り1時間くらいでもう出来ることがなくなってベンチマークガチャを回すくらいしか出来なくなってしまった。 これは単純に能力不足と言うほかない。

3台構成にしようとしててMySQLが疎通しなくてDBのスペシャリスト2人いても苦戦していたところに bind-address 127.0.0.1が原因だって見抜いたことが僕の一番の功績だったかもしれない。

チーム的には…

最後の1時間くらいで出来ることがほぼ無くなって「次の一手」を打てなかったのが厳しかったか… 最初はしっかりaccess logを解析してボトルネックを見つけて、とやっていたけど 最後は「うーん やっぱり /buy が遅いみたいだけど どうすりゃええんや…」みたいな状態で そこから詳細に原因を調べて改善に繋げていくことが出来なかった。外部リクエストの測定も出来なかったし あと数時間あったとしても有効な改善を思い付いて実行できたかどうか分からない。

競技終了後に「これ、『明日もう1回 同じ問題に8時間取り組んでいいですよ』って言われても勝てる気しないっすね…」ってなってしまっていて完敗な感じになってしまっていた。

あとは全体的に作業スピードが遅くて時間に余裕が無くなりすぎていたか…?という気もする。 1人チームの学生さんに負けないくらいに各自がミスなく素早く動いて 最初の数時間をもっと短縮できていたら良かったのかもしれない。おじさんたちももっと精進して頑張らねば。

感想

結果は悔しいものに終わってしまったけれど、練習会・予選当日ともに このメンバーで集中して議論して改善に取り組んでいく過程はとてもエキサイティングで楽しかったです。誘っていただきありがとうございました!!! また是非このメンバーで一緒に戦いたいな

運営の皆様には今回も良質な問題を提供していただき、ありがとうございました。 本当に 予選であんな盛り沢山の参照実装を用意してくるとは驚きでした。。

あとスタンプめっちゃ気に入ってます

本戦当日に妻と花火大会に行く予定でチケット買ってしまっててダブルブッキングになる不安があったんですが、心おきなく一緒に花火を楽しんでこようと思います。。。

電子工作 5作目・Claw44

f:id:sugyan:20190717112703j:plain

HelixPico、Mint60、ErgoDash、Corne Cherry に続いて、自作キーボード 5作目。

今回作ったのは Claw44。 この記事はClaw44を使って書いています。

booth.pm

なぜClaw44を選んだか

Corne Cherryで概ね自分の理想は実現されていたけど、前回の記事に書いた通り

親指用のキーは下部に3つ 押しやすい位置に斜めに並んでいるのだけど、 どうもそれでも最内側のキーがまだ少し遠くて押しづらい…。

というのが唯一の懸念だった。

そこで登場したClaw44。設計者の id:yfuku さんが、まさにそこにこだわって作られている。

blog.yfuku.com

ので自分のニーズにとても一致している。 Corne Cherryと同様にコンパクトで最小限のキー数なColumn-Staggered配列というのも理想的。

組み立て

前作Corne CherryのときはチップダイオードやLEDの表面実装が難しすぎて泣きそうになっていたけど、Claw44はそういう高難度の要素が殆どなかったので、 ビルドガイド を読みながら組み立てるだけで だいぶラクに完成させることができた。

キースイッチ・キーキャップ

今回のキースイッチは "Blue Zilent" 65g で。

yushakobo.jp

キーキャップは前作と同じ XDA Blank を使った。

talpkeyboard.stores.jp

親指の部分で 1.25U のものがどうすべきか分からないけど 今はお試しで送っていただいた傾斜つきのものを使わせていただいている。

キーマップ

前作同様、自分にとって最適のものを設定。

--- keyboards/claw44/keymaps/default/keymap.c   2019-07-08 13:16:45.000000000 +0900
+++ keyboards/claw44/keymaps/sugyan/keymap.c    2019-07-08 15:26:53.000000000 +0900
@@ -38,15 +38,15 @@
 const uint16_t PROGMEM keymaps[][MATRIX_ROWS][MATRIX_COLS] = {

   [_QWERTY] = LAYOUT( \
-  //,--------+--------+---------+--------+---------+--------.   ,--------+---------+--------+---------+--------+--------.
-     KC_ESC , KC_Q   , KC_W    , KC_E   , KC_R    , KC_T   ,     KC_Y   , KC_U    , KC_I   , KC_O    , KC_P   , KC_MINS,
-  //|--------+--------+---------+--------+---------+--------|   |--------+---------+--------+---------+--------+--------|
-     KC_TAB , KC_A   , KC_S    , KC_D   , KC_F    , KC_G   ,     KC_H   , KC_J    , KC_K   , KC_L    , KC_SCLN, KC_QUOT,
-  //|--------+--------+---------+--------+---------+--------|   |--------+---------+--------+---------+--------+--------|
-     KC_LSFT, KC_Z   , KC_X    , KC_C   , KC_V    , KC_B   ,     KC_N   , KC_M    , KC_COMM, KC_DOT  , KC_SLSH, KC_RSFT,
-  //`--------+--------+---------+--------+---------+--------/   \--------+---------+--------+---------+--------+--------'
-                       KC_A_DEL, KC_G_EN, KC_L_SPC, KC_C_BS,     KC_C_BS, KC_R_ENT, KC_G_JA, KC_A_DEL
-  //                 `----------+--------+---------+--------'   `--------+---------+--------+---------'
+  //,--------+--------+--------+--------+--------+--------.   ,--------+---------+--------+---------+--------+--------.
+     KC_TAB , KC_Q   , KC_W   , KC_E   , KC_R   , KC_T   ,     KC_Y   , KC_U    , KC_I   , KC_O    , KC_P   , KC_BSLS,
+  //|--------+--------+--------+--------+--------+--------|   |--------+---------+--------+---------+--------+--------|
+     KC_LCTL, KC_A   , KC_S   , KC_D   , KC_F   , KC_G   ,     KC_H   , KC_J    , KC_K   , KC_L    , KC_SCLN, KC_QUOT,
+  //|--------+--------+--------+--------+--------+--------|   |--------+---------+--------+---------+--------+--------|
+     KC_LSFT, KC_Z   , KC_X   , KC_C   , KC_V   , KC_B   ,     KC_N   , KC_M    , KC_COMM, KC_DOT  , KC_SLSH, KC_R_ENT,
+  //`--------+--------+--------+--------+--------+--------/   \--------+---------+--------+---------+--------+--------'
+                       KC_LALT, KC_LGUI, KC_L_SPC,KC_C_BS,     KC_C_BS, KC_RSFT , RAISE  , LOWER
+  //                 `---------+--------+--------+--------'   `--------+---------+--------+---------'
   ),

   //   \ ^ ! & |  @ = + * % -
@@ -55,23 +55,23 @@

   [_RAISE] = LAYOUT( \
   //,--------+--------+--------+--------+--------+--------.   ,--------+--------+--------+--------+--------+--------.
-     _______, KC_BSLS, KC_CIRC, KC_EXLM, KC_AMPR, KC_PIPE,     KC_AT  , KC_EQL , KC_PLUS, KC_ASTR, KC_PERC, KC_MINS,
+     KC_ESC , KC_EXLM, KC_AT  , KC_HASH, KC_DLR , KC_PERC,     KC_CIRC, KC_AMPR, KC_ASTR, KC_LPRN, KC_RPRN, KC_DEL ,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     KC_LPRN, KC_HASH, KC_DLR , KC_DQT , KC_QUOT, KC_TILD,     KC_LEFT, KC_DOWN,  KC_UP , KC_RGHT, KC_GRV , KC_RPRN,
+     KC_LCTL, KC_UNDS, KC_PLUS, KC_LCBR, KC_RCBR, KC_TILD,     KC_GRV , KC_MINS, KC_EQL , KC_LBRC, KC_RBRC, _______,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     _______, _______, _______, _______, KC_LCBR, KC_LBRC,     KC_RBRC, KC_RCBR, _______, _______, _______, _______,
+     KC_LSFT, KC_1   , KC_2   , KC_3   , KC_4   , KC_5   ,     KC_6   , KC_7   , KC_8   , KC_9   , KC_0   , _______,
   //`--------+--------+--------+--------+--------+--------/   \--------+--------+--------+--------+--------+--------'
-                       _______, _______, _______, _______,     _______, _______, _______, RESET
+                       KC_LALT, KC_LGUI, KC_L_SPC,KC_C_BS,     KC_C_BS, KC_RSFT, RAISE  , LOWER
   //                  `--------+--------+--------+--------'   `--------+--------+--------+--------'
   ),

   [_LOWER] = LAYOUT( \
   //,--------+--------+--------+--------+--------+--------.   ,--------+--------+--------+--------+--------+--------.
-     KC_F1  , KC_F2  , KC_F3  , KC_F4  , KC_F5  , KC_F6  ,     _______, KC_EQL , KC_PLUS, KC_ASTR, KC_PERC, KC_MINS,
+     _______, KC_F1  , KC_F2  , KC_F3  , KC_F4  , KC_F5  ,     KC_F6  , KC_F7  , KC_F8  , KC_F9  , KC_F10 , _______,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     _______, KC_1   , KC_2   , KC_3   , KC_4   , KC_5   ,     KC_6   , KC_7   , KC_8   , KC_9   , KC_0   , _______,
+     _______, _______, _______, _______, _______, _______,     KC_LEFT, KC_DOWN, KC_UP  , KC_RGHT, _______, _______,
   //|--------+--------+--------+--------+--------+--------|   |--------+--------+--------+--------+--------+--------|
-     KC_F7  , KC_F8  , KC_F9  , KC_F10 , KC_F11 , KC_F12 ,     _______, _______, KC_COMM, KC_DOT , KC_SLSH, _______,
+     _______, _______, _______, _______, _______, _______,     KC_HOME, KC_PGDN, KC_PGUP, KC_END , _______, _______,
   //`--------+--------+--------+--------+--------+--------/   \--------+--------+--------+--------+--------+--------'
                        RESET  , _______, _______, _______,     _______, _______, _______, _______
   //                  `--------+--------+--------+--------'   `--------+--------+--------+--------'

結局 最内側の親指は相当手を広げないと届かないので使わない感じになっている…

使用感

最初は少し戸惑ったけど、慣れるとまったく気にならないレベル。 仕事用に会社でずっと使い続けていて、ほぼ不満無く快適に毎日使っている。

ただ普通のXDAキーキャップだと角が指に当たって少し痛くなるので やはり傾斜つきのものが欲しいな、というのが最近の気持ち。1.25Uの外隣のキー用に2個だけ何か欲しい…

TensorFlow.jsがChromeでWebWorker上でもWebGL backendで動く

僕も以前にWebWorker上でTensorFlow.jsを使おうとして WebGL backendで動かないことに気付いて諦めていたのだった。

memo.sugyan.com

…と思っていたのだけど、どうも先月くらいの @tensorflow/tfjs@1.2.2 あたりから ChromeではOffscreenCanvasというのを使ってWebWorker上でもWebGL backendで動くようになったようだ。 試してみたところでは 動くのはChromeのみで、SafariFirefoxではCPU backendのまま。

動作を確認できるdemoページを作ってみた。

https://sugyan.com/tfjs-webworker/

ボタンを押すと適当にmodelをloadして、何回かrandom inputに対してpredictを計算する。

普通に MainThread上で実行すると、WebGL backendが使われて predictの計算などは高速になるのだけど、最初のloadや1回目の計算のときなどに重い処理が走り、UIの更新がストップしてしまう。 f:id:sugyan:20190801125155g:plain

Platform and environment  |  TensorFlow.js  |  TensorFlow

これを回避するために、MainThreadではなくWebWorker上で計算を行うようにすると、UIの更新がブロックされずに計算が出来るのだけど、従来だとWebWorker上ではCPU backendにfallbackしてしまうために計算がとても遅くなってしまっていた。

f:id:sugyan:20190801125825g:plain

これが、Chromeだと、UI更新をブロックせずにWebWorker上で高速に計算することが出来る。

f:id:sugyan:20190801125446g:plain

これは嬉しい。 すべてのブラウザでもサポートされてくれると嬉しいな〜〜〜

https://github.com/sugyan/tfjs-webworker

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

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

memo.sugyan.com

Backend

昨年までのものをほぼ使い回しで出来るかな、と思っていたのだけど、今年は動かすプラットフォームを変えてみることにした。

今までは HerokuでRails appとして動かしていたのだけど、いつも月末になるとfree dynoを使い切ってしまい課金しないと見られなくなってしまっていた。 開催直前で使えなくなってしまうのはちょっと致命的だし困る… し、そもそも別にHerokuでRailsでないと動かないわけでもないので別のところに移しても問題ないな、と。

最近はBackendはGoで書きたい気分だったし、Google App Engine の Go1.12 Standard Environment で。

cloud.google.com

何故今までHeroku+Railsを使っていたかというと 画像生成の部分で rmagick を使っていたから、というのが大きい。 最近のGo 1.11/1.12 Standard Environmentは もうGAEのAPIに縛られた特殊なアプリ(ていうと言い方アレだけど)ではなく もはや普通のWebアプリとして作ってそのまま動かせる感じになっている。 imagemagick も普通に入っているので使える。のでGAEを使えない理由は無かった。

また、作成したタイムテーブルをリンク共有する機能でDBを使っていたけど、これはURLに使うユニークなIDと選択したステージのIDリストをひもづけるだけのものなので Cloud Datastore がむしろ用途として合っている。

ImageMagickでの画像生成

以前は rmagick に頼ったコードをRailsで書いていたけど、機能自体はImageMagickCLIでも実現できるもののはずなので、Go版ではライブラリなどを使わず convert コマンドのみで画像生成を実装した。

タイムテーブル画像は同じサイズの細長い画像を縦に連結する形で作られている。合成するアイテム数が分かっていればそのサイズのcanvasを生成してしまって描画する位置を調整していけば良いのだけど、日付の列は -gravity Center で中央寄せで annotate したものを作りたい、などの要求があったのでやはり横長を複数連結する方式にした。

convert -size 100x30 xc:'#303030' -fill white -gravity Center -annotate +0+0 Hello out.png

といったコマンドで f:id:sugyan:20190722224424p:plain のような画像を作れる。 こういったものを複数作って、最後に -append してやれば良い。 …のだけど、いちいちtempfileに出力してファイル名を管理して…というのはやりたくない。 調べてみるとMIFFというformatを使ってstreamingに処理することが出来るらしい。 それぞれの出力をstdoutに繋げて出力して、pipeでまとめて受け取って使える。

File Handling -- IM v6 Examples

(
    convert -size 100x30 xc:'#303030' -fill white -gravity Center -annotate +0+0 Hello miff:-;
    convert -size 100x30 xc:'#505050' -fill white -gravity Center -annotate +0+0 World miff:-;
    convert -size 100x30 xc:'#707070' -fill white -gravity Center -annotate +0+0 '!!!' miff:-;
) | convert - -append out.png

と、こういう形で f:id:sugyan:20190722225309p:plain のような画像を出力することが出来る。 最後のところを png:- とすればPNGのバイナリデータも受け取れるので一切中間ファイルに吐き出す必要がなくなる。知らなかった〜〜

結局サーバ側の実装ではpipeを使わず bufferに貯め込んで読み取る という方式にしたけど、ともかく convert コマンドだけで冒頭のような画像を生成することが出来た。

Frontend

Frontendは元々 React + ReactRouter でSPAにしていたものを昨年TypeScript化していて、ほぼ変える必要なかった。

memo.sugyan.com

Updateしたといえば tslint を使うのやめて @typescript-eslint に変えた、というのが大きいか。出来るだけ recommended な設定を使って 1件もwarning出ないよう書ける限り正確に型を書いて使うように心掛けて結局ほぼ全部イチから書き直した。

出演者名で絞り込みするフォームで 入力文字列を使って RegExp を組み立ててmatchさせてフィルタリングする、という処理をしていたのだけど ?* を入れるとぶっ壊れるバグがあることに今年になって気付いた。ひどいバグをずっと埋めていたんだ。。。気付かせてくれた 「転校少女*」さんに感謝。 しかしJavaScriptRegExpのescapeしてくれる関数みたいのって無いものなのか。

あとは時刻系ライブラリ。去年までは Moment.js を使っていたのだけど、そんなデカい処理は必要なくて Backendから取得できるJSONの時刻文字列をparseして 「8/2(金) 09:30」のようにformat出来れば良いだけ。なので とても軽いらしいという Day.js を最初つかってみたのだけど、任意のtimezoneに固定した出力が出来ない、ということに気付いた。

こんなの日本国内の人間しか使わんやろ、と油断していたら昨年 フィンランドのヲタクのヒトから「お前んとこでは動くかもしれんけど こっちのTZだと時刻ズレるんやで」とプルリクを貰ったのだった。

Set time zone for formatting times by hannesj · Pull Request #1 · sugyan/tif2018-mytt · GitHub

なので Asia/Tokyo で固定して出力できる必要はある。適当に調べていたら spacetime というライブラリがあったので 今回はこれを使うことにした。

昨年のJSは 545KB だったのに対し 今年は 230KB と半分以下のサイズになったので効果あったと言えそう

Others

その他にも今回は PWA化とかも入れようかな? と思っていたけど、それほど享受できるメリットも無さそうだし微妙かな、と思って見送った。

生成した画像も CloudStorage に入れるように、とかすれば良いのかもしれないけど そこまで参照されるものでもなさそうだし別にいいかな、と。

要するに、自分が行くわけでもないフェスのためにそこまで頑張るモチベが湧かなかった、、、

Repository