blog

DeNAのエンジニアが考えていることや、担当しているサービスについて情報発信しています

2017.11.21 技術記事

Chainerを用いたRealtime Multi-Person Pose Estimation実装

by Tianqi Li

#AI

はじめに

皆さんこんにちは。DeNAのAI研究開発エンジニアの李天琦( leetenki )です。今日は Chainer Advent Calendar 2017 の初日エントリという事で、Realtime Multi-Person Pose Estimationの実装について解説させて頂きます。

pose estimationの実行結果

背景

Realtime Multi-Person Pose Estimationとは、CVPR2017でCMUが発表した、RGBの2次元画像のみから複数人のPose情報を検出するアルゴリズム (Cao, et al., 2017) です。特徴は、1枚の画像から複数人のPoseを検出するために、それまで主流であったBounding Boxを検出した後に各Boxに対してPose検出するというトップダウン方式を取らずに、ボトムアップかつワンショットに複数人のPoseを推定してしまう点です。画像に映ってる人数に関わらず1回の推論でPose推定を行うので、Realtimeに処理できるほど高速という訳です。また、1回の推論でPoseまで検出できるので、Bounding Boxから検出するトップダウン方式に比べると誤差の蓄積がなく、精度も著しく向上しています。事実こちらのアルゴリズムは2016 MSCOCO Keypoints Challengeで優勝し、この時点においてのstate-of-the-artを記録しています。

CMUのオリジナル実装はCaffeをベースにした openpose というライブラリで公開されています。TensorFlowやPyTorchによる再現実装も有志で行われているようですが、Chainer実装で公開されているものはなかったので、今回はこれをChainer化していこうと思います。コードは こちら を参照してください。

モデル解説

実装の話に入る前に、まずはモデルの構造を簡単に説明しておきます。

詳細は 元論文 を参照して頂ければと思いますが、このアルゴリズムのアーキテクチャ自体は非常にシンプルです。以下のモデル図にあるように、ネットワーク構造は1 × 1、3 × 3、7 × 7のConvolution 及びPooling Layerのみで構成されています。

Architecture of the two-branch multi-stage CNN (Cao, et al., 2017)

入力画像を、まずはVGG19とほぼ同じ構造のCNNに通して、解像度を8分の1に圧縮した特徴マップを抽出します。その後段は2つのブランチに分岐しており、1つはConfidence Mapsと呼ばれる、体の各key pointをheatmap形式で予測するネットワークです。下図のように、key pointの種類ごとに1channelの出力で予測します。

Part Confidence Maps (Cao, et al., 2017)

もう一つのブランチが、PAFs (Part Affinity Fields) と呼ばれる、各key point間の繋がりうる可能性を表すベクトルマップを予測するネットワークです。繋がりが定義されているkey point間を結ぶ線分上(正確には一定の幅を持つ領域)の全てのピクセルにおいて、一定の長さを持つ方向ベクトルが定義されます。このベクトルはxとyの2枚チャンネルのheatmapによって表現されます。下図の例では、オレンジ色となっている部分が、肩から腕にかけての方向ベクトルのマップです。

出典: Part Affinity Fields (Cao, et al., 2017)

これら2つのブランチでConfidence Maps及びPAFsをそれぞれ予測した後、さらに予測した結果に最初のVGG19で抽出した特徴マップをconcatして、これを再度同じ構造のネットワークに繰り返し入力していきます。この繰り返しのネットワークをstageと言い、stageが進むほど精度があがる仕組みです。

モデルの実装

では実装の解説に入っていきましょう。MSCOCO Keypoints Challenge 2016で訓練済みの重みパラメータファイルが こちら で公開されていますので、今回まずこれをChainer用に変換して、推論の処理を実装して行きます。

先ほど説明したように、このアルゴリズムのネットワーク構造自体は非常にシンプルで、1 × 1、3 × 3、及び7 × 7のConvolution Layerのみで構成されています。ゆえに、chainer.links.caffe.CaffeFunctionを使えば簡単にcaffemodelを読み込む事ができます。ただし、CaffeFunctionを使ったcaffemodelの読み込みは非常に時間がかかるので、毎回使い回す事を考えて、一旦自前でChainerのLayer定義を書いて、これに重みパラメータを代入した状態でnpzファイルに書き出します。

ChainerのLayer定義ファイルは こちら です。以下のconv1_1からconv4_4_CPMの部分が最初のVGG19を使った特徴抽出器です。VGG19と同じ構造で、全て3 × 3のConvolution Layerとなっています。これによって画像サイズが8分の1に圧縮されたFeature mapが出力されます。

