【javascript】MediaPipeで3次元姿勢推定

ロゴ - ©Google 2020 guide line

はじめに

これまで、イラスト作成のためキャラスターのポーズを3Dで表示することができないかと試行錯誤し、MMDからポーズ画像を作成するイラスト用ポーズ作成ツールを作成してきた。しかし、MMDファイルの配布にも限りがあるので、今回はwebカメラや動画内に映る人物の3次元姿勢推定によるポーズ作成をやってみる。

3次元姿勢推定ライブラリ「mediaPipe」

3次元姿勢推定にはMediaPipeを利用する。

MediaPipeは2020年頃にGoogleが公開したライブラリで、ライセンスはApache-2.0 license。本体はC++で書かれているようだが、C++ / Python / JavaScript等の言語で利用でき、Android / iOSむけにものSDKが公開されている模様。

単一のカメラ/動画ファイルから、全身、顔、手の姿勢推定を同時にに行えることが特徴。webカメラからリアルタイムに姿勢推定もできるほど、高速に動作する。

ダウンロード

CDN版も存在するが、今回はnpmを用いてダウンロードする。

npm install [solution]

javascriptで利用可能な機能(solution)は、以下のとおり。

機能 solution 内容
Holistic @mediapipe/holistic 全身(顔・指・姿勢)を同時に検出
Face Detection @mediapipe/face_detection 顔を四角い枠で検出
Face Mesh @mediapipe/face_mesh 顔の形状をパックのようにメッシュとして検出
Hands @mediapipe/hands 指を検出
Pose @mediapipe/pose 姿勢を検出
Selfie Segmentation @mediapipe/selfie_segmentation 画像内で人物だけを切り取り
Objectron @mediapipe/objectron 物体が収まる範囲を3Dのボックスで検出

使い方

使い方は簡単で、解析対象の画像をcanvas要素に貼り付け、MediaPipeのsolutionクラスに対して「send」コマンドでを送るだけ。推定完了時に「onResults」イベントが発生するので、イベントハンドラで推定結果の処理を行う。カメラ・動画の場合は、これを連続して実施する。

サンプル1(最小構成)

まずは、最小構成で使用方法を確認する。以下のサンプルでは、人の全身が遷っている動画ファイル(mp4やwebm等ブラウザが対応している形式)をファイル選択すると、姿勢推定の結果を文字列で出力する。カメラを入力にしたい場合、公式にサンプルがあるのでそちらを参照ください(ただし、セキュリティの問題からHTTPSからしかカメラは利用できません)。

サンプルプログラム

【フォルダ構成】

root
┣ node_modules
┃ ┗ (ここにダウンロードした@mediapipeフォルダがある)
┗ src
  ┗ index_min.html

【index_min.html】

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <script src="../node_modules/@mediapipe/pose/pose.js" crossorigin="anonymous"></script>
</head>

<body>
  <div class="container">
    <video id="video"></video>
    <input id="select" type="file" />
  </div>
  <output id="out"></output>

  <script type="module">
    const fps   = 60;     // 動画の処理間隔
    var   pose  = null;   // 姿勢推定器

    // 読込完了時に処理開始
    window.onload = async function(){
      // 動画ファイルの読み込み時に、推定処理を開始する
      let video   = document.getElementById("video");
      let select  = document.getElementById("select");
      select.addEventListener( 'change' , (e) => { video.src = window.URL.createObjectURL( e.target.files[0] ) } );
      video.addEventListener( 'loadedmetadata' , async (e) => { sendImage(); } );
      video.addEventListener( 'timeupdate' , async (e) => { sendImage(); } );

      // 姿勢推定器を作成
      pose = new Pose({locateFile: (file) => { return `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`; }});
      pose.setOptions({
        modelComplexity: 1,
        smoothLandmarks: true,
        enableSegmentation: true,
        smoothSegmentation: true,
        minDetectionConfidence: 0.5,
        minTrackingConfidence: 0.5
      });
      pose.onResults(onResults);

    }

    // 推定器に画像を送付
    async function sendImage(){
        // 動画の最後まで行ったら終了
        if( video.duration == video.currentTime ){ return; }

        // 推定
        await pose.send({image: video});

        // 次のフレームへ
        video.currentTime = Math.min( video.duration , video.currentTime + 1 / fps );
    }

    // 推定結果の処理
    function onResults(results) {
      if (!results.poseLandmarks) { return; }

      let out = document.getElementById("out");
      out.value = JSON.stringify( results.poseWorldLandmarks );
    }

