自習室

こもります

Kinect v2 のデプス画像をそのままの解像度で点群としてUnityで表示する

f:id:AMANE:20171227222450p:plain

できあがった物がこちら

youtu.be

はじめに

遙か遠い昔(3年前) Kinect v2 のデプス情報を Unity で描画するシリーズをやっていました

前者では、単純に画像を表示しただけでした。後者ではポイントクラウド風の描画にチャレンジしていますが、当時はUnityの仕様的に、またマシンスペック的に点数を間引かざるを得ませんでした。

2017.3 の新機能「32 bit Mesh index buffers」

この新機能の登場で、65536点以上のメッシュを一つのオブジェクトとして利用出来るようになりました。

先に keijiro さんがこの機能を利用して、PCLのデータをインポートするエディタ拡張を作られていて、これが出来るなら Kinect V2 のデプスデータも似たような感じで扱えるのではと思い、今回のネタに着手しました。

github.com

大まかな方策

以下の作戦で行けると踏んで作業を始めました。結果、おおよそ行けました。

  • Kinect v2のデプスデータは画像の形式で得られる。各ピクセル輝度値(= 奥行き)に基づいてVertex Shader で点を動かす
  • Kinect v2 は 512x424 = 217088 点のデータなので、32bit のインデックスに収まる。これを一つのメッシュとして描画する

イメージは下の図のような感じです。大きな値ほど、遠くに頂点が移動します。

f:id:AMANE:20171226210541p:plain

実装 - C#の部分

作成したプロジェクトを公開しています。

github.com

ビルド・実行

ビルド・実行の仕方については、上記リポジトリの Readme.md に記載しています

512x424 頂点のメッシュを用意する

f:id:AMANE:20171226214001j:plain

初めBlender でそのようなデータを用意しようかと思ったのですが、柔軟で良いし勉強になると思いコードで作ることにしました。こちらの記事を参考にさせて頂きました。

上を参考に書いたコードはこちらになります

詳細はコードを読んでいただきたいのですが、ポイントだけ整理しておきます。

32 bit Mesh index buffers を有効にする

2017.3 から追加された 32 bit Mesh index buffers を有効にするために、以下の指定を行います。これで、頂点数が32bit… 4,294,967,296 まで拡張されます。

mesh.indexFormat = UnityEngine.Rendering.IndexFormat.UInt32;
テクスチャ座標を 0~1 ではなく、半ピクセル分シフトさせる。

これにより、すべての頂点とすべてのデプス画像の画素を1対1で対応づけます。

Vector2[] uv = new Vector2[w * h];
for (var y = 0; y < h; y++)
{
  for (var x = 0; x < w; x++)
  {
    uv[y * w + x] = new Vector2(((float)x + 0.5f) / (float)w, ((float)y + 0.5f) / (float)h);
  }
}
mesh.uv = uv;

ただしこの工夫、やらなくてもぱっと見差がありません。シフトをやらない方の関数も一応用意しました Mesh MeshGenerateNormal(int w, int h, float pitch)

頂点のみ描画する(ポイント指定)

こちらの記事を参考にさせて頂きました。 この記事のスクリプトを、メッシュを含む GameObject に貼り付けるだけです

Kinect v2 のデプス画像データを、16bit の画像として上記メッシュに貼る

f:id:AMANE:20171226214142p:plain

デプスのデータを取ってくる部分は、 Kinect for Windows SDK 2.0 Unity Pro Add-in のサンプルままですので説明は割愛します。できあがりのコードを見つつポイントだけ説明します。

ushort 配列としてデプス画像を取得する

デプス画像は ushort の値を1ピクセルとして、512x424 要素の配列で表現されます。ushort は 2byte = 16bit です。

// L29
_Data = new ushort[frameDesc.LengthInPixels];

// L53
frame.CopyFrameDataToArray(_Data);
Unity のテクスチャデータにする

Unity には GRAY 1ch 16bit 深度のテクスチャとして TextureFormat.R16 があるので、これを使います。

// L30
_RawData = new byte[frameDesc.LengthInPixels * 2]; // ushort 配列を byte 配列として扱う
_Texture = new Texture2D(frameDesc.Width, frameDesc.Height, TextureFormat.R16, false); // R16 の形式でテクスチャを用意する
// L58
Buffer.BlockCopy(_Data, 0, _RawData, 0, _Data.Length * 2); // ushort 配列をそのまままるっと byte 配列にはめ込む
// L62
_Texture.LoadRawTextureData(_RawData); // 毎フレーム、byte配列を元にテクスチャを更新する
_Texture.Apply();

実装 - Shaderの部分

これで、メッシュを動かすのに必要な情報は、シェーダ側に渡されました。ここからはシェーダでの記述になります。できあがりのコードは以下です。

