読者です 読者をやめる 読者になる 読者になる

ProcessingとKinectでオリジナルアニメ制作にチャレンジ

f:id:karaage:20141215212052j:plain

Processing Advent Calendar 2014

 この記事は、Processing Advent Calendar 2014という企画の参加記事です。担当日は21日なのですが、実は17日から一週間程連続で出張で時間とれないため先行で公開してしまいます。すみませんご容赦をー。

オリジナルアニメを作りたい!

 実は私は「ネコマン」というWeb漫画を描いています。


 絶賛連載中!

 そしてずっと待っているのですが、いまだに来ません。そうです、待てども待てどもメディアミックスの話がこない!もう待ちきれないので、自分でアニメつくることにしました。かの手塚治虫大先生と同じ道ですね。
 でもアニメをつくるのは大変です。原理上、1秒の動画に30枚もの絵が必要となります。今も日本のプロのアニメータが薄給でこき使われている実体は、なによりこの作業量の多さが原因なのは明らかです。もちろん、個人制作のアニメとなると絶望的で、セミプロが数ヶ月とか数年かけて1本作品を作るような伝統工芸的なものが多いのが実情です。
 そんな、アニメ界を、じゃあ俺がああ!! Processingで!! このアニメ界を! ウグッブーン!! ゴノ、ゴノアニメのブッヒィフエエエーーーーンン!! ヒィェーーッフウンン!! ウゥ……ウゥ……。ア゛ーーーーーア゛ッア゛ーー!!!! ゴノ! アニメの! 中の人ガッハッハアン!! ア゛ーー世の中を! ゥ変エダイ!

 という高い志は特に無いのですが、主に自分のためにProcessingとKinectを使って手軽にアニメを作る方法を考案してみました。手法自体はいわゆるモーションキャプチャなので、プロの世界では既に使われている技術ですが、個人が手軽にアニメをつくる手法として広がっても面白いのじゃないかなと考え公開してみます。自分が持っているのがMacなのでMacの例で書いていますが、少しアレンジすればWindowsでも使えるものだと思います(多分)。

完成したアニメ「ネコマンアニメーション」

 最初に、完成したものを載せておきます。

 最初の方が、テスト動画です。自分の動きと同期してネコマンが動いているのがわかります。後半、素材動画を背景として、グリーンバックのキャラクタを合成しています。ちなみに二人のネコマンの中の人は私と妻です。


f:id:karaage:20141215212052j:plain
 認識している様子

f:id:karaage:20141215212409j:plain
 夫婦でネコマン

 作り方に興味がある人は、続きを読むをクリック下さい。

アニメの制作方法

 具体的な方法を書いていきます。

MacでのProcessing + Kinectのセットアップ

 昔書いた下記記事を参照下さい。最新のOSだとこのままだとダメかもしれません。その場合はグーグル先生に聞いてみて下さい。

Xbox 360 Kinect センサー

Xbox 360 Kinect センサー

素材

 キャラクタの顔を描いて下さい。描けないという人のために、とりあえず自分が描いた素材おいておきます。

f:id:karaage:20141215190804p:plain

f:id:karaage:20141215190752p:plain

プログラムソースコード

 おしげもなく公開するので、コピペして下さい。あとは、上記の素材の画像ファイルを「neko_m.png」「neko_f.png」というファイル名でプログラムと同じフォルダに保存して実行するだけです。
 基本は、ProcessingのSimpleOpenNIのsample(user)をちょこちょこっと弄っただけです。人間の骨格を認識して、キャラクタに変身させます。重要なのは、以下くらいです(以下は、首の座標を取得するためのプログラム)。

PVector vecpos = new PVector();
PVector ipos = new PVector();

context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_NECK, vecpos);
context.convertRealWorldToProjective(vecpos, ipos);

 これで ipos.x , ipos.y, ipos.z それぞれに首の x,y,z座標が入力されます。convertRealWorldToProjectiveで一回変換するのが重要みたいです。プログラム全文は以下。一応距離に応じてネコマンの大きさは変わるようになっています。