</script>
</body>
</html>

実行結果

[{"x":0.0181600172072649,"y":-0.6417614817619324,"z":-0.18612128496170044,"visibility":0.9999957084655762},
{"x":0.02383008413016796,"y":-0.6798545718193054,"z":-0.16934724152088165,"visibility":0.9999898076057434},

(中略)
{"x":0.012037980370223522,"y":0.844993531703949,"z":0.033230893313884735,"visibility":0.9592608213424683}]

解説

  • 【29〜38行目】姿勢推定器の作成
    オプションを指定して、姿勢推定のクラスPoseを作成している。また、姿勢推定結果を渡す関数onResultsを指定している。

  • 【24〜26行目】動画読込時
    動画を選択し、読み込み準備が完了するとsendImage関数を読んでいる。sendImage関数の最後で動画のコマ送りをしているため、「timeupdate」イベントが発生し、サイドsendImageが呼ばれる再起処理になり、動画の最後のフレームを処理すると処理が終了する。

  • 【43〜51行目】姿勢推定器へ画像の送付
    send関数によりvideoタグの画像を姿勢推定器poseへ渡し、該当フレームの推定処理が始まる。

  • 【55〜60行目】推定結果の処理
    推定結果は、引数resultsとして渡される。中には公式サイトの「output」に記載の項目が格納されている。

その中でも「poseWorldLandmarks」には、体の各部位の姿勢推定結果が配列で格納されている(実行結果を参照)。配列の長さは33で、以下の画像に記載の番号に対応するインデックスで指定の部位の位置情報(x,y,z)が取得できる。各座標値は基準点(hip)を原点とする座標系で表される。

MediaPipeの姿勢モデル

引用「MediaPipeの姿勢推定モデル

サンプル2(きつねダンスの姿勢推定)

次にMediaPipeで3次元姿勢推定し、推定結果をthree.jsで3Dモデル(vrm)を表示して視覚的に確認するサンプルを以下に示す。題材として、最近バズりぎみのきつねダンスの姿勢推定を行ってみる。

VRM

VRMはglTF をベースにしクロスプラットフォームなファイル形式で、主にアバター(VTuber)データ用のデータ形式として広まっている。glTFとは異なり、ファイル内にモーションデータは含まず、メッシュとボーン情報・メタデータを保持する。ヒューマノイドという仕様で、キャラクターの必須ボーンやボーン名称を規定しているため、互換性の問題が少ないことが特徴。

three-vrm

three.jsでvrmモデルを表示するライブラリ。開発元はPixivで、ライセンスはMITライセンス。キーフレームをjson形式データで渡すと間を補完して表示するキーフレームアニメーション機能も備えている。

今回は、単純にモデルの表示のために利用し、推定した姿勢をフレーム毎にVRMモデルで出力する。

以下のコマンドでダウンロードできる。

npm install three @pixiv/three-vrm

サンプルプログラム

MediaPipe(solution = Pose)の使用方法をサンプルプログラムで見ていく。VRMの処理部が手作り感満載なのはご愛嬌。

【フォルダ構成】

root
┣ node_modules
┃ ┗ (ここにダウンロードした@mediapipeフォルダやthree-vrmがある)
┗ src
  ┣ (importしているthree.jsの関連ファイル)
  ┗ index.html

【index.html】

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html>
<head>
  <meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
  <script src="three.js"></script>
  <script src="GLTFLoader.js"></script>
  <script src="OrbitControls.js"></script>
  <script src="three-vrm.js"></script>
  <script src="../node_modules/@mediapipe/drawing_utils/drawing_utils.js" crossorigin="anonymous"></script>
  <script src="../node_modules/@mediapipe/pose/pose.js"></script>
  <script src="./index.js" type="module"></script>
