Last-modified: Tue, 05 Feb 2013 12:00:33 JST
Counter:9024 Today:2 Yesterday:1 Online:11
このエントリーをはてなブックマークに追加

Kinectで人物のみ映った画像を表示する

About

0.png

 Kinectを利用すると、カメラ画像中の人物の映っている画素領域が検出できます。これを利用すると図のように"人物のみ映った画像"を取得・表示することができます。特別な環境が無くても、映画などの撮影で利用されるブルーバック(グリーン)の様な効果が得られるということです。身近な所では天気予報などに使われている所もあります。Microsoft公式のサンプルにも同種のものが与えられますが、ここでは異なる手法でアプローチしています。
掲載しているソースコードはXNAの物ですがWPF版もそれ程変わりません。

How to

利用するデータの取得と準備

 ユーザの領域だけ抽出するためには、カメラ画像データの他に、深度データに含まれるUserIndexが必要です。これらの取得に関しては今さらなのでここでは言及しません。このサンプルではAllFrameReadyで双方とも取得しています。ここでは更に、ユーザのマスク画像を保存するためのbyte配列を用意しています。マスク画像のみが必要な場合は用意する必要がない実装方法もありますが、これについては後述します。

            //必要なデータ長をもった配列を用意する
            //画素全てを保存する配列
            byte[] colorDataArray = new byte[colorImageFrame.PixelDataLength];
            //ユーザの画素のみ保存する配列
            byte[] userColorArray = new byte[colorImageFrame.PixelDataLength];
            //カメラ画像のデータ配列を保存する
            colorImageFrame.CopyPixelDataTo(colorDataArray);

            //必要なデータ長をもった配列を用意する(PixelDataLength = 640*480)
            DepthImagePixel[] depthImgPixArray = new DepthImagePixel[depthImageFrame.PixelDataLength];
            //深度データの配列を保存する
            depthImageFrame.CopyDepthImagePixelDataTo(depthImgPixArray);

マッピングとマスク

 以下に示すソースコードが今回のコア部分です。まずは後の処理のために深度画像の座標系を、カラー画像の座標系にマッピングしておきます。SDKver1.6からは「DepthImagePixel」が利用できるようになったので、多少は透明性がまして分かり易くなったでしょうか。

 次いで取得できたDepthImagePixelの分だけ処理を繰り返します。DepthImagePixelからPlayerIndexを取得し、それが0以外であった場合は、その画素にプレイヤー(人物)が映っているわけですから、その領域のみ、カラー画像を表示すれば良いことになります。i番目の深度画像の座標(画素)に対応する、カラー画像の座標(画素)は、先にマッピングした結果(colorImagePoints)のi番目から取得することができます。

 プレイヤが映っている画素領域のみ、出力すれば良いわけですから、各プラットフォームに合わせて対応する画素情報を操作します。ここで表示しているサンプルはXNAの物になります。ColorImagePointsを、対応する画素情報配列のindexに変換し、値を保存します。

 ここで深度画像の座標系からカラー画像の座標系へ変換した際に、得られたカラー画像の座標(画素)が、実際にKinectで映し出される画像の中に収まっていない可能性があります。そこで得られた座標が範囲外であれば、以降の処理をスキップする様に設計します。

            //カラー画像上で扱われる座標の配列を生成する
            ColorImagePoint[] colorImagePoints
                = new ColorImagePoint[kinect.DepthStream.FramePixelDataLength];

            //深度情報を、カラー画像の座標上にマッピング(対応付け)する
            //※colorImagePointsにデータが出力される
            kinect.CoordinateMapper.MapDepthFrameToColorFrame
                (kinect.DepthStream.Format, depthImgPixArray,
                kinect.ColorStream.Format, colorImagePoints);

            //深度画像の画素数の分だけ処理を実行する
            for (int i = 0; i < depthImgPixArray.Length; i++)
            {
                //プレイヤがいないと判定された画素領域はスキップする
                if (depthImgPixArray[i].PlayerIndex == 0)
                    continue;

                //現在の深度画像上の座標に対応する、カラー画像上の座標を取得する
                ColorImagePoint colImgPnt = colorImagePoints[i];

                //得られたカラー画像上の座標が、Kinectで撮影される範囲外なら処理をスキップする
                if (colImgPnt.X >= kinect.ColorStream.FrameWidth || colImgPnt.X < 0
                    || colImgPnt.Y >= kinect.ColorStream.FrameHeight || colImgPnt.Y < 0)
                    continue;

                //カラー画像上の座標を、対応するbyte配列のindexに変換する
                int colorDataIndex =
                    ((colImgPnt.Y * kinect.ColorStream.FrameWidth) + colImgPnt.X)
                    * kinect.ColorStream.FrameBytesPerPixel;

                //ユーザの色情報を保存する。XNAではRGBAの順で画素情報を扱うので注意。
                //Kinectから送られるデータはBGR(A)なのでBRを反転する
                userColorArray[colorDataIndex] = colorDataArray[colorDataIndex + 2];//R
                userColorArray[colorDataIndex + 1] = colorDataArray[colorDataIndex + 1];//G
                userColorArray[colorDataIndex + 2] = colorDataArray[colorDataIndex];//B
                userColorArray[colorDataIndex + 3] = 255;//A

                //※隣の画素の情報も更新しておくことで隙間をなくす
                //★この箇所は後の項目で解説します。
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel]
                    = colorDataArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 2];
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 1]
                    = colorDataArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 1];
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 2]
                    = colorDataArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel];
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 3] = 255;

 ユーザの画素領域のみを抽出した配列userColorArrayをここでは用意していますが、取得した全てのカラー画像情報を持った配列から、ユーザの画素領域以外を透過してしまえば、同じ結果画像が得られます。元の全て映った画像は無くなってしまいますが、メモリの確保を考えるならその様な実装方法が良いでしょう。恐らく全て映った画像と、ユーザのみ映った画像と双方が必要になる実装は稀でしょうし。

