stMind

about Tech, Computer vision and Machine learning

VNect : 1枚のRGB画像からリアルタイムに3D pose estimation

www.youtube.com

SIGGRAPH2017で発表された、単眼RGB画像から3D poseをリアルタイムに推定するVNectのプレゼン動画。音声が若干残念ですが、20分程度で概要を把握できましたので、さらっとまとめ。

3D poseとは

Local 3D PoseとGlobal 3D Poseの二種類がある。Local 3D Poseは、root jointに対する相対的な座標(x, y, z)で表された関節位置及び姿勢。root jointはpelvis(腰位置)。一方、Global 3D Poseは、root jointのカメラからの距離dを考慮したカメラ空間での関節位置及び姿勢。よってこの論文では、1枚のRGB画像からLocal 3D Poseを推定し、さらにそこからGlobal 3D Poseを推定する方法となる。

イデアその1:heatmapベースの3D関節位置推定

単純に関節位置を推定すると考えると、DeepPoseのように画像から(x, y)を回帰するモデルを学習するという手法があるが、推定位置ずれの問題が起こりやすい。これに対して、各ピクセルが関節らしさの値を持つHeatmapを回帰するモデルを学習する手法があり、推定位置ずれの問題を解決している。ということで、Heatmapベースの回帰モデルを3次元に拡張し、3D pose estimationを実現しようというのがVNectのアイデア

2D HeatmapsとLocation maps

f:id:satojkovic:20180714140037p:plain

どのようにして2Dから3DへのLiftを実現するのか、のからくりが2D HeatmapsとLocation maps。

ResNet50をベースにしたFCNNが出力するのは、2D heatmapsとLocation mapsの4つのmapsで、Location mapsというのは、root jointからの相対的な距離を表したもの。X, Y, Zそれぞれ-80cm(濃い青)から+80cm(黄色)までの範囲で、例えば1番目の関節で見ると、X1のmapの左半分は体の左半身の領域でありroot jointに対して負と推定、Y1ではmapの上半分がroot jointに対して負の方向(高い位置)と推定、Z1では周辺がroot jointよりも少しだけ前に出ているので正の方向と推定されていることがわかる。

こんな風にしてroot jointからの相対的な距離を表すLocation Mapを推定することによって、2D Heatmapにより推定された2D関節位置を、Location map上で対応する位置の相対距離で表された3D関節位置に変換できる。

では、学習のための正解データはどう作るのか。2D Heatmapは2D pose estimationの方で行われており、比較的簡単。関節位置が付与されたデータセットがあるので、関節を中心にGaussianをかけて関節毎の2D Heatmapを作成することができる。Location mapsの方は、既存の3D poseデータは存在するが、全て室内に限られていて、様々なシーンで使いたいという目的に対しては不十分。そこで、14台のカメラを設置したマーカーレスモーションキャプチャースタジオを作って、その中でデータを取得。様々な人が任意の姿勢をとり、かつ背景(グリーンバック)をマスクすることで、背景を様々に入れ替えたりしてデータのオーグメンテーションも行った。この新しいデータセットMPI-INF-3DHPは、下記にて公開されている!

VNect: Real-time 3D Human Pose Estimation with a Single RGB Camera, SIGGRAPH 2017

Note: 標準的な体型の人のデータを集めており、160cmから170cmくらいの人が一番良くFitする模様。

イデアその2:スケルトンのFittingによるGlobal 3D pose推定

フレーム単位でのLocal 3D Pose推定では時系列的に推定が不安定になってしまう可能性があり、かつGlobal 3D Poseを求めるために、関節角度θとカメラからの距離dをパラメータとして持つ時系列的に一貫性のあるスケルトンモデルをフィッティングする。

f:id:satojkovic:20180714152238p:plain

IK(Inverse kinematics)はモデルとLocal 3D Poseの差の項、Projは射影後のモデルと2D Poseとの差の項、smoothはモデルの関節角度のacceleration項、depthはモデルのdepthのvelocity項、最後はjoint angleの時系列フィルタリングの項。

ピンホールカメラモデルを想定し、カメラキャリブレーションが未知の場合は、垂直画角は54度と想定する、と論文に記載があった。

動画だけではわからなかったこと

  • なぜ、この処理がリアルタイムで実現できるのか
    • 今のGPUを使うと44fpsまで出るみたいだが、旧世代のGPUでも33fpsは出ているらしい
    • Bounding boxの検出は、2フレーム目以降は関節位置をもとに簡易的に算出できるということだったが、それだけではないはず