</head>

<body>
  <div class="container">
    <div id="model_canvas" style="display:inline-block"></div>
    <canvas id="output_canvas"></canvas>
    <video id="video"></video>

    <br/>

    <input id="select" type="file" />
    <input id="stop" type="button" value="stop" />
    <input id="restart" type="button" value="restart" />
  </div>
</body>
</html>

【index.js】

var canvas           = null;
var cnt              = 0;
var fps    = 60;
var flg   = false;

// VRM
var vrm              = null;
var objList          = null;
var scene            = null;
var directionalLight = null;
var camera           = null;
var renderer         = null;
var controls         = null;

// 姿勢推定
var pose   = null;
var video  = null;
var output = null; 
var outCtx = null;

// 読込後の処理
window.onload = async function(){
    // VRM読み込み
    await initModel();

    // 要素の取得
    video  = document.getElementById('video');
    output = document.getElementById('output_canvas');
    outCtx = output.getContext('2d');

    // 姿勢推定器を構築
    pose = new Pose({locateFile: (file) => {
        return `https://cdn.jsdelivr.net/npm/@mediapipe/pose/${file}`;
    }});
    pose.setOptions({
        modelComplexity: 2,
        smoothLandmarks: true,
        enableSegmentation: true,
        smoothSegmentation: true,
        minDetectionConfidence: 0.5,
        minTrackingConfidence: 0.5
    });
    pose.onResults(onResults);

    // イベントハンドラ設定
    document.getElementById("stop").addEventListener( 'click' , (e) => { flg = true; } );
    document.getElementById("restart").addEventListener( 'click' , (e) => { flg = false; video.currentTime = Math.min( video.duration , video.currentTime + 1 / fps ); } );
    document.getElementById("select").addEventListener( 'change' , (e) => { video.src = window.URL.createObjectURL( e.target.files[0] ) } );

    // ビデオの準備ができたとき
    document.getElementById("video").addEventListener( 'loadedmetadata' , (e) => {
        // キャンバスサイズを変更
        output.width  = video.videoWidth;
        output.height = video.videoHeight;
    } );

    // フレーム読込時
    document.getElementById("video").addEventListener( 'loadedmetadata' , async (e) => { sendImage(); } );
    document.getElementById("video").addEventListener( 'timeupdate' , async (e) => { sendImage(); } );
}

// VRMモデルの読込
async function initModel(){
    // シーンの準備
    scene = new THREE.Scene();

    // 地面の準備
    const gridHelper = new THREE.GridHelper( 10 , 10 );
    scene.add( gridHelper );

    // ライトの準備
    directionalLight = new THREE.DirectionalLight('#ffffff', 1);
    directionalLight.position.set(1, 1, 1);
    scene.add(directionalLight);

    // カメラの準備
    camera = new THREE.PerspectiveCamera( 45, 800/600,0.1,1000 );
    camera.position.set(-0.5, 1 , 2.5 );

    // 3Dモデル準備
    let fileURL = 'Alicia_VRM/Alicia/VRM/AliciaSolid.vrm';
    let gltf    = await new THREE.GLTFLoader().loadAsync( fileURL );
    vrm         = await THREE.VRM.from(gltf);
    vrm.humanoid.setPose( { hips : { rotation : new THREE.Quaternion().setFromAxisAngle( new THREE.Vector3( 0 , 1 , 0 ) , Math.PI ).toArray() } } ); 
    scene.add(vrm.scene);
    console.log( vrm );

    // 表情セット
    vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.Lookup , 1.0);
    vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.A , 0.5);
    vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.E , 0.5);
    vrm.blendShapeProxy.update()

    // 球体を追加
    objList = new Array();
    for( let i=0 ; i<34 ; i++ ){
        const geometry  = new THREE.SphereGeometry( 0.01, 32, 16 );
        const material  = new THREE.MeshBasicMaterial( { color: 0xff0000 } );
        const sphere    = new THREE.Mesh( geometry, material );
        objList.push( sphere );
        scene.add( sphere );
    }

    // レンダラーの準備
    renderer = new THREE.WebGLRenderer({ antialias: true , preserveDrawingBuffer: true });
    renderer.setPixelRatio(window.devicePixelRatio);
    renderer.setSize(800,600);
    renderer.setClearColor(0x7fbfff, 1.0);
    canvas = renderer.domElement;
    document.getElementById("model_canvas").appendChild(renderer.domElement);

    // マウスによるコントロール
    controls = new THREE.OrbitControls( camera, renderer.domElement );
    controls.target = new THREE.Vector3(-0.5, 1, 0);
    controls.update();

    // 初期状態を表示
    renderer.render(scene, camera);

    // 描画ループの開始
    function animate() {
        // 次のフレーム描画要求(60fps目標)
        requestAnimationFrame(animate);

        // マウスイベント更新
        controls.update();

        // 画面を更新
        renderer.render( scene, camera );
    }
    animate();
}

