顔・手・フィルタリング:ウェブカメラ・アバターの完成 — アバタートーク #4(MediaPipe → three.js)

最後のピースです。パート13で、アバターは私の動きに合わせて体を動かせるようになりました——けれどそれはマネキンでした。指はなく、顔は無表情で、スムージングも「わざと単純なまま」。パート4では、、そしてそれらを「見られる」ものにするスムージングを加えます。3つのMediaPipeモデル(体・顔・手)が、1つのVRMを単一のレンダーループで動かします。コードはfilter.tsexpression.tshands.tsにあり、GitHubで公開しています。(動画・コードの画面表示は英語です。)

分割画面:左のウェブカメラ映像(体・顔・手のランドマーク付き)が、右のVRMアバターを動かし、ポーズ・口を開いた表情・上げた手を鏡のように再現。ブレンドシェイプから表情へのリアルタイム表示付き。
パート4——3つのMediaPipeモデル(体・顔・手)が、1つのVRMを単一のレンダーループで動かす。体も顔も手も、すべてブラウザ内で。

ジッターの罠と One Euro フィルター

生のランドマークはフレームごとに揺れます。素朴なローパスフィルターという分かりやすい対策は、しかし罠です。揺れを消すほど強くかけるとアバターは遅延し、弱くかけると震えが残る。この二律背反を1つの設定では勝てません——2つの問題は異なる速度に存在するからです。One Euro フィルター(Casiez, Roussel & Vogel, CHI 2012)は、設定そのものを変えます。値がどれだけ速く動いているかを見て、カットオフを速度に合わせて上げるのです。止まっていれば強くかけ、静止した手の震えを止める。速く動けば信号を通し、遅延を出さない。

One Euro フィルターをコードで

約40行、定数は2つ。カットオフは minCutoff + beta·|速度|。そして速度の推定値自体もローパスして、1つのノイズサンプルでゲートが開かないようにします。

export class OneEuroFilter {
  constructor(
    private minCutoff = 1.2,  // 静止時のカットオフ — 低いほど安定・遅延
    private beta = 0.02,      // 速度がカットオフをどれだけ上げるか
    private dCutoff = 1.0     // 速度推定そのもののカットオフ
  ) {}

  private static alpha(cutoff: number, dt: number): number {
    const tau = 1 / (2 * Math.PI * cutoff);
    return 1 / (1 + tau / dt);
  }

  filter(value: number, dt: number): number {
    if (!this.started) { this.started = true; this.previous = value; return value; }
    // 速度。1つのノイズでゲートが開かないよう、速度自体もローパス。
    const rawDerivative = (value - this.previous) / dt;
    this.derivative += OneEuroFilter.alpha(this.dCutoff, dt) * (rawDerivative - this.derivative);
    // カットオフは速度で上がる:遅いときは強く、速いときは通す。
    const cutoff = this.minCutoff + this.beta * Math.abs(this.derivative);
    this.previous += OneEuroFilter.alpha(cutoff, dt) * (value - this.previous);
    return this.previous;
  }
}

ボーンではなく「源」で平滑化する

どこでかけるかは、どのフィルターかよりも重要です。パート3ではスムージングをボーンの回転——パイプラインの末端——に置きました。今回はランドマーク、つまり入口でかけます。1ランドマークにつき3つのフィルター(x・y・z)。ジッターは測定の性質なので、源で掃除すれば、その後に導くあらゆる角度がきれいな信号を受け継ぎ、リターゲティングは渡された数値の純粋な関数のままでいられます。

// ランドマークごと・軸ごとに1フィルター — リターゲティングの前に列を平滑化。
export class LandmarkFilter {
  apply<T extends Point3>(points: T[], dt: number): T[] {
    for (let i = 0; i < points.length; i++) {
      const f = this.axes[i] ??= [this.make(), this.make(), this.make()];
      const p = points[i];
      p.x = f[0].filter(p.x, dt);
      p.y = f[1].filter(p.y, dt);
      p.z = f[2].filter(p.z, dt);
    }
    return points;
  }
}

顔 → VRMの表情

ポーズモデルは頭部全体に対して粗い4点しか返しません。顔モデルはその横で走る2つ目のモデルで、コストに見合います:ARKit と同じ52個のブレンドシェイプ——jawOpen、左右それぞれのまばたき、口角、眉——をすべて個別に返します。VRMは独自の小さな表情セットを定義するので、2つの語彙を橋渡しする必要があります:jawOpen は「あ」の口へ、両口角の平均は happy へ、眉を下げると angry へ。