アーセンベンゲル監督のラストホームゲーム関連グッズ

ベンゲル監督の最後のエミレーツ、バーンリー戦で配られたグッズをebayで手に入れることができました。総額は、全部で2万円強。

送料が高かった(約3000円)のが痛かったけど、ベンゲル監督信者としては、これくらいなら十分出せますね!

今も、ebayではいくつか出品されていますし、現地に行けなくて悔しい思いをした人がいれば、見てみるといいかと思います。(merci arseneなどで検索)

  • Merci Arsene Tシャツ

  • キーリング

  • 22 GUN SALUT

これはマッチデープログラムではないのかな?22年間のベンゲル監督のスタッツが載ってました。

  • チームシート(メンバー表)

これはまあ、セットで購入したのでおまけみたいなもんですね(笑)

  • 20周年の記念キーリングとバッジ

これは20周年のときの限定。裏にはちゃんとシリアル番号が振ってあります。

Deep Learning時代のPose Estimation研究

[1602.00134] Convolutional Pose Machines

[1611.08050] Realtime Multi-Person 2D Pose Estimation using Part Affinity Fields

少し前まではPose estimationは非常に難しい問題だったように思いますが、Convolutional Pose MachinesやRealtime Multi-Person 2D Pose Estimation using Part Affinity Fieldsといった論文において、Fully convolutionalなNNによって人体関節をconfidence map(もしくはbelief mapやheatmapとも呼ばれる)として推定する手法が提案、有効性が検証され、さらにそれを実装したソフトウェアが公開されて応用探索されていくのを見ていると、Pose Estimation自体は既に研究フェーズからは移行したように感じます。

最近、GoogleからWebブラウザで実行可能なPoseNetモデルがリリースされたというニュースがありましたが、OpenPoseのような関節間の接続を表現するPart Affinity Fieldではなく、各ピクセルの関節に対するオフセットを表すOffset Vectorを学習するモデルになっていて、これに限らず手法の工夫ポイントはまだあるようにも思いますが、進歩性としてはどこまで残っているんだろう?とは思います。

medium.com

Deep Learning時代は、研究から応用までのスピードが速いですね...

KerasでGAN

towardsdatascience.com

Mediumの記事を参考に、一番基本のGANについて試してみた。データセットはおなじみのfashion mnist。

GANのアーキテクチャ

ノイズ画像(100次元のランダムなベクトル)からfashion画像を生成するgeneratorは、3層の全結合層から成るネットワーク。各層の出力次元数は28, 29, 210としている。

def get_generator(optimizer, output_dim=784):
    generator = Sequential()
    generator.add(
        Dense(
            256,
            input_dim=random_dim,
            kernel_initializer=initializers.RandomNormal(stddev=0.02)))
    generator.add(LeakyReLU(0.2))

    generator.add(Dense(512))
    generator.add(LeakyReLU(0.2))

    generator.add(Dense(1024))
    generator.add(LeakyReLU(0.2))

    generator.add(Dense(output_dim, activation='tanh'))
    generator.compile(loss='binary_crossentropy', optimizer=optimizer)
    return generator

一方のdiscriminatorも3層の全結合層から成るネットワーク。出力ノードはrealかfakeかのバイナリ値。同じように、各層の出力次元数は210, 29, 28としている。

def get_discriminator(optimizer, input_dim=784):
    discriminator = Sequential()
    discriminator.add(
        Dense(
            1024,
            input_dim=input_dim,
            kernel_initializer=initializers.RandomNormal(stddev=0.02)))
    discriminator.add(LeakyReLU(0.2))
    discriminator.add(Dropout(0.3))

    discriminator.add(Dense(512))
    discriminator.add(LeakyReLU(0.2))
    discriminator.add(Dropout(0.3))

    discriminator.add(Dense(256))
    discriminator.add(LeakyReLU(0.2))
    discriminator.add(Dropout(0.3))

    discriminator.add(Dense(1, activation='sigmoid'))
    discriminator.compile(loss='binary_crossentropy', optimizer=optimizer)
    return discriminator

GANの学習

最初にdiscriminatorだけを学習してパラメータを更新した後、ノイズ画像(100次元ベクトル)からgeneratorとdiscriminatorを通してrealかfakeかを判定するend-to-endのネットワーク(get_gan_network)に対して、discriminatorは重みを固定してgeneratorだけを学習する、という二段階の処理です。

