Imaginary Code

from kougaku-navi.net

ProcessingでKinectを使って遮蔽表現のあるARを作ろう

今日の話は,ARにおいて人や実物体の後方にCGがある時に,CGの一部または全体が隠れて見えなくなっている状態(オクルージョン)をきちんと表現するべく,Kinectを使ってみましょうというものです.
前回はマーカベースの手法で遮蔽を実現する方法を紹介しましたが,今回はそれのKinect版です.事前に形が知らされていない未知形状の物体や,変形する物体,人間の手や胴体に対してもオクルージョンが表現できるようになります.ビバ!Kinect



今日はこれの作り方と原理について解説します.あ,ちなみに僕本人です.

まずはソースコード

いきなりですがProcessingのコードです.Kinectを扱うライブラリとしてsimple-openniを使いました.simple-openniのセットアップについては説明を省きます.

import SimpleOpenNI.*;

SimpleOpenNI  kinect;
float   fov = 45.0;                    // Kinectの垂直方向の視野角
float   z0 = 0;                        // 視点から画像平面までの距離
color[] pixelBuffer = null;            // 描画内容を一時保存しておくためのバッファ
color   key_color = color(0, 255, 0);  // クロマキー合成用の背景色
float   angle = 0;                     // 回転角度

void setup() {
  size( 640, 480, P3D ); // 画面サイズの設定
 
  kinect = new SimpleOpenNI(this);            // SimpleOpenNIの初期化
  kinect.enableDepth();                       // 距離画像有効化
  kinect.enableRGB();                         // カラー画像有効化
  kinect.alternativeViewPointDepthToImage();  // RGBカメラと深度カメラの視点を一致させる
  
  pixelBuffer = new color[width * height];    // バッファの準備
  z0 = (height/2)/tan(radians(fov/2));        // 視点と画像平面までの距離
}

void draw() {
  kinect.update();       // Kinectのデータ更新
  background(key_color); // 背景色をキーカラーにする
  
  perspective( radians(fov), float(width)/float(height), 1.0, 10000.0);   // 視野の設定
  camera( width/2, height/2, z0, width/2, height/2, 0.0, 0.0, 1.0, 0.0 ); // 視点の設定
  lights();  // ライトON

  // Kinectを原点とする座標系での描画
  pushMatrix();
    translate( width/2, height/2, z0); // Kinectを原点とする座標系にするための移動と
    rotateX(PI);                       // 回転
    
    // 重畳したいCGオブジェクトの描画
    pushMatrix();
      translate( 0, 0, 2000 );  // Kinectから前方2000[mm]のところに
      rotateX(angle);           // X方向に回転し,
      rotateY(angle);           // さらにY方向に回転する
      noStroke();               // 輪郭線なしで
      fill(128, 128, 255);      // 青色の
      box(500, 500, 500);       // 500×500×500[mm3]の立方体を描画
    popMatrix();

    // デプスデータの描画
    noLights();         // ライトOFF
    stroke(key_color);  // キーカラーを設定
    drawDepth3D();      // Kinectで計測されたデプスデータを3次元的に描画    
    
  popMatrix();

  // ここまでの描画内容をバッファに保存
  loadPixels();
  arrayCopy(pixels, pixelBuffer);
  
  // 背景画像の描画
  background(0);
  ortho( -width/2, width/2, -height/2, height/2, 0, 1 );
  image( kinect.rgbImage(), 0, 0 );

  // さきほど描画した内容をクロマキー合成
  loadPixels();
  for (int i = 0; i < width * height; i++) {
    if (pixelBuffer[i] != key_color) {
      pixels[i] = pixelBuffer[i];
    }
  }
  updatePixels(); 

  angle += 0.2; // 回転角度を更新
}

/* デプスデータの点群を3次元的に描画する関数 */
void drawDepth3D() {
  int steps = 2;       // 点の間隔
  strokeWeight(3);     // 点のサイズ
  
  for (int y=0; y<kinect.depthHeight(); y+=steps) {
    for (int x=0; x<kinect.depthWidth(); x+=steps) {
      int index = x + y * kinect.depthWidth();
      if ( kinect.depthMap()[index] > 0) {
        PVector realWorldPoint = kinect.depthMapRealWorld()[index];
        point(realWorldPoint.x, realWorldPoint.y, realWorldPoint.z);
      }
    } 
  }  
}

これを実現するための3つの重要なポイントについて順番に説明します.

CG空間を撮影しているカメラの設定

第1のポイントはCG空間を撮影しているカメラ(視点)の設定です.現実世界に3次元CGを重畳するタイプのARでは,現実世界とCG世界にあるそれぞれのカメラのパラメータ(焦点距離やレンズ歪み,位置,姿勢)を一致させることによって現実とCGの合成を実現しています.

これがきちんとできていれば,CGが遠くにあるときは小さく見え,傾けたら底面が見えるといった幾何学的な現象を,現実・CG間で整合性を持たせつつ表現できるようになります.逆にこれがうまくいっていないと,CGを合成した際に遠近感が現実のそれと食い違ってしまい,下手くそな合成写真でよくある「パースがおかしい」現象が発生します.