/* --------------------------------------------------------------------------
 * Nekoman Kinect
 * --------------------------------------------------------------------------
 * Processing Wrapper for the OpenNI/Kinect library
 * http://code.google.com/p/simple-openni
 * --------------------------------------------------------------------------
 * prog:  karaage / http://karaage.hatenadiary.jp
 * date:  12/14/2014 (m/d/y)
 * ----------------------------------------------------------------------------
 */

import SimpleOpenNI.*;

SimpleOpenNI  context;
boolean  autoCalib=true;
boolean  movie_state=false;
boolean  preview_state=false;

PImage img_neko_m;
PImage img_neko_f;

final int face_size_m = 160;
final int face_size_f = 160;

final int arm_size_m = 50;
final int arm_size_f = 50;

final int frame_width = 5;

final int body_width_m = 150;
final int body_height_m = 200;

final int body_height_offset = 180;
final int body_z_offset = 500;
final int body_z_scale = 2500;

final int face_z_offset = 500;
final int face_z_scale = 2500;

void setup(){
  img_neko_m = loadImage("neko_m.png");
  img_neko_f = loadImage("neko_f.png");

  context = new SimpleOpenNI(this);
   
  // enable depthMap generation 
  if(context.enableDepth() == false){
     println("Can't open the depthMap, maybe the camera is not connected!"); 
     exit();
     return;
  }

  if(context.enableRGB() == false){
     println("Can't open the rgbMap, maybe the camera is not connected or there is no rgbSensor!"); 
     exit();
     return;
  }
   
  // enable skeleton generation for all joints
  context.enableUser(SimpleOpenNI.SKEL_PROFILE_ALL);

  // green back
  background(0 ,255,0);

  strokeWeight(30);
  smooth();
  
  size(context.depthWidth(), context.depthHeight()); 
}

void draw()
{
  background(0 ,255,0);

  // update the cam
  context.update();
  
  // draw depthImageMap
//  image(context.depthImage(),0,0);

  // draw rgbImageMap
//  image(context.rgbImage(),0,0);
  
  // draw the skeleton if it's available
  int[] userList = context.getUsers();
  for(int i=0;i<userList.length;i++)
  {
    if(context.isTrackingSkeleton(userList[i]))
      drawNekoman(userList[i]);
  }

  if(preview_state == true){
    image(context.depthImage(), 0, context.depthHeight() *2/3 , context.depthWidth() /3, context.depthHeight()/3);
  }

  if(movie_state == true){
    saveFrame("frames/####.tif");
  }
}