// ポーズ・オブジェクトに画像を送付
async function sendImage(){
    // ファイルの最後まで行ったら終了
    if( video.duration == video.currentTime ){ return; }

    // 推定
    await pose.send({image: video});

    // 次のフレームへ
    if( !flg ){
        video.currentTime = Math.min( video.duration , video.currentTime + 1 / fps );
    }
}

// 姿勢推定結果の処理
function onResults(results) {
    if (!results.poseLandmarks) {
    return;
    }

    // VRMモデルのポーズを変更
    if( vrm != null ){
        let hipPos = getHipPos( results );
        vrm.humanoid.setPose( convToVRMPose( hipPos , results.poseWorldLandmarks) );
    }

    // 球体の位置を変更
    if( objList != null ){
        for( let i=0 ; i<33 ; i++ ){
            let sphere = objList[i];
            let coord  = convMP2WLDCoord( results.poseWorldLandmarks[i] );
            sphere.position.set( coord.x - 1  , coord.y + 1 , coord.z );
        }
    }

    // 画像として出力
    outCtx.save();
    outCtx.clearRect(0, 0, output.width, output.height);

    // Only overwrite existing pixels.
    outCtx.globalCompositeOperation = 'source-in';
    outCtx.fillStyle = '#00FF00';
    outCtx.fillRect(0, 0, output.width, output.height);

    // Only overwrite missing pixels.
    outCtx.globalCompositeOperation = 'destination-atop';
    outCtx.drawImage(
        results.image, 0, 0, output.width, output.height);

    outCtx.globalCompositeOperation = 'source-over';
    drawConnectors(outCtx, results.poseLandmarks, POSE_CONNECTIONS,
                    {color: '#00FF00', lineWidth: 4});
    drawLandmarks(outCtx, results.poseLandmarks,
                {color: '#FF0000', lineWidth: 2});
    outCtx.restore();
}

// mediapipeの関節番号
const MP_POSE = {
    nose                : 0 ,
    left_eye_inner      : 1 ,
    left_eye            : 2 ,
    left_eye_outer      : 3 ,
    right_eye_inner     : 4 ,
    right_eye           : 5 ,
    right_eye_outer     : 6 ,
    left_ear            : 7 ,  right_ear           : 8 ,
    mouth_left          : 9 ,  mouth_right         : 10 ,
    left_shoulder       : 11 , right_shoulder      : 12 ,
    left_elbow          : 13 , right_elbow         : 14 ,
    left_wrist          : 15 , right_wrist         : 16 ,
    left_pinky          : 17 , right_pinky         : 18 ,
    left_index          : 19 , right_index         : 20 ,
    left_thumb          : 21 , right_thumb         : 22 ,
    left_hip            : 23 , right_hip           : 24 ,
    left_knee           : 25 , right_knee          : 26 ,
    left_ankle          : 27 , right_ankle         : 28 ,
    left_heel           : 29 , right_heel          : 30 ,
    left_foot_index     : 31 , right_foot_index    : 32 ,
}

// mediapipe座標をvrmの座標へ変換
function convMP2VRMCoord( coord ){
    return new THREE.Vector3(  -1 * coord.x , -1 * coord.y , coord.z );
}