discriminatorの学習では、x_trainからランダムにピックアップしたreal画像とgeneratorで生成したfake画像をそれぞれbatch_size個用意して、それらを連結したデータ(Xとy_dis)を作成して与えています。また、ノイズ画像から生成した画像をrealと判定するようなgeneratorにするために、ラベルを1としてgeneratorを学習しています。

def train(epochs=1, batch_size=128):
    # Get the training and testing data
    (x_train, y_train, x_test, y_test), x_height_width = load_mnist_fashion()
    # Split the training data into batches of size 128
    batch_count = x_train.shape[0] // batch_size

    # Build our GAN netowrk
    adam = get_optimizer()
    generator = get_generator(adam, output_dim=x_train.shape[1])
    discriminator = get_discriminator(adam, x_train.shape[1])
    gan = get_gan_network(discriminator, random_dim, generator, adam)

    for e in range(1, epochs + 1):
        print('-' * 15, 'Epoch %d' % e, '-' * 15)
        for _ in tqdm(range(batch_count)):
            # Get a random set of input noise and images
            noise = np.random.normal(0, 1, size=[batch_size, random_dim])
            image_batch = x_train[np.random.randint(
                0, x_train.shape[0], size=batch_size)]

            # Generate fake images
            generated_images = generator.predict(noise)
            X = np.concatenate([image_batch, generated_images])

            # Labels for generated and real data
            y_dis = np.zeros(2 * batch_size)
            # One-sided label smoothing
            y_dis[:batch_size] = 1.0

            # Train discriminator
            discriminator.trainable = True
            discriminator.train_on_batch(X, y_dis)

            # Train generator
            noise = np.random.normal(0, 1, size=[batch_size, random_dim])
            y_gen = np.ones(batch_size)
            discriminator.trainable = False
            gan.train_on_batch(noise, y_gen)

        if e == 1 or e % 20 == 0:
            plot_generated_images(e, generator, train_shape=x_height_width)

生成された画像

batch_size=128で、300epoch学習を回した結果。

  • Epoch1では、まだ単なる矩形画像にしかなっていない

f:id:satojkovic:20180521235550p:plain

  • Epoch100では、各アイテムがかなり分かる程度になっているが、一部単なる矩形画像が残っている

f:id:satojkovic:20180521235705p:plain

  • Epoch300では、各アイテムがはっきりと分かる程度に生成された

f:id:satojkovic:20180521235922p:plain

レポジトリ

github.com

KerasでSemantic segmentation

画像ではなく、ピクセル単位でクラス分類するSegmentationのタスク。 fast.aiにあるtiramisuが実装もあって分かりやすいので試してみた。下記のコードスニペットは、fast.aiのオリジナル実装ではなく、keras2で書き直されたjupyter notebookのコードをベースに、自分で若干の手直しをしたものを使っている。

github.com

tiramisu

Down-sampling path

  • Down-sampling pathの構成と実装
    • DB(Dense Block)とTD(Transition Down)の繰り返しで特徴抽出
    • DBの最後の出力特徴マップをUp-sampling pathのためのskip connectionとして保存
def down_path(x, nb_layers, growth_rate, keep_prob, scale):
    skips = []
    for i, nb_layer in enumerate(nb_layers):
        x, added = dense_block(nb_layer, x, growth_rate, keep_prob, scale)
        skips.append(x)
        x = transition_down(x, keep_prob, scale)
    return skips, added
  • DBの構成と実装
    • BN(Batch Normalization)、ReLU、3x3 same convolution(とdropout)を組み合わせたLayerで特徴マップを生成
      • growth rateが特徴マップ数を表す
    • 特徴マップを入力に連結(concat)
    • 次のDBのために新しく生成した分の特徴マップを保存
def dense_block(nb_layers, x, growth_rate, keep_prob, scale):
    added = []
    for i in range(nb_layers):
        b = conv_relu_batch_norm(
            x, growth_rate, keep_prob=keep_prob, scale=scale)
        x = concat([x, b])
        added.append(b)
return x, added
  • TDは空間解像度の削減、要はpoolingと同等の処理
    • 論文では1x1 convolutionと2x2 poolingの組み合わせが提案されているが、fast.aiではstrideが2の1x1 convolutionを提案。
def transition_down(x, keep_prob, scale):
    return conv_relu_batch_norm(
        x,
        x.get_shape().as_list()[-1],
        ksize=1,
        scale=scale,
        keep_prob=keep_prob,
        stride=2)

