こんにちは! テリーです。リモートワーク楽しんでますか? 僕は朝起きたときの寝ぐせがパイナップルのようになっていて、テレカンのときにはずかしい思いをすることがしばしばあります。髪型、服装、化粧、背景など、テレカンの目的とはまったく関係ないものは消してしまいたい! そんな人に向けて、自分の代わりにアバターを表示するテクニックをご紹介します。

2020年3月頃、カメラ画像の中から顔の3D形状を検出するオープンソース機械学習モデル、facemeshがGoogleからリリースされました。このライブラリを使うと、とても短いコードでリアルタイムに顔の3Dデータを取得し、テレカン用アバターの映像を作ることができます。昨年までこの手の検出処理はC++やOpenCV(Pyhton)で実装するのが一般的でしたが、ブラウザ上のスクリプト言語であるJavaScriptで、PC・スマホともに実用的な速度を達成したことが2020年のトレンドです。

実写(左) アバター(中) 顔検出データ(右)の例

サンプル

サンプルの使い方

まずは上記のサンプルをお試しください(カメラ付きのパソコンやスマートフォンをご使用ください)。「start」ボタンを押してください。真ん中の四角にアバターが表示されます。次にカメラ使用許可の確認ダイアログが現れます。「許可」を押すと、左の四角にカメラの生映像が表示されます。数秒から20秒ほど待つと、右の四角に検出した顔を点で描画したものが描画されます。その後アバターがカメラの映像に連動して動き出します。合計10MBのファイルをダウンロードして展開するため、最初の待ち時間は少し長く感じます。

(注意) 遅いパソコンやスマートフォンで実行すると、コンピュータが固まったと心配になるほど猛烈に遅くなります。その場合は、ブラウザを強制終了してください。

動作確認: Chrome(Mac), Safari(Mac), FireFox(Mac), Safari(iPhone), Chrome(Android)
すべて2020年6月における最新バージョン

顔検出処理単体のコードは前回の記事と9割同じです。アバター表示の知識が必要ない方は、シンプル版のコードをこちらのCodePenでお試しください。

アバターの表示

さて、顔検出はとても簡単にできましたが、点や線だけでは見た目がとても怖いので、そのままでは使えません。人型のアバターを表示して、顔のデータを元にアバターを動かしてみましょう。

人型のアバターをテレカン用に出力するのに最低限必要なものはなんでしょうか? バストアップ表示、首の動き、まばたき、口の動きの4つです。この4つを実現できれば自然な感じで会議に参加できます。生きている、話している、聞いている感じが相手に伝わるからです。

アバターの表示にはpixiv three-vrmを使用します。VRMというファイル形式のデータをブラウザで簡単に表示、操作するライブラリです。
顔の検出結果から首の角度の計算をする処理は、本記事執筆時点でfacemeshライブラリには含まれていません。Google ARCoreライブラリには顔の角度を返す関数が存在するので、おそらくそれほど遠くないうちにfacemeshにも追加されるでしょう。顔の輪郭4点からベクトルの外積を用いて顔の法線ベクトルを取得し、回転行列を算出します。
目の開閉の動きは残念ながらfacemeshでは検出できません。タイマーでまばたきをします。
口の動きはfacemeshの出力から直接反映します。facemeshで検出した上唇と下唇の上下距離に応じて、アバターの口の開く量を調整します。

JavaScriptコード解説

コードの重要な箇所を説明していきます。(CodePenにアクセスすると、JS窓にJavaScriptコードが表示されます)

概要

行数 関数 機能
1〜15行目 setupScene関数 VRM表示エリアの初期化
17〜28行目 initVRM関数 VRMの初期化
30〜40行目 setupCamera関数 カメラオープンとvideoタグへのアタッチ
42〜55行目 estimatePose関数 顔の向き計算
57〜84行目 startRender関数 カメラ画像から顔検出、VRM更新、描画のループ
86〜88行目 loading関数 ローディングアイコン表示・削除
90〜100行目 start関数 VRM・カメラ・facemeshの初期化、描画開始