# cnn to make feature map
conv1_1=L.Convolution2D(in_channels=3, out_channels=64, ksize=3, stride=1, pad=1),
conv1_2=L.Convolution2D(in_channels=64, out_channels=64, ksize=3, stride=1, pad=1),
conv2_1=L.Convolution2D(in_channels=64, out_channels=128, ksize=3, stride=1, pad=1),
conv2_2=L.Convolution2D(in_channels=128, out_channels=128, ksize=3, stride=1, pad=1),
conv3_1=L.Convolution2D(in_channels=128, out_channels=256, ksize=3, stride=1, pad=1),
conv3_2=L.Convolution2D(in_channels=256, out_channels=256, ksize=3, stride=1, pad=1),
conv3_3=L.Convolution2D(in_channels=256, out_channels=256, ksize=3, stride=1, pad=1),
conv3_4=L.Convolution2D(in_channels=256, out_channels=256, ksize=3, stride=1, pad=1),
conv4_1=L.Convolution2D(in_channels=256, out_channels=512, ksize=3, stride=1, pad=1),
conv4_2=L.Convolution2D(in_channels=512, out_channels=512, ksize=3, stride=1, pad=1),
conv4_3_CPM=L.Convolution2D(in_channels=512, out_channels=256, ksize=3, stride=1, pad=1),
conv4_4_CPM=L.Convolution2D(in_channels=256, out_channels=128, ksize=3, stride=1, pad=1),

その後に続く以下のような2分岐されたConvolution Layerが、各StageにおけるPAFs及びConfidence Mapsの計算部分になります。ここではL1とついてるのがPAFsで、L2がConfidence Mapsになります。そして、stage1ではカーネルサイズ3 × 3のConvolution Layerで構成されていますが、stage2以降ではreceptive fieldを広げるために7 × 7のConvolutionに置き換わっています。stage3以降のネットワークも全てstage2と同じ構造となっています。

# stage1
conv5_1_CPM_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=3, stride=1, pad=1),
conv5_2_CPM_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=3, stride=1, pad=1),
conv5_3_CPM_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=3, stride=1, pad=1),
conv5_4_CPM_L1=L.Convolution2D(in_channels=128, out_channels=512, ksize=1, stride=1, pad=0),
conv5_5_CPM_L1=L.Convolution2D(in_channels=512, out_channels=38, ksize=1, stride=1, pad=0),
conv5_1_CPM_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=3, stride=1, pad=1),
conv5_2_CPM_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=3, stride=1, pad=1),
conv5_3_CPM_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=3, stride=1, pad=1),
conv5_4_CPM_L2=L.Convolution2D(in_channels=128, out_channels=512, ksize=1, stride=1, pad=0),
conv5_5_CPM_L2=L.Convolution2D(in_channels=512, out_channels=19, ksize=1, stride=1, pad=0),
# stage2
Mconv1_stage2_L1=L.Convolution2D(in_channels=185, out_channels=128, ksize=7, stride=1, pad=3),
Mconv2_stage2_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv3_stage2_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv4_stage2_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv5_stage2_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv6_stage2_L1=L.Convolution2D(in_channels=128, out_channels=128, ksize=1, stride=1, pad=0),
Mconv7_stage2_L1=L.Convolution2D(in_channels=128, out_channels=38, ksize=1, stride=1, pad=0),
Mconv1_stage2_L2=L.Convolution2D(in_channels=185, out_channels=128, ksize=7, stride=1, pad=3),
Mconv2_stage2_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv3_stage2_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv4_stage2_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv5_stage2_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=7, stride=1, pad=3),
Mconv6_stage2_L2=L.Convolution2D(in_channels=128, out_channels=128, ksize=1, stride=1, pad=0),
Mconv7_stage2_L2=L.Convolution2D(in_channels=128, out_channels=19, ksize=1, stride=1, pad=0),

次に、caffemodelをChainer用に変換します。変換用コードはこちらです。重みパラメータの代入部分は以下のようになります。

exec("chainer_model.%s.W.data = caffe_model['%s'].W.data" % (layer_name, layer_name))
exec("chainer_model.%s.b.data = caffe_model['%s'].b.data" % (layer_name, layer_name))

Convolution Layerの場合、W.dataとb.dataのみ代入すれば済みますので、これを全てのLayerに対して繰り返すだけです。

推論の実装

ネットワークの推論の実装はこちらです。実際にCNNの処理を行っている部分はこれだけです。

h1s, h2s = self.model(x_data)

RGB画像をCNNに通して、PAFsとConfidence Mapsの出力を得るだけですね。ただ、このアルゴリズムのミソはその後処理部分で、得られたPAFsとConfidence Mapsからスケルトン情報を再構築する部分が最も複雑です。では順を追って説明していきます。

① PAFsとConfidence Mapsのサイズ拡大
ネットワークから出力されるfeature mapは幅も高さも8分の1に圧縮されているので、まずはこれをresizeしてオリジナルの画像サイズに引き伸ばします。Chainer2.0からはchainer.functions.resize_imagesというFunctionが定義されたので、これを使うとVariableのまま計算できます。

② Confidence Mapsをガウシアン平滑化
8倍サイズに引き伸ばした直後のConfidence Mapsは、peak周りがデコボコしていて、山がハッキリしないので、これにガウシアンフィルタをかけてpeakを一定に平滑化します。scipy.ndimage.filters.gaussian_filterを使えば簡単に実装できるのでオススメです。以下がガウシアン平滑化の計算部分になります。

heatmap = gaussian_filter(heatmaps[i], sigma=params['gaussian_sigma'])

