Bakulog

獏の夢日記的な何か。

目のトラッキングなしでアバターのまばたき制御をする話

本記事は VTuber Tech #1 Advent Calendar 2019 4日目の記事です。

※コードはほとんど出てきません。

  1. やったこと紹介
  2. コンセプト: まばたきって突発事象ではないよね
  3. 小技A: 首振りまばたき
  4. 小技B: 眼球運動まばたき
  5. 小技C: 句読点まばたき
  6. 最後に: 実装していて思ったこと

1. やったこと紹介

VRMアバターを3つの方法でまばたきさせました。

本ブログではこれらを順に「首振りまばたき」「眼球運動まばたき」「句読点まばたき」と呼びます。

首振りまばたき

 

眼球運動まばたき

 

句読点まばたき

 

これら3つのまばたき実装には共通事項があります。

それは人間の目を直接トラッキングせず実装されていることです。

なぜ目のトラッキングをしないかというと、これらは最終的に拙作VMagicMirrorへ適用する目的で作っているからです。

baku-dreameater.booth.pm

VMagicMirrorはPC向けに作っているVRMアバター制御用のソフトで、マウスとキーボードベースで腕とか目線が動くのが基本機能です。

これに加えてウェブカメラでの顔トラッキングにも対応しています。

 

…が、しかし。この顔トラッキング、目の開閉については精度が悪いです。

精度が出ない理由はいくつかありますが、主にCPU負荷を削ろうとしているのが原因です。

そのため、素朴な実装だとアバターの目の動きが安定しないという課題がありました。

 

目のトラッキング精度を高める事例としてはiPhoneで検出した顔データをPCに投げ込む手法などが有名ですが、そもそも私がiPhoneを持ってないとか、iOSハードを持ってないユーザーを蹴落としたくないといった思いがあり、このアプローチはボツにしました。

 

そうした背景のなかで色々考えた結果、

「いっそ目のトラッキングは完全に捨てて、まばたき動作をうまく生成すれば良いのでは?」

と思い立って何個か実装してみた、というのが今回の記事への経緯です。

 

2. コンセプト: まばたきって突発事象ではないよね

本記事でいちばん主張したいのはこの節です。

まずこちらのNHK記事を読んでください。

www.nhk.or.jp

 

本記事と縁のありそうな部分を大まかに抜き出します。

  • ヒトは目の乾燥を防ぐには20秒に1回まばたきすれば十分
  • ヒトのまばたきは3秒に1回くらいのペース
  • ヒトのまばたきには社会的役割がある
  • 出来事のまとまりの切れ目では、まばたきが発生する

 

これをソフトウェア設計に近い観点で解釈するとこんな感じ。

  • ヒトは目の乾燥を防ぐには20秒に1回まばたきすれば十分
    • → まばたきの最大間隔は20秒くらいにしておけば大丈夫らしい
  • ヒトのまばたきは3秒に1回くらいの頻度
    • →世間でよく見るCGキャラのまばたき頻度は実際のヒトよりやや少なそう
  • ヒトのまばたきには社会的役割がある
    • →上手にまばたきを制御できるとアバターへの親近感が湧くので良い
  • 出来事のまとまりの切れ目では、まばたきが発生する
    • →まばたきはランダムに行うよりも、意味があって発生する状態が正しい

 

実際の人間の目をトラッキングするのはこれらのポイントを抑えるための有効な手段ですが、逆にポイントさえ理解できていれば他にも手段がありそうです。

 

そこで、とくにラストのポイントである「まばたきはランダムに行うよりも、意味があって発生する状態が正しい」というコンセプトのもと、いろいろ作ってみたというのが本記事の残りです。

以下はUnityを想定して書いてますが、Unity以外にも通じる話題が多いと思います。

 

3. 小技A: 首振りまばたき

首の角度が一定時間内にある程度以上変化したらまばたきをさせます。

パラメータを調整すると首をかしげた時にまばたきするようになり、アバターによってはこれだけでも相当可愛くなります。

 

首振りのトラッキングには顔検出アセットのDlibFaceLandmarkDetectorを使っています。「なんだよ結局顔トラッキングしてるじゃん!」と思うかもしれませんが、首振りのトラッキングは輪郭と鼻の位置くらいまで分かれば可能で、目のトラッキングよりも安定します。したがって、そこから生成したまばたき動作も安定してそれっぽく動いてくれます。

 

また別の見方をすると、本手法は「目のことは分からないけど頭の向きだけは分かるよ」というシチュエーションで使えます。そのため、たとえばアイトラッキング非対応のHMD(無印Viveとか)を被っているときにも有効です。そこそこ応用先が広そうでいいですね。

 

ちなみに、首振りの角度を取得するアプローチはいくつか考えられます。

  • NeckボーンとHeadボーンのlocalRotationをかけた値を使って、首から上を動かした分量を計算する
  • Headボーンのrotationを使う、つまり頭がワールド回転としてどっちを向いているのか参照する

今回はHeadボーンのrotationを使う方法を採用しています。これは実装が簡単になります。

また、仮説として「体をどう動かしたにせよ、見ている方向が変わったらまばたきするのでは?」とも考えて、このようにしています。

 