詳細

JavaScriptコードの重要な箇所を説明していきます。

setupScene関数

2〜5行目:VRM表示をWebGLで行うための初期化
6行目:3Dシーンを透視投影描画するためのカメラ初期化
7行目:3Dオブジェクトの最上位クラスSceneの初期化
8行目:3Dシーンの光源追加
9〜14行目:VRMファイルのダウンロード開始とローディングログ出力

  1 function setupScene(vrm_parent){
  2   window.renderer = new THREE.WebGLRenderer();
  3   renderer.setSize(320, 240);
  4   renderer.setPixelRatio(window.devicePixelRatio);
  5   vrm_parent.appendChild(renderer.domElement);
  6   window.camera = new THREE.PerspectiveCamera(50.0, 4.0 / 3.0, 0.1, 5.0);
  7   window.scene = new THREE.Scene();
  8   scene.add(new THREE.DirectionalLight(0xffffff));
  9   new THREE.GLTFLoader().load(
 10     "https://pixiv.github.io/three-vrm/examples/models/three-vrm-girl.vrm",
 11     initVRM,
 12     progress => console.log("Loading model...",100.0 * (progress.loaded / progress.total),"%"),
 13     console.error
 14   );
 15 }

initVRM関数

18〜19行目:ダウンロードしたVRMファイルの展開とシーンへの追加
20〜22行目:体の向きを180度回転、腕の角度を地面方向に60度回転
23〜24行目:VRMの顔の中心から5cm上を描画エリアの中心とするようにカメラを移動
25〜26行目:経過時刻を取得するクラスClockの初期化と開始
27行目:3Dシーン描画1回目

 17 async function initVRM(gltf) {
 18   window.vrm = await THREE.VRM.from(gltf);
 19   scene.add(vrm.scene);
 20   vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Hips).rotation.y = Math.PI;
 21   vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.LeftUpperArm).rotation.z = Math.PI * 2 / 5;
 22   vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.RightUpperArm).rotation.z = -Math.PI * 2 / 5;
 23   const head = vrm.humanoid.getBoneNode( THREE.VRMSchema.HumanoidBoneName.Head );
 24   camera.position.set( 0.0, head.getWorldPosition(new THREE.Vector3()).y + 0.05, 0.5 );
 25   window.clock = new THREE.Clock();
 26   clock.start();
 27   renderer.render(scene, camera);
 28 }

setupCamera関数

31〜33行目:カメラのオープン処理(横320ピクセル、縦240ピクセル、オーディオなし)
34〜39行目:カメラ映像の描画準備が整うまで待機

 30 async function setupCamera(videoElement) {
 31   const constraints = {video: {width: 320,height: 240}, audio: false};
 32   const stream = await navigator.mediaDevices.getUserMedia(constraints);
 33   videoElement.srcObject = stream;
 34   return new Promise(resolve => {
 35     videoElement.onloadedmetadata = () => {
 36       videoElement.play();
 37       resolve();
 38     };
 39   });
 40 }

estimatePose関数

43〜47行目:顔の外周のうちの上下左右端4点の座標を取得
48〜49行目:顔の縦軸・横軸ベクトルを取得
50行目:縦軸と横軸の外積から顔の法線ベクトルを計算
51〜54行目:顔の姿勢(回転)行列を先に求めたXYZ軸ベクトルから計算、クォータニオンに変換

 42 function estimatePose(annotations) {
 43   const faces = annotations.silhouette;
 44   const x1 = new THREE.Vector3().fromArray(faces[9]);
 45   const x2 = new THREE.Vector3().fromArray(faces[27]);
 46   const y1 = new THREE.Vector3().fromArray(faces[18]);
 47   const y2 = new THREE.Vector3().fromArray(faces[0]);
 48   const xaxis = x2.sub(x1).normalize();
 49   const yaxis = y2.sub(y1).normalize();
 50   const zaxis = new THREE.Vector3().crossVectors(xaxis, yaxis);
 51   const mat = new THREE.Matrix4().makeBasis(xaxis, yaxis, zaxis).premultiply(
 52     new THREE.Matrix4().makeRotationZ(Math.PI)
 53   );
 54   return new THREE.Quaternion().setFromRotationMatrix(mat);
 55 }