// mediapipe座標をthree.jsの座標へ変換
function convMP2WLDCoord( coord ){
    return new THREE.Vector3( coord.x , -1 * coord.y , -1 * coord.z );
}

// mediapipe(MP)の座標から、VRMのポーズを計算する。
function convToVRMPose( hipPose , mp_coord ){
    let vecUp           = new THREE.Vector3( 0 , 1 , 0 );
    let vecDown         = new THREE.Vector3( 0 , -1 , 0 );
    let vecRight        = new THREE.Vector3( 1 , 0 , 0 );
    let vecLeft         = new THREE.Vector3( -1 , 0 , 0 );
    let vecFront        = new THREE.Vector3( 0 , 0 , -1 );
    let vecBack         = new THREE.Vector3( 0 , 0 , 1 );

    // 起点
    let posHip          = new THREE.Vector3( 0 , 0 , 0 );
    let posLHip         = convMP2VRMCoord( mp_coord[ MP_POSE.left_hip ] );
    let posRHip         = convMP2VRMCoord( mp_coord[ MP_POSE.right_hip ] );
    // 下半身
    let posLKnee        = convMP2VRMCoord( mp_coord[ 25 ] );
    let posRKnee        = convMP2VRMCoord( mp_coord[ MP_POSE.right_knee ] );
    let posLAnkle       = convMP2VRMCoord( mp_coord[ MP_POSE.left_ankle ] );
    let posRAnkle       = convMP2VRMCoord( mp_coord[ MP_POSE.right_ankle ] );
    let posLHeel        = convMP2VRMCoord( mp_coord[ MP_POSE.left_heel ] );
    let posRHeel        = convMP2VRMCoord( mp_coord[ MP_POSE.right_heel ] );
    let posLToes        = convMP2VRMCoord( mp_coord[ MP_POSE.left_foot_index ] );
    let posRToes        = convMP2VRMCoord( mp_coord[ MP_POSE.right_foot_index ] );
    // 上半身
    let posSpine        = posHip.clone();   // MPにないため、代用
    let posLShoulder    = convMP2VRMCoord( mp_coord[ MP_POSE.left_shoulder ] );
    let posRShoulder    = convMP2VRMCoord( mp_coord[ MP_POSE.right_shoulder ] );
    let posNeck         = posLShoulder.clone().add( posRShoulder ).divideScalar(2);   // MPにないため、代用
    // 顔
    let posLInnerEye    = convMP2VRMCoord( mp_coord[ MP_POSE.left_eye_inner ] );
    let posRInnerEye    = convMP2VRMCoord( mp_coord[ MP_POSE.right_eye_inner ] );
    let posCenterEye    = posLInnerEye.clone().add( posRInnerEye ).divideScalar(2);   // 仮想点
    let posLMouth       = convMP2VRMCoord( mp_coord[ MP_POSE.mouth_left ] );
    let posRMouth       = convMP2VRMCoord( mp_coord[ MP_POSE.mouth_right ] );
    let posCenterMouth  = posLMouth.clone().add( posRMouth ).divideScalar(2);   // 仮想点
    // 腕
    let posLElbow       = convMP2VRMCoord( mp_coord[ MP_POSE.left_elbow ] );
    let posRElbow       = convMP2VRMCoord( mp_coord[ MP_POSE.right_elbow ] );
    let posLWrist       = convMP2VRMCoord( mp_coord[ MP_POSE.left_wrist ] );
    let posRWrist       = convMP2VRMCoord( mp_coord[ MP_POSE.right_wrist ] );
    let posLIndex       = convMP2VRMCoord( mp_coord[ MP_POSE.left_index ] );
    let posLPinky       = convMP2VRMCoord( mp_coord[ MP_POSE.left_pinky ] );
    let posLMiddle      = posLIndex.clone().add( posLPinky ).divideScalar(2);   // 仮想点
    let posRIndex       = convMP2VRMCoord( mp_coord[ MP_POSE.right_index ] );
    let posRPinky       = convMP2VRMCoord( mp_coord[ MP_POSE.right_pinky ] );
    let posRMiddle      = posRIndex.clone().add( posRPinky ).divideScalar(2);   // 仮想点

    let vrmPose     = {};

    // 基準点の移動と回転
    let rotHip      = new THREE.Quaternion().setFromAxisAngle( new THREE.Vector3( 0 , 1 , 0 ) , Math.PI )
                                .multiply( getQuaternion( vecRight , posRHip.clone().sub( posLHip ) ) );
    vrmPose.hips    = { position : hipPose.toArray(),
                        rotation : rotHip.toArray() };

    // 各関節の回転を設定
    // 太もも
    vrmPose.leftUpperLeg    = { rotation : getQuaternion( vecDown , posLKnee.clone().sub( posLHip ) ).toArray() };
    vrmPose.rightUpperLeg   = { rotation : getQuaternion( vecDown , posRKnee.clone().sub( posRHip ) ).toArray() };
    // 膝
    vrmPose.leftLowerLeg    = { rotation : getQuaternion( posLKnee.clone().sub( posLHip ) , posLAnkle.clone().sub( posLKnee ) ).toArray() };
    vrmPose.rightLowerLeg   = { rotation : getQuaternion( posRKnee.clone().sub( posRHip ) , posRAnkle.clone().sub( posRKnee ) ).toArray() };
    // 足首
    vrmPose.leftFoot        = { rotation : getQuaternion( vecFront , posLToes.clone().sub( posLHeel ) ).toArray() };
    vrmPose.rightFoot       = { rotation : getQuaternion( vecFront , posRToes.clone().sub( posRHeel ) ).toArray() };
    // 脊椎
    vrmPose.spine           = { rotation : getQuaternion( vecUp ,  posNeck.clone().sub( posSpine ) ).toArray() };
    // 首
    vrmPose.neck            = { rotation : getQuaternion( posNeck.clone().sub( posSpine ) ,  posCenterEye.clone().sub( posCenterMouth ) ).toArray() };
    // 胸
    vrmPose.chest           = { rotation : getQuaternion( posLHip.clone().sub( posRHip ) ,  posLShoulder.clone().sub( posRShoulder ) ).toArray() };
    // 肩
    vrmPose.leftUpperArm    = { rotation : getQuaternion( vecLeft  , posLElbow.clone().sub( posLShoulder ) ).toArray() };
    vrmPose.rightUpperArm   = { rotation : getQuaternion( vecRight , posRElbow.clone().sub( posRShoulder ) ).toArray() };
    // 肘
    vrmPose.leftLowerArm    = { rotation : getQuaternion( posLElbow.clone().sub( posLShoulder ) , posLWrist.clone().sub( posLElbow ) ).toArray() };
    vrmPose.rightLowerArm   = { rotation : getQuaternion( posRElbow.clone().sub( posRShoulder ) , posRWrist.clone().sub( posRElbow ) ).toArray() };
    // 手首
    vrmPose.leftHand        = { rotation : getQuaternion( posLWrist.clone().sub( posLElbow ) , posLMiddle.clone().sub( posLWrist ) ).toArray() };
    vrmPose.rightHand       = { rotation : getQuaternion( posRWrist.clone().sub( posRElbow ) , posRMiddle.clone().sub( posRWrist ) ).toArray() };

    return vrmPose;
}

