stMind

about Tech, Computer vision and Machine learning

RegressionベースとHeatmapベースではどちらが良い?

モバイル向けのSingle person 2D ポーズ推定のレポジトリのIssueなのですが、興味深い内容だったので紹介します。

github.com

CNNが前提だと思いますが、Keypoint localizationを行う方法として、

  • DeepPoseなど、関節位置を回帰するRegression
  • OpenPoseなど、関節毎のHeatmapを生成し、L2ロスを計算する
  • Mask R−CNNなど、関節毎のHeatmapを生成し、関節位置だけ1となるone hot maskとのcross entropy lossを計算する

のうち、どれが良いのか?という問いかけでした。

作者いわく、このレポジトリでは、まず推定速度だけを考慮してRegressionで実装したようですが、RegressionよりもHeatmapベースの方がmAPとしては良いのではないかなぁ、検証したわけではないけど、という回答をしてます。

質問者も、ここ最近の論文を見ていると、Heatmapベースの方が良さそうというのは同意しつつ、Heatmapベースの中だったら、L2ロスの場合とcross entropy lossだとどちらが良いのかなぁ、と言いつつスレッドが終了していました。

ICCV2015で発表された論文(下記リンクはスライド)でも、Coordinate net(Regression)とHeatmap net(L2ロス)として比較していますが、Heatmapの方が明らかにAccuracyが高く、学習データを増やした場合でもRegressionの方は向上があまり見られないのに対して、Heatmapの方は大幅に改善する結果となっていますね。

http://lear.inrialpes.fr/workshop/allegro2015/slides/zisserman01.pdf

Heatmapの方が優れている点として、学習結果の可視化により解釈が容易になることと、信頼度をモデル化して各ピクセルが保持していて、学習を進めると間違った推定のピクセルが徐々に抑制されていく、ということが論文で述べられていて、結果からみても納得な感じです。

HeatmapのL2ロスとcross entropy lossはどちらがいいか?については、Issueのスレッドの最後で、3つの方式を比較した論文のリンクがはられていました。次は、この論文を読んでみようと思います。

https://arxiv.org/pdf/1711.08229.pdf

MoveMirrorで使われているPose Estimation Algorithm

ポーズをとると似た姿勢を探してくれるブラウザベースのアプリ。面白いなぁ。あと、ブラウザだけでできるのも良いなぁ。

姿勢の推定に使われているのは、Googleの研究チームが発表した論文が基になってます。 さくっと読んでみた内容メモです。

[1701.01779] Towards Accurate Multi-person Pose Estimation in the Wild

medium.com

どのようなアルゴリズム

いわゆるTop-down方式の姿勢推定。Top-down方式は、最初に人の矩形を検出しておき、その中から関節を検出する。それに対してBottom-up方式があり、こちらは画像全体から関節を検出し、検出された関節同士を関連度マップなどに基づいて対応付けしていく方式。OpenPoseはBottom-up方式。

処理の全体像

f:id:satojkovic:20180721193552p:plain

Top-down方式なのでStage1で人の検出、Stage2で関節の検出、となるのだけど、もう少し詳しく記述すると次のような処理を行う。

  1. Stage1では、ResNet101 + atrous convolutionのFaster RCNNで人の矩形を検出。矩形を検出したら、Stage2の矩形アスペクト比に合うように幅もしくは高さを伸ばし、かつランダムに矩形枠を広げた後、353h x 257wにリサイズ。
  2. Stage2もResNet101を使って、heatmaps(K joints)とoffset maps(xとyの2ch * K joints)を生成。最後に、heatmapsとoffset mapsを統合して、より正確なjointを表現するmapsを出力。

Stage2で使うResNet101 + atrous convolution with stride 8は、最終層を1x1 convで3 x K個のFeature mapsを出力するように置き換え(Kは関節数で、ここではK=17)、最後にbilinear upsamplingで入力と同じサイズにリサイズされる。

heatmapとoffset heatmapによる関節のdetection and localization

Stage2の関節推定で、HeatmapとOffset mapを使うのが、この論文の新しいアイデア

Heatmapは、関節位置から一定範囲内のピクセルが1、それ以外が0となるmap。もう一つのOffset mapは、関節位置から一定範囲内のピクセルについて、関節位置に対するオフセットを保持するmap。

学習においては、ResNetの出力する3xKのfeature mapsについて、heatmapの出力mapにsigmoidを適用し、関節毎に各ピクセルのlogistic lossの総和をとったものと、XとYのoffsetの出力feature mapに対して、ground truthとの差を計算したHuber lossとの重み付き和が全体のロスとなる。(論文の式(3)を参照)

f:id:satojkovic:20180724220343p:plain

真ん中の図が、左肘のheatmapで赤が1の領域を表している。一番右の図が、対応するoffset mapで、L2ノルムをgrayscaleで表しているので中心に行くほど値が小さくなっていて、外側の方が値が大きくなっていることがわかる。赤い矢印は、offsetベクトルを示している。

このように二つにわけて学習しているのは、Faster RCNNなどの物体検出の枠組みから着想を得たとのこと。キーポイントを推定する一つの方法は、DeepPoseのようなRegressionのネットワークを使う方法があるが、矩形に複数人物がいた場合には、一人分の関節数しか出力できない問題がある。また、関節毎のactivation mapを出力するネットワークを使う方法があるが、関節位置の正確さが出力のmapサイズに依存する。そこで、ピクセル単位で関節の近傍か否か(関節か)の分類と、関節位置をリファインするオフセット値の回帰に分けて、全身の関節位置推定という難しい問題を学習しやすくしている。

Heatmapとoffset mapから関節位置の決定

heatmapとoffset mapが得られたら、関節位置をどのように計算するのか?

heatmapの各点に対して、

  1. offset mapの値を使って、オフセット値を加えた位置を計算
  2. 関節毎にその位置で重みを累積

下の図は、すべての関節を一枚の画像で表した状態。オフセットによって、より関節候補位置がsureになっている様子がわかる。

f:id:satojkovic:20180729200008p:plain

activation mapで最もスコアの高い位置を、各関節位置として求めることができる。

結果

このようなheatmapとoffsetを使った関節位置推定の精度はどうなのか?

COCO Keypointのtest-devとtest-standard setsで2016年のCOCO keypoints challengeのwinnerを超える結果となった。

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