startRender関数

58行目:canvas描画コンテキスト取得
59〜82行目:顔検出、canvas描画更新、VRM更新処理 1回分
60行目:再描画リクエスト
61行目:VRMの物理演算更新
62行目:顔検出処理と結果取得
63〜69行目:顔検出結果の座標をcanvasに点で描画
70〜73行目:顔検出結果から顔の姿勢をクォータニオンとして取得、VRMの頭の向き更新
74〜75行目:5秒に1回、VRMの目をまばたきする
76〜79行目:口の開閉量計算、VRMの口を開閉する
81行目:3Dシーン全体描画更新
83行目:1度目の描画

 57 function startRender(input, output, model) {
 58   const ctx = output.getContext("2d");
 59   async function renderFrame() {
 60     requestAnimationFrame(renderFrame);
 61     vrm.update(clock.getDelta());
 62     const faces = await model.estimateFaces(input, false, false);
 63     ctx.clearRect(0, 0, output.width, output.height);
 64     faces.forEach(face => {
 65       face.scaledMesh.forEach(xy => {
 66         ctx.beginPath();
 67         ctx.arc(xy[0], xy[1], 1, 0, 2 * Math.PI);
 68         ctx.fill();
 69       });
 70       const annotations = face.annotations;
 71       const q = estimatePose(annotations);
 72       const head = vrm.humanoid.getBoneNode(THREE.VRMSchema.HumanoidBoneName.Head);
 73       head.quaternion.slerp(q, 0.1);
 74       const blink = Math.max( 0.0, 1.0 - 10.0 * Math.abs( ( clock.getElapsedTime() % 4.0 ) - 2.0 ) );
 75       vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.Blink, blink);
 76       const lipsLowerInner = annotations.lipsLowerInner[5];
 77       const lipsUpperInner = annotations.lipsUpperInner[5];
 78       const expressionA = Math.max(0, Math.min(1, (lipsLowerInner[1] - lipsUpperInner[1])/10.0));
 79       vrm.blendShapeProxy.setValue(THREE.VRMSchema.BlendShapePresetName.A, expressionA);
 80     });
 81     renderer.render(scene, camera);
 82   }
 83   renderFrame();
 84 }

loading関数

87行目:ローディングアイコン表示・削除

 86 function loading(onoff) {
 87   document.getElementById("loadingicon").style.display = onoff ? "inline" : "none";
 88 } 

start関数

95行目:VRM初期化
97行目:facemeshモデルのダウンロードと初期化 顔検出数1人
98行目:描画ループ開始

 90 async function start() {
 91   const input = document.getElementById("input");
 92   const output = document.getElementById("output");
 93   const vrm_parent = document.getElementById("vrm_parent");
 94   loading(true);
 95   setupScene(vrm_parent);
 96   await setupCamera(input);
 97   const model = await facemesh.load({ maxFaces: 1 });
 98   startRender(input, output, model);
 99   loading(false); 
100 }   

まとめ

ご覧のように、たった100行のコードでテレカン用アバター表示が実現できました。
facemeshには髪の検出や手の検出などの派生ライブラリがあり、上記とほぼ同等のコードでこれらを利用することができます。首の動きは計算結果をそのまま使用すると少しプルプルと震えるような表示になります。EKFというフィルタ処理を行うとなめらかになります。口の動きは開閉量だけでなく、あいうえおの母音によっても変わってきます。音声認識+母音検出によって口の形を変えると、よりリアリティのある動きになるかもしれません。CodePenにアクセスして、このプログラムをベースに修正してみてください。

次回予告

アバターを表示できたら、この加工映像を使ってテレカンしたいですよね。僕は実際にやってみました! 次回はJavaScriptで加工した映像を配信するテクニックについてご紹介したいと思います。

参考