// 2つのベクトル(vecA->vecB)のクォータニオンを取得
function getQuaternion( vecA , vecB ){
    // 単位ベクトルに変換
    let vecUnitA = vecA.clone().normalize();
    let vecUnitB = vecB.clone().normalize();
    console.log(vecUnitA,vecUnitB);

    // 2つのベクトルの外積(回転軸)を計算
    let vecNormal = new THREE.Vector3();
    vecNormal.crossVectors( vecUnitA , vecUnitB ).normalize();
    console.log(vecNormal);

    // 2つのベクトルの回転角
    let rad = vecUnitA.angleTo(vecUnitB);

    //クォータニオンオブジェクトを生成
    let q = new THREE.Quaternion();
    q.setFromAxisAngle( vecNormal , rad );

    // 回転角の象限を確認
    let dot = vecUnitA.dot( vecUnitB );
    if( dot > 0 ){
        // 第1象限か第4象限
        rad  = rad; // 第1象限と仮定

        // 回転方向が逆か確認
        let vec = vecUnitA.clone().applyQuaternion( q );
        if( vec.angleTo( vecUnitB ) > rad ){
            //逆回転
            rad  = 2 * Math.PI - rad;
            //console.log("第4象限",rad,rad * 180 / Math.PI);
        }else{
            //console.log("第1象限",rad,rad * 180 / Math.PI , vec.angleTo( vecUnitB )*180/Math.PI);
        };
    }else{
        // 第2象限か第3象限
        rad     = rad ;    // 第2象限と仮定

        // 回転方向が逆か確認
        let vec = vecUnitA.clone().applyQuaternion( q );
        if( vec.angleTo( vecUnitB ) > rad ){
            //逆回転
            rad  = 2 * Math.PI - rad;
            //console.log("第3象限",rad,rad * 180 / Math.PI);
        }else{
            //console.log("第2象限",rad,rad * 180 / Math.PI , vec.angleTo( vecUnitB )*180/Math.PI);
        };
    }

    // 回転方向を踏まえて、クォータニオンを再計算
    q = new THREE.Quaternion().setFromAxisAngle( vecNormal, rad );

    return q;
}