Up-sampling path

  • Up-sampling pathの構成と実装
    • TU(Transition Up)、skip connectionとの連結(concat)、DBの繰り返しで入力画像サイズに復元
def up_path(added, skips, nb_layers, growth_rate, keep_prob, scale):
    for i, nb_layer in enumerate(nb_layers):
        x = transition_up(added, scale)
        x = concat([x, skips[i]])
        x, added = dense_block(nb_layer, x, growth_rate, keep_prob, scale)
    return x
  • TUは特徴マップをupsamplingするtransposed convolutionで構成
    • 入力と同じch数で、3x3 kernel size、strides 2で2倍の特徴マップを生成
def transition_up(added, scale):
    x = concat(added)
    _, row, col, ch = x.get_shape().as_list()
    return Conv2DTranspose(
        ch,
        3,
        kernel_initializer='he_uniform',
        padding='same',
        strides=(2, 2),
        kernel_regularizer=l2(scale))(x)

学習

  • 56層バージョン。FC-DenseNet56
  • OptimizerはRMSProp、学習率の初期値は1e-3、学習率減衰は0.00005
  • L2正則化の係数1e-4、Dropout rateは0.2、growth rateは12
  • データセットはcamvid、360x480から224x224をクロップして入力画像とする
  • batchsizeは10、epochsは30で実行

結果

  • 24epochでvalidation accuracyが85%
  • 結果画像を見ると、道路と白線は分類出来ているようだが、車と背景との分離はもう一歩。Ground-Truthにはない画像右上の柱が推定出来ている。
  • パラメータ数が少ないモデルという特徴があり、実際にGPUの無いMacでも学習を実行出来るのは良い点。

f:id:satojkovic:20180501234738p:plain

WIndowsでTensorflow C++ APIを使う準備

TL;DR

WindowsでTensorflowのC++ APIを使わなければいけないケースというのはそれほど多くないと思うし、どうしてもという理由がなければやらない方が良いと思う!

ステップ

基本は、下記のブログを参照すれば出来る。

tadaoyamaoka.hatenablog.com

  1. cmakeを使って、VS2015のソリューションを生成
    • Tensorflowのソースディレクトリはtensorflow/tensorflow/contrib/cmake
    • ソリューションファイルの生成先は、例えばtensorflo/cmake_buildを作って指定する
    • CUDAは9.0で、CuDNNは7.0
  2. configureして、swigが見つからないというエラーが出たらswig.exeのパスを指定
    • swigwin-3.0.12をダウンロード
    • swigwin-3.0.12/swig.exeを指定するだけ
  3. 再度configure、configure doneになったらgenerate
  4. generate doneになったらOpen projectでソリューションファイルを開く
  5. x64でReleaseビルド

注意点

  • ビルドが終了するまで数時間はかかる
  • メモリ16GBのマシンでビルドしたら、いくつかのプロジェクトでリソース不足的なエラーでビルド出来てなかった。結構なハイスペックが要求されそう。

あなたは世界を変えない

Ruby on Railsの作者DHHさんのmediumエントリー。最近読んだ中でも一番良いなと思った。

TwitterFacebookも世界を変えよう❗という明確なミッションを持って始まったわけではないし、そういうミッションを持つことは大変な重荷。壮大な幻想(ミッション)を持つことで、毎日毎日会議を繰り返す日々に無理やり意味をもたせようとしているのでは?みたいな指摘も。

Set out to do good work. Set out to be fair in your dealings with customers, employees, and reality. Leave a lasting impressions on the people you touch, and worry less (or not at all!) about changing the world. Chances are you won’t, and if you do, it’s not going to be because you said you would.

良い仕事をしよう、顧客や従業員、現実に対してフェアでいよう、関わる人に対して良い印象を残そう。そういうことに集中することの方がより大事だよというアドバイスでした。

I have no illusions that my time in this world is going to be remembered through the ages. When it’s over, I’ll be so fortunate as to have left an impression on my friends, my family, and a few colleagues in the industry. And those impressions will fade quickly, so they aren’t even worth elevating much.

私の人生は世代を通じて思い出されるという幻想は持ってない。終わったときに、友達や家族、何人かの同僚に思い出してもらえれば相当幸せだろう。それらの印象もいずれは薄れていくし、もてはやすようなものではない。

相当なオッサンになってきたので、こういう考えもそうだよなぁと思うようになった。

P.S. いくつかmediumでエントリーを見たけど、DHHさんの英語って難しくないですか❓独特の言い方というか、自分が知らないだけなのか…