// draw the Nekoman with the selected joints
void drawNekoman(int userId)
{
  PVector vecpos = new PVector();
  PVector ipos = new PVector();
  PVector ipos2 = new PVector();
  PVector ipos3 = new PVector();

  if(userId == 1){ // nekoman male
    strokeWeight(arm_size_m);
    stroke(0, 0, 0);
    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_LEFT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_SHOULDER, SimpleOpenNI.SKEL_LEFT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_ELBOW, SimpleOpenNI.SKEL_LEFT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_RIGHT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_SHOULDER, SimpleOpenNI.SKEL_RIGHT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_ELBOW, SimpleOpenNI.SKEL_RIGHT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_SHOULDER, SimpleOpenNI.SKEL_TORSO);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_SHOULDER, SimpleOpenNI.SKEL_TORSO);

    context.drawLimb(userId, SimpleOpenNI.SKEL_TORSO, SimpleOpenNI.SKEL_LEFT_HIP);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_HIP, SimpleOpenNI.SKEL_LEFT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_KNEE, SimpleOpenNI.SKEL_LEFT_FOOT);

    context.drawLimb(userId, SimpleOpenNI.SKEL_TORSO, SimpleOpenNI.SKEL_RIGHT_HIP);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_HIP, SimpleOpenNI.SKEL_RIGHT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_KNEE, SimpleOpenNI.SKEL_RIGHT_FOOT);  

    strokeWeight(arm_size_m - frame_width);
    stroke(255, 255, 255);
    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_LEFT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_SHOULDER, SimpleOpenNI.SKEL_LEFT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_ELBOW, SimpleOpenNI.SKEL_LEFT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_RIGHT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_SHOULDER, SimpleOpenNI.SKEL_RIGHT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_ELBOW, SimpleOpenNI.SKEL_RIGHT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_HIP, SimpleOpenNI.SKEL_LEFT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_KNEE, SimpleOpenNI.SKEL_LEFT_FOOT);

    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_HIP, SimpleOpenNI.SKEL_RIGHT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_KNEE, SimpleOpenNI.SKEL_RIGHT_FOOT);

    // nekoman body
    pushMatrix();
    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_NECK, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos);

    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_LEFT_HIP, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos2);
    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_RIGHT_HIP, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos3);
    ipos2.x = (ipos2.x + ipos3.x)/2;
    ipos2.y = (ipos2.y + ipos3.y)/2;
    float theta = atan2(ipos.y-ipos2.y, ipos.x-ipos2.x);
    float body_height = sqrt(pow(ipos.x-ipos2.x,2)+ pow(ipos.y-ipos2.y,2));
    translate((ipos.x + ipos2.x)/2, (ipos.y+ipos2.y)/2+50);
    rotate(theta);
    float body_z = (ipos.z - body_z_offset)/body_z_scale;
    if(body_z < 0.8){
      body_z = 0.8;
    }

    strokeWeight(0);
    fill(0, 0, 0);
    ellipse(0, 0, body_height+body_height_offset, body_width_m/body_z);
    fill(255, 255, 255);
    ellipse(0, 0, body_height+body_height_offset - frame_width, body_width_m/body_z - frame_width);

    popMatrix();

    // nekoman face
    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_HEAD, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos);

    float face_z = (ipos.z - face_z_offset)/face_z_scale;
    if(face_z < 0.8){
      face_z = 0.8;
    }

    image(img_neko_m, ipos.x-face_size_m/2, ipos.y-face_size_m/2, face_size_m/face_z, face_size_m/face_z);

  }else{ // nekoman female
    strokeWeight(arm_size_f);
    stroke(0, 0, 0);
    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_LEFT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_SHOULDER, SimpleOpenNI.SKEL_LEFT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_ELBOW, SimpleOpenNI.SKEL_LEFT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_RIGHT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_SHOULDER, SimpleOpenNI.SKEL_RIGHT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_ELBOW, SimpleOpenNI.SKEL_RIGHT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_SHOULDER, SimpleOpenNI.SKEL_TORSO);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_SHOULDER, SimpleOpenNI.SKEL_TORSO);

    context.drawLimb(userId, SimpleOpenNI.SKEL_TORSO, SimpleOpenNI.SKEL_LEFT_HIP);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_HIP, SimpleOpenNI.SKEL_LEFT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_KNEE, SimpleOpenNI.SKEL_LEFT_FOOT);

    context.drawLimb(userId, SimpleOpenNI.SKEL_TORSO, SimpleOpenNI.SKEL_RIGHT_HIP);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_HIP, SimpleOpenNI.SKEL_RIGHT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_KNEE, SimpleOpenNI.SKEL_RIGHT_FOOT);  

    strokeWeight(arm_size_m - frame_width);
    stroke(255, 255, 255);
    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_LEFT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_SHOULDER, SimpleOpenNI.SKEL_LEFT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_ELBOW, SimpleOpenNI.SKEL_LEFT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_NECK, SimpleOpenNI.SKEL_RIGHT_SHOULDER);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_SHOULDER, SimpleOpenNI.SKEL_RIGHT_ELBOW);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_ELBOW, SimpleOpenNI.SKEL_RIGHT_HAND);

    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_HIP, SimpleOpenNI.SKEL_LEFT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_LEFT_KNEE, SimpleOpenNI.SKEL_LEFT_FOOT);

    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_HIP, SimpleOpenNI.SKEL_RIGHT_KNEE);
    context.drawLimb(userId, SimpleOpenNI.SKEL_RIGHT_KNEE, SimpleOpenNI.SKEL_RIGHT_FOOT);

    // nekoman body
    pushMatrix();
    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_NECK, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos);

    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_LEFT_HIP, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos2);
    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_RIGHT_HIP, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos3);
    ipos2.x = (ipos2.x + ipos3.x)/2;
    ipos2.y = (ipos2.y + ipos3.y)/2;
    float theta = atan2(ipos.y-ipos2.y, ipos.x-ipos2.x);
    float body_height = sqrt(pow(ipos.x-ipos2.x,2)+ pow(ipos.y-ipos2.y,2));
    translate((ipos.x + ipos2.x)/2, (ipos.y+ipos2.y)/2+50);
    rotate(theta);
    float body_z = (ipos.z - body_z_offset)/body_z_scale;
    if(body_z < 0.8){
      body_z = 0.8;
    }

    strokeWeight(0);
    fill(0, 0, 0);
    ellipse(0, 0, body_height+body_height_offset, body_width_m/body_z);
    fill(255, 255, 255);
    ellipse(0, 0, body_height+body_height_offset - frame_width, body_width_m/body_z - frame_width);

    strokeWeight(0);
    fill(0, 0, 0);
    ellipse(0, 0, (body_height+body_height_offset)/2, (body_width_m/body_z)/2);
    fill(255, 255, 255);
    ellipse(0, 0, (body_height+body_height_offset)/2 - frame_width, (body_width_m/body_z)/2 - frame_width);

    popMatrix();

    // nekoman face
    context.getJointPositionSkeleton(userId,SimpleOpenNI.SKEL_HEAD, vecpos);
    context.convertRealWorldToProjective(vecpos, ipos);

    float face_z = (ipos.z - face_z_offset)/face_z_scale;
    if(face_z < 0.8){
      face_z = 0.8;
    }

    image(img_neko_f, ipos.x-face_size_f/2, ipos.y-face_size_f/2, face_size_f/face_z, face_size_f/face_z);
  }
}