具体的なやり方について説明しましょう.Processingでは,CG空間を撮影するカメラ(視点)を設定するための関数としてperspective()とcamera()が用意されています.

  • perspective() 透視投影における視野角と視野範囲を設定する関数
  • camera() カメラの位置と姿勢(注視点と上方向)を設定する関数

さきほど「焦点距離」などのカメラのパラメータを現実のそれと一致させると言いましたが,perspective()は引数として「視野角」を与える仕様になっています.一瞬あれ?と思うかもしれませんが,大丈夫です.視野角は撮像面のサイズと焦点距離によって決まる値だからです*1Kinectの場合,垂直方向の視野角が約45度であることが知られています*2.ちなみにレンズ歪みについては,ここではないものとして扱います.

手順はこうです.まず,perspective()によってカメラの視野角を45度に設定します.次に,その視野にぴったり画像平面(スクリーン)が収まるようにcamera()によってカメラの位置と姿勢を設定します.図としては以下のような感じです*3


コードとしては以下のようになります.

float fov = 45.0;                          // Kinectの垂直方向の視野角
float z0 = (height/2)/tan(radians(fov/2)); // 視点と画像平面までの距離

// 視野の設定
perspective( radians(fov), float(width)/float(height), 1.0, 10000.0);

// 視点の設定
camera( width/2, height/2, z0, width/2, height/2, 0.0, 0.0, 1.0, 0.0 );

これにより,奥行き方向に物体が移動した際の「見え方」が,現実・CG間できちんと一致するようになります.

デプスデータによるCGのマスキング

第2のポイントはデプスデータに基づくCGのマスキングです.前回のARマーカを使った実装では,実物体によってCGが隠れた状態を作り出すために実物体と同形状のCGをマスキング用に緑色でレンダリングしていましたが,今回の場合はKinectから得られたデプスデータを使ってそれと同じことをやります.


 
↑前回はこの位置合わせをARマーカでやっていたわけです


重畳したいCGと一緒にデプスデータを緑色の点群で描画すれば,人や家具によってCGに隠れが生じた画像を得ることができます.点群を3次元的に描画する処理をやっているのがdrawDepth3D()という自作の関数ですが,ここでひとつ注意するポイントがあります.

それは座標系の違いです.Processingで3次元のCG空間を扱うとき,座標系の原点は初期状態でスクリーン(画像平面)の左上隅にあります.また,各軸の正方向はX:右,Y:下,Z:手前となっています.これに対し,Kinectではレンズのあたりに原点を置く座標系でデプスデータが記録されています.これらを総合すると以下の図のようになります.


互いに座標系の位置と向きが異なるので,デプスデータを描画する際には座標系の移動と回転を行う必要があります.それをやっているのが以下の2行です.

translate( width/2, height/2, z0); // Kinectを原点とする座標系にするための移動と
rotateX(PI);                       // 回転


simple-openniでデプスデータを得るには.depthMapRealWorld()というメソッドを使います.このメソッドを使うと,ミリメートル単位(すなわち実寸)のデプスデータを得ることができます.例えば,Kinectによって撮影された画像上の点(x,y)に対応する空間中の点(X,Y,Z)を求めるには以下のようにします.

※ここでkinectはSimpleOpenNI型の変数です.

int index = x + y * kinect.depthWidth();
PVector realWorldPoint = kinect.depthMapRealWorld()[index];

float X = realWorldPoint.x;
float Y = realWorldPoint.y;
float Z = realWorldPoint.z;

合成の手順も大事

第3のポイントは,CGとカメラ画像(背景画像)のレンダリングの手順です.draw()の中で以下の手順で処理を行っています.

  1. 画面を初期化(背景色は緑色とする)
  2. カメラを透視投影に設定
  3. 現実世界に重畳したいCGを描画
  4. Kinectから得られたデプスデータを描画してマスキング処理
  5. ここまでの描画内容を一旦バッファに保存
  6. 画面を初期化
  7. カメラを正射影に設定
  8. 背景画像を描画
  9. バッファに入れておいたデータをクロマキー合成する(緑色以外のところを描画)

前半で3次元的な描画を行ってCGのみのシーン画像を取得し,後半で2次元的に合成を行っています.大事なのは,背景画像をレンダリングする前にカメラを正射影(ortho)に設定することです.

いかがでしたか?

というわけで以上です.ここで紹介したテクニックを使えば,(遮蔽表現に限らず)人の手にCG物体を持たせるような処理をやる際にも2次元的な合成ではなく3次元的な合成を行えるようになるので,より没入感の高い作品を作れるようになると思います.ぜひいろいろ遊んでみてください.


*1:焦点距離と視野角(画角)の関係についてはこのサイトがわかりやすい→第19回 :カメラレンズと焦点距離、画角 デジカメの「しくみ」

*2:Kinectの視野角や焦点距離については"kinect fov"とか"kinect focal length"などでググると出てきます.例えば:Precision of the kinect depth camera - Stack Overflow 製品のロットやキャリブレーションのやり方によって誤差があると思われます.

*3:ここで出てくるZ0という記号は焦点距離fと同義なのですが,実装の都合上,3D空間にスクリーン面を置いてそれをカメラで撮ってるという構図になっているため,あえて別の記号を用いました.