完成版:1台のウェブカメラで、体・顔・指・手首 — アバタートーク

ウェブカメラが1台。ブラウザのタブが1つ。スーツも、深度カメラも、グローブもなし。これがアバタートークの完成版です。ウェブカメラが、アバターの腕・頭・表情・指・手首を動かします。すべてクライアント側で完結し、映像はこのマシンから出ません。ここまでの4部(ランドマークアバターリターゲティング顔+手)を経て、この最後の仕上げは、ほとんどがバグ取りと、数字に正直になる話でした。コードは GitHub にあります。

左に体・顔・手のランドマークを重ねたウェブカメラ映像、右に両手を上げたポーズを鏡映しにするVRMアバター。読み出しには eyeBlinkLeft が0.24、VRM表情はニュートラルのまま。字幕は「eyes open, reading ~0.3」「so 0.3 is the new zero」。
完成版:1台のウェブカメラから、体・顔・指・手首。リラックスした開いた目でも eyeBlink は約0.3を示すので、その安静時スコアを引き、0.3を新しいゼロにします。

1台のカメラ、1つのタブ — 完成版

4回前は空のページでした。いまは3つの MediaPipe モデル——体・顔・手——が1台のウェブカメラを読み、ブラウザの単一レンダーループで1つのVRMアバターを動かします。体モデルから腕と頭、顔モデルから52個のブレンドシェイプ、手モデルから片手21点。これらを毎フレーム1つにまとめます。インストールも、アップロードも、特別なハードもなし。この記事は、その最後の仕上げ——手首・親指・目・鏡——を辿り、実際にどれだけのコストで動くのかを正直に見ます。

腕の計算では届かない手首

まず手首から。ここにこの手法の限界が出ます。腕の計算が使うのは、肩から肘、肘から手首という向きだけ。でも向きにはひねりの情報がありません。手の位置を変えずに手の平を裏返しても、そのベクトルはどれも変わらない——だから腕の計算をいくら重ねても手首は回りません。手そのものに語らせます。手の平を横切る2本のベクトル、指に沿う向き指の付け根を横切る向きで姿勢は完全に決まり、基準姿勢のペアを測ったペアに重ねる回転が、そのまま手首です。その一部は前腕に渡します。手の平を正面に向ける動きは、手首の関節ではなく、橈骨が尺骨をまたぐ動きだからです。

// 手の平を定義する2本のベクトル:指に沿う向きと、付け根を横切る向き。
const wrist  = toAvatarSpace(lm[WRIST], a, 1);
const middle = toAvatarSpace(lm[MIDDLE_MCP], b, 1);
forward.subVectors(middle, wrist).normalize();          // 指に沿う向き

const index = toAvatarSpace(lm[INDEX_MCP], b, 1);
const pinky = toAvatarSpace(lm[PINKY_MCP], c, 1);
across.subVectors(pinky, index).normalize();            // 付け根を横切る向き

// 基準姿勢の基底を測定した基底に重ねる回転=手首。
basisMatrix(forward, across, measured, scratch);
target.copy(qMeasured).multiply(qRest.invert());
// 腕自身の回転を差し引き、残りを手のボーンに載せる。
chainWorldQuaternion(vrm, FOREARM_CHAINS[side], parent);
target.premultiply(parent.invert());

死んで見えた親指

親指は完全に死んでいるように見えました。でも動いてはいたんです——毎フレーム0.25ラジアンほどが、間違った軸に入っていました。4本の指は手に沿って並ぶので、ある1方向に曲がります。ところが親指は手の平を斜めに横切って生えているので、同じ軸で回すと内側に折れずに横へ開くだけ。解決は、軸を決め打ちせずリグから読むこと。各ボーンについて、子ボーンへの向きが実際の曲がる方向を教えてくれるので、親指も自分専用のヒンジを手に入れます。

// 各指ボーンの本当のヒンジを、決め打ちではなくリグから導く。
// 次のボーンのローカル位置がこのボーンの向き。「手の平の上」との外積が、
// 手の平側へ折り込む軸になる。
const dir  = next.position.clone().normalize();
const axis = new THREE.Vector3().crossVectors(PALM_UP, dir);
if (axis.lengthSq() > 1e-8) this.curlAxes.set(bone, axis.normalize());
// 指は±xに沿うのでzまわりに曲がる。斜めに座る親指はここで別の軸を得る——
// だから今度は、横に開かず内側に折れる。

フレーム精度のタイミング — 再利用と計数

カメラとレンダーループは同じ拍では進みません。しかも MediaPipe は同じタイムスタンプを拒否します——一度でも渡すと、そのセッションの残りはグラフがエラーになる。そこで各トラッカーは、小さくて正直な2つのことをします。映像に新しいフレームが無いときは、前回の結果を再利用する。だからカメラフレームの合間も One Euro のイージングが滑らかに走り続け、カクつきません。そして推論が実際に例外を投げたときは、クラッシュせずに取りこぼしを数える。劣化したトラッキングは、死んだタブではなく、読める数字になります。

/** カメラフレームの合間は前回の結果を返し、イージングを継続させる。 */
update(): HandStream[] {
  if (!this.landmarker || this.video.videoWidth === 0) return [];
  if (this.video.currentTime === this.lastVideoTime) return this.last;  // 再利用
  this.lastVideoTime = this.video.currentTime;

  // タイムスタンプを厳密に前進させる:繰り返しはグラフにとって致命的。
  const timestamp = Math.max(this.lastTimestamp + 1, Math.round(performance.now()));
  this.lastTimestamp = timestamp;

  try {
    const result = this.landmarker.detectForVideo(this.video, timestamp);
    this.last = result.landmarks.map(/* … handedness + world … */);
    return this.last;
  } catch {
    this.droppedFrames++;   // クラッシュせず数える
    return [];
  }
}