void keyPressed() {
  if (key == 'M' || key == 'm'){
    if(movie_state == false){
      println("Recording Start");
      movie_state = true;
    }else{
      println("Recording Stop");
      movie_state = false;
    }
  }

  if (key == 'P' || key == 'p'){
    if(preview_state == false){
      preview_state = true;
    }else{
      preview_state = false;
    }
  }

}
void onNewUser(int userId){
  println("onNewUser - userId: " + userId);
  println("  start pose detection");
  
  if(autoCalib)
    context.requestCalibrationSkeleton(userId,true);
  else    
    context.startPoseDetection("Psi",userId);
}

void onLostUser(int userId){
  println("onLostUser - userId: " + userId);
}

void onExitUser(int userId){
  println("onExitUser - userId: " + userId);
}

void onReEnterUser(int userId){
  println("onReEnterUser - userId: " + userId);
}

void onStartCalibration(int userId){
  println("onStartCalibration - userId: " + userId);
}

void onEndCalibration(int userId, boolean successfull){
  println("onEndCalibration - userId: " + userId + ", successfull: " + successfull);
  
  if (successfull){ 
    println("  User calibrated !!!");
    context.startTrackingSkeleton(userId); 
  } 
  else{ 
    println("  Failed to calibrate user !!!");
    println("  Start pose detection");
    context.startPoseDetection("Psi",userId);
  }
}

void onStartPose(String pose,int userId){
  println("onStartPose - userId: " + userId + ", pose: " + pose);
  println(" stop pose detection");
  
  context.stopPoseDetection(userId); 
  context.requestCalibrationSkeleton(userId, true);
}

void onEndPose(String pose,int userId){
  println("onEndPose - userId: " + userId + ", pose: " + pose);
}

プログラム使い方

 起動したら自動的に動作します。人を認識すると、ネコマン(オス)が表示されます。2人目を認識すると、ネコマン(メス)が表示されます。

「M」「m」を押す:「frames」というフォルダの中に ****.tifという連番の画像ファイルが自動で出力
「P」「p」を押す:プレビュー画面が左下に表示される

動画生成方法

 プログラムで連番の静止画から動画を生成します。以下の記事を参照下さい。QuickTime Player 7を使った方法が手軽です。

 後は、iMovie等で合成してやると立派なアニメーションの完成です。

まとめ

 ProcessingとKinectを使って手軽にアニメ化することに成功しました。これを応用すれば、例えば「ジョジョの奇妙な冒険」の「スタンド」みたいなものも実現できそうですね。
 まだ、キャラの口の動きや瞬き、奥行き方向の処理等イマイチな箇所は沢山ありますが、改良して音楽だったり台詞だったりを入れればそれなりのアニメができそうです。

関連記事