4. 小技B: 眼球運動まばたき

眼球運動の始まりを検出してまばたきをさせます。

具体的には視線方向ベクトルの変化を時間で割って角速度(deg/sec)を求め、角速度があらかじめ決めたしきい値(約15deg/sec)以下からしきい値以上に変化したとき、まばたきをします。

首振りまばたきと似ていますが、こちらは「首はほとんど動かさずに目だけ動かす」というケースに強いです。

 

UnityとVRMの組み合わせの場合、HumanBodyBones.LeftEyeとかHumanBodyBones.RightEyeのボーンを拾ってくると視線方向ベクトルがほぼ直ちに求まります。

//animator: VRMに紐付けられたアニメーター
//HumanBodyBonesにLeftEye, RightEyeというのがあるため、
//(VRM上で定義済みならば)これで目のボーンが取得可能
_leftEye = animator.GetBoneTransform(HumanBodyBones.LeftEye);
_rightEye = animator.GetBoneTransform(HumanBodyBones.RightEye);

// ... 

// orientation: 2つの目が向いてる方向を平均したもの
var orientation = 0.5f * (
    _leftEye.localRotation * Vector3.forward + 
    _rightEye.localRotation * Vector3.forward
    );

//_prevEyeLookOrientation: 1フレーム前の値を保存しておいたやつ
Quaternion
    .FromToRotation(_prevEyeLookOrientation, orientation)
    .ToAngleAxis(out float difAngle, out _);

//rotSpeed : 角速度(deg/sec)
float rotSpeed = difAngle / Time.deltaTime;

 

この手法は適用できると見栄えがいいですが、首振りまばたきよりも癖が強く、適用先は絞られます。

というのも、「目線ってどうやって取得するの?」という問題があるためです。

素朴に実現したい場合、それこそ高精度な顔トラッキングや視線トラッキングが欲しくなります。

 

VMagicMirrorではキャラの目線がマウスポインターを追う仕様なので、幸運にも眼球運動まばたきが使えます。

他のソフトでも、LookAt的な処理をゴリゴリ使っているならこの方法が使えると思います。

 

5. 小技C: 句読点まばたき

音声解析(リップシンク)をもとに、声の切れ目を検出してまばたきするようにしています。

これは上のほうでも紹介したNHK記事と関係が深いため再掲します。

www.nhk.or.jp

記事内の一節にこうあります。

アンドロイドに話の切れ目で瞬きをさせたところ、向かい合って話を聞いていた人間は、アンドロイドの瞬きに引き込まれて、瞬きをしていました。

これをストレートフォワードにアバター制御へ持ち込もう、というのが句読点まばたきの狙いです。

 

手元の実装ではOVRLipSyncから得られるリップシンクの解析情報を使っています。

リップシンクのフレーム情報のなかにVisemesというfloat配列で発音記号ごとの重みが乗っているため、しきい値ベースで喋っているか黙っているかを判別できます。

//発話の状態取得をする根拠となるリップシンク
[SerializeField] private OVRLipSyncContextBase lipSyncContext = null;

//Visemeのなかでこのしきい値を超える値が一つでもあれば、発声中だと判定する
[SerializeField] private float lipSyncVisemeThreshold = 0.1f;


// ...


//リップシンクの更新情報があるかどうかチェック
if(!(lipSyncContext.GetCurrentPhonemeFrame() is OVRLipSync.Frame frame))
{
    return;
}

bool isTalking = false;
//NOTE: Visemesの最初の要素にはsil(無音)があるのでそこは無視
for (int i = 1; i < frame.Visemes.Length; i++)
{
    if (frame.Visemes[i] > lipSyncVisemeThreshold)
    {
        isTalking = true;
        break;
    }
}

なお、上記のコードは毎フレームの判定処理を抜粋したものです。

実際にはノイズ対策として「数フレーム喋っている状態が続いたら、ほんとうに喋り始めたものと判定する」みたいな、ロバスト化のための工夫が必要です。

 

6. 最後に: 実装していて思ったこと

感想として、完全ランダムのまばたきって味気なかったんだな…と思うようになりました。

実は今回やった手法も裏には「何も起きないと10~20秒に1回まばたきする」というランダム処理が入れてあるんですが、ランダム処理の優先度が下がることで以前よりしっくり来るようになりました。

 

また、今回の手法では結果としてまばたき頻度が高いときも嫌悪感が無いように感じました。

対照的な事例として、もし完全ランダムのまばたきを高頻度、つまり3~4秒に1回ほど発生する設定にしたら、多すぎで不自然に見えるはずです。

 

完全ランダムなまばたき処理しか使わない場合は恐らく、NHK記事にもあった

  • ヒトは目の乾燥を防ぐには20秒に1回まばたきすれば十分

という目安を参照して、けっこう長めの間隔にするほうが適しているのでしょう。

 

今回は以上です。

ソフトウェアの都合しだいで適用手法は変わってくると思いますが、ともあれ正確な目のトラッキング以外でも案外やれることはあるよ、というのが伝わっていれば幸いです。

 

最後に冒頭の繰り返しですが、本記事はVTuber Tech #1 Advent Calendar 2019の4日目の記事です。5日目はGarupanOjisanさんです!

qiita.com