不具合・原因・解決

不具合

1.png

 さて、先の項目で人物の画素領域のみを表示するプログラムは完成しますが、ソースコードの最下部に記述した箇所だけ解説していません。実際の所、該当部を除いてしまっても、それらしい結果画像は得られますが、不具合が生じます。ちょうど画像のように、得られた人物の画素領域の一部に抜けが生じます。分かり易く○を付けていますが、特に黒いズボンの所がハッキリ分かるでしょうか。明らかに取得できている領域内でこの様な抜けが生じてしまいます。

 原因の考察に関しては理屈が確かではない可能性があります。また理解しなくても先のソースコードの通りに扱えば、不具合は生じません。

原因

2.png

 Kinectでは赤外線を利用して深度情報を取得しているわけですが、その赤外線の受光部と、カラー画像を撮影するカメラの位置は物理的にズレています。また赤外線は放射状に照射されているわけですが、これを受光する際に生じる歪みと、カメラがカラー画像を撮影した際に生じる歪みが一致していません(後述参考)。
 カメラが好きな人は樽型歪み(Barrel Distortion)をご存知かと思います。そうでない人も、一眼レフカメラで撮影された画像が円状に湾曲している画像を見たことがあるでしょう。最たるものが魚眼レンズです。先ほどから"赤外線を受光する"と述べていますが、正確にはカメラも光を受光しています。その光がレンズによって歪み、完全に平行に映した画像が得られないわけです。この現象がKinectの赤外線の受光時にも生じていているのです。

 ここでMap関連のメソッドは、ある座標系を異なる座標系へ変換するためのメソッドです。"先に説明した歪みを調整し、カメラの位置と赤外線受光部の位置が一致するように平行移動させる様な処理"が、内部的に行われていると思われます。その過程において、図に示したような「"マッピングのズレ"が生じると、問題にしている様な"画素領域の抜け"が生じる」のだと考察しました。本来は一対一でマッピングされるはずの深度画像とカラー画像の画素領域ですが、マッピングの算出の際にズレが生じてしまうと、対応するカラー画像の画素が取得できず欠損します。実際には人物が映っている"深度"画素領域を特定できているのですが、変換する際に誤差が生じるわけです。小数レベルで計算されても画素に落とし込む場合には整数化されるわけですから、もしかしたらそこで値が巻かれて誤差が生じるのかもしれません(定かではありません)。

 実際に変換後の座標を出力してみると、座標の重複を確認することが出来ます。また赤外線とカメラ画像の歪み方が異なることは明らかで、得られた深度画像をカメラ画像に全てマッピングすると、赤外線カメラが撮影する範囲が、カメラ画像に対して明らかに狭いことは周知のとおりです。

解決

 問題について考察・解説しましたが、Microsoft公式のサンプルではこの様な不具合が見られませんでした。どうやって解決しているのかと覗いて見れば、隣の画素領域も人物が映っている画素と見なしてしまい抜けを埋めるという力技でした。これで凡その抜けに対応できる様です。そういう訳で、先のソースコードのように、隣の画素も埋めるように実装しています。

                //ユーザの色情報を保存する。XNAではRGBAの順で画素情報を扱うので注意。
                //Kinectから送られるデータはBGR(A)なのでBRを反転する
                userColorArray[colorDataIndex] = colorDataArray[colorDataIndex + 2];//R
                userColorArray[colorDataIndex + 1] = colorDataArray[colorDataIndex + 1];//G
                userColorArray[colorDataIndex + 2] = colorDataArray[colorDataIndex];//B
                userColorArray[colorDataIndex + 3] = 255;//A

                //※隣の画素の情報も更新しておくことで隙間をなくす
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel]
                    = colorDataArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 2];
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 1]
                    = colorDataArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 1];
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 2]
                    = colorDataArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel];
                userColorArray[colorDataIndex - kinect.ColorStream.FrameBytesPerPixel + 3] = 255;

【ツッコミ1】
 ここでcolorDataIndexが、カメラの映す領域外に抜けてしまう可能性があります。が、実際にマップしてみると分かりますが、深度画像が映している領域は、カメラが映すカラー画像領域より狭いので、そもそもの変換先が枠内に収まるのであれば条件判定しなくても不都合なく処理できる様です。仮に実装したとして、条件判定が画素の分だけ生じるわけですから、大分処理が重くなってしまいます。折角なので削れるものは削りましょう。重複しない隣の画素領域を埋める処理がそもそも非効率に見えますが、条件判定文よりはマシと割り切ります。

【ツッコミ2】
 隣の画素領域を埋めるのは良しとして、この様な実装形態では、実際にユーザの画素領域と判定される領域よりも、左側に1ピクセル分、取得される画素領域が増えます。つまり余計なモノが映り込む訳です。1ピクセルと割り切って諦めるか、外枠の領域だけ帳尻を合わせる効率の良いアルゴリズムを見つけて実装するしかありません。手はいくつか考えられますが、私は気にならないので試験していませんし割愛します。

MS公式がこの様な形式(SDK1.5時点)で抜けを解決していることから、原因の考察もあながち間違いではないのかもしれません。