このシェーダでは、メッシュの頂点数とテクスチャの画素数が一致していることを前提として、その画素数 = 頂点数分だけ vertex shader がまわり、各頂点を画素のデータに基づき奥に動かす、と言うことをしていきます。ここも重要な箇所だけ見ていきます。

奥行きデータを再構成する

_Displacement は奥行き感を調整するための係数です。用意したメッシュのサイズなどと照らし合わせて適宜調整して下さい。(実際はMaterialの inspector から調整出来ます)

// KinectDepthBasic.shader L72
float d = col.x * 4000 * _Displacement;

4000 という定数がありますが、これは今回他のテクスチャの使い方も試したときの名残ですのであまり気にしないでください

デプス画像座標系から、空間に展開する

CoordinateMapper.MapDepthFrameToCameraSpace が使えない件

本来 Kinect v2 SDK には、 CoordinateMapper.MapDepthFrameToCameraSpace という関数があり、これを使うことでカメラの正確な諸元によって深度画像を3次元上の点に変換出来ます。(以前書いた記事ではそれを使いました

しかし今回は、1ch 16bit テクスチャとしてシェーダに送り込みたいので、事前に変換してしまうと、以下の様な問題が起きます

  • 1点につき xyz それぞれ float の値として表現されてしまう
    • これはデータをchar形式とかに圧縮させることで回避可能だが…
  • CoordinateMapper.MapDepthFrameToCameraSpace の結果はそもそも、 CameraSpacePoint という実質 Vector3 形式の配列で与えられるため、圧縮作業を行うためには要素数だけ for 文を回す必要がある。for文を回した時点で、高速化は見込めない。

というわけで、今回は C# …というかSDKの層での3次元展開は諦めます。

Vertex Shader で模擬して展開する

正確では無いかも知れませんが、「それっぽく見せたい」程度で良ければ、自力で変換してしまえば良いです。図を見ながら説明します。

f:id:AMANE:20171226223839p:plain

水平面でまず考えますが、垂直面も同じです。 Kinect V2 のデプス画像の水平視野角は 70deg なので、画像の左端のx座標を 256 (=デプス画像横幅512の半分) と置いたとき、デプス画像は D= 365.6 の高さの四角錐の底面に投影されている、と表現できます。

各画素について、Kinect v2 から得られるデータとして、画像上でのX軸の座標を x , 奥行きを d とすると、実際のX軸座標は x' = x * d / D で表すことが出来ます。

これをy座標についても行います。実際のシェーダの記述は以下の様になります

// KinectDepthBasic.shader L57
v.vertex.x = v.vertex.x * d / 3.656;
v.vertex.y = v.vertex.y * d / 3.656;
v.vertex.z = d;

(おまけ)奥行きに合わせてHueを回して着色する

f:id:AMANE:20171227212959p:plain

このままだとすべての点が単色で表示されます。空間中に点があるのでこれでもわかりますが、せっかくなのでより奥行きがわかりやすくしてみます。

hsv 表色系の hue の値を奥行きに連動させるようにします。 0~1 でhue が虹色のように一周し元の色に戻ってきます。

// L25 vertex shader で位置情報をもとに色を計算して、fragment shaderに渡せるよう構造体に color の情報を加える
struct v2f
{
  float2 uv : TEXCOORD0;
  float4 vertex : SV_POSITION;
  float4 col : COLOR;
};

// 中略

// L40 HSV から RGB に変換する関数
// 参照→ http://www.chilliant.com/rgb2hsv.html

float3 HUEtoRGB(in float  H)
{
  float R = abs(H * 6 - 3) - 1;
  float G = 2 - abs(H * 6 - 2);
  float B = 2 - abs(H * 6 - 4);
  return saturate(float3(R, G, B));
}

float3 HSVtoRGB(in float3 HSV)
{
  float3 RGB = HUEtoRGB(HSV.x);
  return ((RGB - 1) * HSV.y + 1) * HSV.z;
}

// 中略
// L86 
// 距離に応じた色をつけてあげる
float3 hsv = float3(1, 1, 1);
hsv.x = v.vertex.z % 1.0;
float3 rgb = HSVtoRGB(hsv);
o.col = float4(rgb.xyz, 1);

実装は以上です。

最後に

制約

今回の実装にはいくつか制約がありますので、ご注意下さい。

  • Kinect SDK の公式な Coordinate mapping をしていないので、点群の位置が不正確
  • Color 情報と点群のマッピングもしたいけど、それができる設計になっていない(自力でシェーダ内で実装できるか?)

感想

しかし、結果としては高速に大量の点を描くことが出来るようになったので満足です。メディアアート的なアプリにはもってこいなのではないでしょうか。

上記制約を解消したり、実装面でイケてない所とか見つけた人、これを使ってなにかおもしろいことをやった人がいたらぜひおしえてください〜