ニュートラルな顔:0.3が新しいゼロ

アバターがずっと半目でした。原因は顔モデルの小さな真実です。実際に大きく開いた目でも、スコアは0ではない。3分の1ほど——eyeBlink で0.1〜0.3——で揺れていて、それがそのまま3分の1の瞬きとして入っていたのです。口角も完全にはゼロになりません。だから各表情は、ゲインをかける前に安静時スコアから基準化します:floor を引き、残りを0〜1に再スケールする。すると、リラックスした顔はニュートラルと読まれます。0.3が新しいゼロになり、それを超える動きだけが効きます。

// floor=安静時の顔がすでに出すスコア。基準化してから、ゲイン前に
// 残りの範囲が0..1を保つよう再スケールする。
const raw = sum / n;
let value = ((raw - floor) / (1 - floor)) * gain;
if (name.startsWith("blink")) value -= squint * 0.8;  // 笑うと頬が上がり、誤検出の瞬きに
value = Math.min(1, Math.max(0, value));
// 例:blink は floor 0.4——0.3を出す開いた目は、きれいに0へクランプされる。

これは鏡 — すべての入れ替えは意図的

片目を閉じると、逆の目が閉じました。MediaPipe の左目はあなたの左目、VRM の左目はアバターの左目。素直に対応させると、解剖学的には完璧で、見た目には間違いです——アバターはこちらを向いているから。これがアプリ全体を貫く原則です:これは鏡。あなたの右腕がアバターの左腕を、右手が左手を動かし、左目は画面の同じ側の目を閉じさせる必要がある。どれも意図的な入れ替えで、目だけ、それを忘れていました。

// 手も腕と同じ:人の「右」手がアバターの左を動かし、
// アバター全体が鏡のように振る舞う。
for (const hand of hands) {
  this.poseHand(hand, hand.handedness === "Right" ? "left" : "right", dt);
}

// 瞬きも同じ向きに入れ替える——目と目を素直に対応させると逆目がウインクする:
{ vrm: "blinkLeft",  from: ["eyeBlinkRight"], gain: 1.15, floor: 0.4 },
{ vrm: "blinkRight", from: ["eyeBlinkLeft"],  gain: 1.15, floor: 0.4 },

数字に正直になる

ここからは正直な話です。前のエピソードで私は「毎秒30フレーム出ています」と言いました。測っていませんでした。測ったら、およそ9フレーム——3つのニューラルネットが、毎フレーム、ブラウザのタブの中で動いているからです。ランドマークへの One Euro フィルターは、揺れをおよそ4分の1減らします——目分量ではなく実測です。使えるだけのリアルタイム性はある。でも、言った速さでは全くありませんでした。手を動かさずに前腕だけひねることはまだできず、奥行きは推測のまま、足は最初から呼ばれていません。それでも、タブの中で、カメラ1台で、ちゃんと動く——そしていまフレーム予算は、仮定するものではなく、見るものになりました。

安静状態の同じ分割画面。左に「three models, every frame」、右に「real-time enough, not 30 fps」の字幕、読み出しは hands tracked: 0、VRM表情はニュートラル。
3つの MediaPipe モデル(体・顔・手)が毎フレーム、1つのブラウザタブで動きます。実測はおよそ9fps——使えるだけのリアルタイム性はあるが、シリーズ前半で言った30fpsではありません。

よくある質問

なぜ腕の計算では手首を回せないのですか?

腕の計算が知っているのは向きだけ——肩から肘、肘から手首——で、向きにはひねりがないからです。手を動かさずに手の平を返しても、それらのベクトルは変わらず、ひねりは見えません。手そのものの姿勢を読む必要があります:手の平を横切る2本のベクトル(指に沿う向き、付け根を横切る向き)で姿勢が完全に決まり、基準姿勢へ重ねる回転が手首になります。

なぜ親指はほとんど動かなかったのですか?

間違った軸で折り曲げていたからです。4本の指は手に沿って並び、ある1方向に曲がりますが、親指は手の平を斜めに横切って座るので、同じ軸で回すと内側に折れずに横へ開いてしまいます。各ボーンのヒンジをリグから——子ボーンへの向きから——読むと、親指は自分専用の正しい軸を得て、追従し始めます。

ブラウザのウェブカメラ・モーキャプは本当に30fpsで動きますか?

正直に言うと、ここでは違いました。体・顔・手を同時に——3つの MediaPipe モデルを毎フレーム、1つのブラウザタブで——動かすと、このマシンでは実測およそ9fpsで、以前言った30ではありませんでした。One Euro フィルターは残る揺れをおよそ4分の1取り除きます。使えるだけのリアルタイム性はありますが、毎フレーム3つのネットワークは、仮定せず測るに値する実コストです。

シリーズ全体、タグごとに

ウェブカメラ1台、ブラウザのタブ1つ、そしてあなたのように動き・見つめ・身振りするアバター——スーツも、深度カメラも、マシンから出ていくものもなし。各エピソードは git タグになっているので、どの段階でも取り出して動かせます。シリーズは 第1回:ブラウザ・モーションキャプチャ第2回:VRMアバターの読み込み第3回:リターゲティング第4回:顔・手・フィルタリング → この完成版。全コードは GitHub に。


福島 崇(Takashi Fukushima) — スポーツ科学と姿勢推定。
YouTubeで登録  ·  GitHub  ·  ウェブサイト  ·  お問い合わせ

コメントする

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

上部へスクロール