// MediaPipe の52ブレンドシェイプ → VRM 自身の表情チャンネル。
// gain はスコアが1.0に届きにくいのを補正、floor は静止した顔を基準に戻す。
const MAPPING = [
  { vrm: "blink", from: ["eyeBlinkLeft", "eyeBlinkRight"], gain: 1.15, floor: 0.4 },
  { vrm: "aa",    from: ["jawOpen"],                       gain: 1.4,  floor: 0.08 },
  { vrm: "happy", from: ["mouthSmileLeft", "mouthSmileRight"], gain: 1.6, floor: 0.12 },
  { vrm: "angry", from: ["browDownLeft", "browDownRight"], gain: 1.2,  floor: 0.15 },
  // …sad, surprised, そしてリップシンク用の母音
];

そして、必ずハマる落とし穴が1つ:笑うと頬が持ち上がり、それがまばたきのスコアを押し上げるので、笑顔のアバターが延々とまばたきします。頬のスクイント分をまばたきから引けば止まります。(まばたき自体にも対の癖があります——アバターは鏡なので、左右の目はパート3の腕と同じく入れ替わります。)

手 — 開閉(bend)だけを取る

手は3つ目のモデルで、片手21点。4本の指については、曲げ(bend)——各関節で出会う2つのセグメントの間の角度——だけを取り、そのボーン自身の折り曲げ軸まわりに適用します。横方向の開き(splay)は意図的に捨てます:曲げよりずっとノイズが多く、様式化された手ではほとんど見えないからです。静止した手も完全にはまっすぐではないので、関節ごとに小さな基準角度を引きます(最大は親指で、その第1関節は完全伸展でも約0.5ラジアンを示します)——アバターが常時半開きの手を保つのを防ぐためです。

// 指1本につき4関節。ノイズの多い splay は捨て、bend だけを残す。
const bends = [
  angleBetween(lm[WRIST], lm[p1], lm[p2]),
  angleBetween(lm[p1],    lm[p2], lm[p3]),
  angleBetween(lm[p2],    lm[p3], lm[p4]),
];
for (let i = 0; i < 3; i++) {
  const rest = isThumb ? REST_BEND.thumb[i] : REST_BEND.finger;
  const curl = Math.min(MAX_CURL, Math.max(0, bends[i] - rest)) * scale;
  this.drive(bone, curl, dt);   // ボーンの折り曲げ軸まわりに回す
}
完成したウェブカメラ・アバター:体・顔・手を1台のウェブカメラから同時に追跡し、リアルタイムでVRMに鏡映し。ドロップフレームはゼロ。
体+顔+手が同時に、ドロップフレーム0——シリーズ全体が1つのレンダーループで、ノートPCで30fps。

3つのモデル、1つのレンダーループ

つまり、毎ウェブカメラフレームで3つのニューラルネットワークが、ブラウザのタブの中で走る——これがこの回の正直なコストです。3つは1枚の映像フレームと1つのレンダーループを共有し、ライトモデルは小さく、それでもノートPCで30fpsを保ちます。ただしここが、フレーム予算を「当然ある」と仮定するのではなく監視し始める境目です。体・顔・手が同時に、ドロップフレーム0で動きます。

よくある質問

One Euro フィルターとは何で、なぜトラッキングに使うのですか?

速度適応のローパスフィルターです(Casiez, Roussel & Vogel, CHI 2012)。固定のカットオフではなく、信号が速くなるほどカットオフを上げます——値がほぼ静止しているときは強くかけ、速い動きでは信号を通す。これで「遅延か揺れか」の二律背反を、たった2つの定数で回避できます。だからノイズの多いインタラクティブ信号の定番の答えなのです。

ランドマークと最終ボーン回転、どちらを平滑化すべき?

入口、つまりランドマークを推奨します。ジッターは測定の性質なので、リターゲティングの前に掃除すれば、下流で導くすべての角度がきれいな信号を受け継ぎ、リターゲティングは入力の純粋な関数のままでいられます。最終回転だけを平滑化すると、途中の計算にはノイズが残ったままになります。

体・顔・手をブラウザで同時に動かせますか?

できます——同じ映像フレーム上で3つのMediaPipeモデルを、1つのレンダーループで。ここではノートPCで30fps、ドロップフレーム0でした。ライトモデルのおかげで実現できますが、毎フレーム3つのネットワークは、フレーム時間を「余裕がある」と仮定せず意識的に配分し始める境目です。

これがシリーズの完成

ウェブカメラ1台、ブラウザのタブ1つ、そしてあなたのように動き、見て、身振りするアバター——スーツも深度カメラもなく、何も端末の外に出ません。コードはGitHubにあり、エピソードごとにタグ付けしています(パート4は v4-face-hands)。はじめての方は、パート1:ブラウザ・モーションキャプチャパート2:VRMアバターの読み込みパート3:リターゲティング → この完結編へ。


福島 崇(Takashi Fukushima) — Sports Science & Pose Estimation.
YouTubeで登録  ·  GitHub  ·  ウェブサイト  ·  お問い合わせ

コメントする

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

上部へスクロール