下図の左がガウシアンフィルタをかける前で、右がかけた後です。

ちなみに、これをVariableのままGPUを使って計算したい場合、chainer.functions.convolution_2dを使って、ガウシアンカーネルを手動で定義してあげれば実装できます。

③ Confidence Mapsからkey point座標を求める
ガウシアンフィルタをかけた後のConfidence Mapsは下図(右)のようになります。ここから、peakの(x, y)座標を求めます。実はこのConfidence Mapsからpeakの座標値を求める処理が意外に計算コストが高いのです。

実際にConfidence Mapsからpeak座標を求める処理は以下の部分になります。

map_left = xp.zeros(heatmap.shape)
map_right = xp.zeros(heatmap.shape)
map_top = xp.zeros(heatmap.shape)
map_bottom = xp.zeros(heatmap.shape)
map_left[1:, :] = heatmap[:-1, :]
map_right[:-1, :] = heatmap[1:, :]
map_top[:, 1:] = heatmap[:, :-1]
map_bottom[:, :-1] = heatmap[:, 1:]

peaks_binary = xp.logical_and.reduce(( heatmap >= map_left, heatmap >= map_right, heatmap >= map_top, heatmap >= map_bottom, heatmap > params[‘heatmap_peak_thresh’] ))

ここでは効率良く計算するために、Confidence Mapsを上下左右に1ピクセルずつずらしたheatmapを4枚用意します、オリジナルのConfidence Mapsと上下左右のheatmapを比較して、その全てより値が大きいピクセルをkey pointとして座標抽出するようにしています。

④ key point間のPAFsを積分
key pointが全て求まった後、関係あるkey pointだけをグルーピングして人のスケルトンを構築する必要があります。論文では、2種類のkey point間の考え得る全てのconnectionの組合せを実際に繋げてみて、その間のPAFsの積分値で同じグループか否かを判別します。ちなみに、そもそもなぜこのPAFsの積分を行うのかと言うと、訓練時に、関係あるkey pointの間には一定の方向ベクトルが定義され、関係ないkey point間ではゼロベクトルが定義されるので、推論する時にはこれを手掛りにkey point間のベクトルの方向及び大きさの合計を見れば、2つのkey pointが関係あるか否か判別できるのです。

PAFsの積分は元論文に書いてある通り、2点間を結ぶ線分上の各ピクセルにおいて、その水平方向ベクトルと実際の推論で求まったベクトル値の内積をとって、全部足し合わせるという手法です。

single limb with groundtruth positions (Cao, et al., 2017)

これを実際に実装しているのが以下の部分になります。params[‘n_integ_points’]というのは、2点間を何分割するかというハイパーパラメータで、今回は10に設定しています。

vec_unit = vec / vec_len
integ_points = zip(
    np.linspace(joint_a[0], joint_b[0], num=params['n_integ_points']),
    np.linspace(joint_a[1], joint_b[1], num=params['n_integ_points'])
)
paf_in_edge = self.extract_paf_in_points(paf, integ_points)
inner_products = np.dot(paf_in_edge, vec_unit)

⑤ connectionの選択
以上の④までで、各点間の候補となるconnectionはPAFsによって重み付けられた積分値を得る事ができました。最後はこれを使って有効なconnectionを選択していきます。本来であれば2種類のkey point間で考え得る全パターンの組合せを作り、そのトータルのPAFs積分値が最大となる組合せを選択すべきですが、これを愚直に実装すると人数が増えるにつれて計算量がO(n^2)で増えていきます。なので、今回はgreedy法を採用し、PAFs積分値を大きい順にソートして上から順に選択していきます。そしてそれ以上選べるkey pointがなくなった時点で打ち切るようにしています。以上の処理はcompute_connectionsという関数で実装しています。

推論処理の実装は以上です。モデル訓練の話はData AugmentationやPAFs生成と長くなりそうなので、また次回のパートⅡで書こうと思います。

実行結果

ではてきとうな画像を使って推論を試してみましょう。

完璧にPose認識できていますね。

人が増えても、遠くにいても問題ないですね。 ※ちなみに推論処理のスケールについて、entity.pyというファイル内で以下のように定義しているハイパーパラメータがあります。

'inference_scales': [0.5, 1.0, 1.5]

これは画像を0.5倍、1.0倍、1.5倍のスケールでそれぞれ推論し、その結果を平均するという意味です。速度と精度のトレードオフだと思いますが、この値を調整すればいろんなスケールに対して高精度にPose検出する事ができます。

今回、chainerで実装したRealtime Multi-Person Pose Estimationのコードは全て こちら で公開していますので、皆さん興味があればぜひご自分の環境で動かしてみてください。

参考文献

・Zhe Cao and Tomas Simon and Shih-En Wei and Yaser Sheikh. Realtime Multi-Person 2D Pose Estimation using Part Affinity Fields. In CVPR, 2017. arXiv:1611.08050 [cs.CV].

最後まで読んでいただき、ありがとうございます!
この記事をシェアしていただける方はこちらからお願いします。

recruit

DeNAでは、失敗を恐れず常に挑戦し続けるエンジニアを募集しています。