// 3Dモデルの基準点の座標を取得
var _initHip = null;
function getHipPos( results ){
    // 戻り値
    let hipPos  = null;

    // 二次元画像上の情報を取得
    let landmarks = results.poseLandmarks;
    let posLHip   = landmarks[ MP_POSE.left_hip ];
    let posRHip   = landmarks[ MP_POSE.right_hip ];

    // hipの位置を計算
    let x = ( posLHip.x + posRHip.x ) / 2; 
    let y = ( posLHip.y + posRHip.y ) / 2; 
    let z = ( posLHip.z + posRHip.z ) / 2; 

    // 相対位置を計算
    if( _initHip == null ){
        _initHip = new THREE.Vector3( x , y , z );
        hipPos   = new THREE.Vector3(0,0,0)
    }else{
        let relateX = ( _initHip.x - x );
        let relateY = ( _initHip.y - y );
        let relateZ = ( _initHip.z - z );
        hipPos = new THREE.Vector3( relateX , relateY , relateZ );
    }

    // 座標変換
    hipPos.set(  - hipPos.x , hipPos.y , hipPos.z );

    return hipPos;
}

解説

姿勢推定の部分はサンプル1と変わらないので、かいつまんで説明する。

  • initModel関数
    three.jsの初期化とオブジェクトの配置。気をつけるところはWebGLRendererのコンストラクタに「preserveDrawingBuffer: true」を渡さないと、canvas.toDataURL()等で出力画像をキャプチャできない。

  • convToVRMPose関数 / getQuaternion関数
    推定結果を基に、VRMの各関節の回転角を計算している。MediaPipeの人モデルとVRMの人モデルは、対応する関節があったりなかったりするので、それっぽくなるように力技で変換。

  • convMP2VRMCoord関数 / convMP2WLDCoord関数
    座標系の変換。MediaPipeの座標系、VRMの座標系、three.jsの座標系はそれぞれ異なることに注意する。

  • getHipPos関数
    MediaPipeの座標系の原点となるhipの位置情報はresults内に存在しない。そのため、2次元画像中のhip位置を計算している。推定した3次元座標とは座標の単位が異なることや、2次元画面中のz軸情報は信頼性が低いため気休め程度の位置。

実行結果

実行すると、「入力画像上に推定結果をオーバーレイした画像」と「three.jsで表示した3D画面」が表示される。three.jsでの出力内容は結果は以下のとおり。

赤い点がMediaPipeの推定結果をそのままプロットしたもの。xy軸方向は動画上に見えている位置とピッタリ一致している模様。z軸方向は少し弱く、腰のひねりやかかとの位置等が怪しいが、それ以外の奥行きは違和感がなかった。

3Dモデルは、推定結果から各関節の曲がり具合を抽出して動かしたものになっている。腕の動きが赤い点と連動していないのは、getQuaternion関数の実装が悪いためと思われる。クォータニオンとか誰か教えてください

タイトルとURLをコピーしました