リップシンクで画像ベースと音ベースを組み合わせて使う

はじめに

Luppetのアップデートで画像ベースと音声ベースでリップシンクを切り替えれるっていうのがあって「いいな~これ~」ってバチバチに関心したのでこれをやっていき!っていうね。 www.pixiv.net

なお、本記事の僕が書いたプログラム部分に関してはCC0とするので、ご自由にどうぞ。

何で組み合わせて使うん?

リスク分散

音声ベース(OVRLipsync)の方が精度ええからそれだけでええやんけ!!そんな怒号が聞こえる気もするが、それは結構危ない時もあるのだよワトソン君。

Vtuberをよく見る皆さんは、突然Vtuberが口を動かさずに喋る腹話術師になる姿を結構見たことがあるのではないだろうか。あれ生放送とかでしばらく続くと、なかなか胸が苦しくなってくるよね。よく生放送開始が数十分程度延ばされることがあるけど、ワクワクしちゃう(嘘、ハラハラと応援)。

というのも、マイクはなかなか気まぐれな奴で突然音をうまく拾ってくれなくなるんです。これは本当。しかも、解決が面倒になることがある。ケーブルや配線、マイクがダメというハードウェア的問題(分配とかしてると怪しむところ多い)、windowsの設定で音がうまく拾えてないっていうOS的問題(バージョンアップとかイベントでマシンが貸出機とかであるある)、Unityの問題(いつUnityそういうとこやぞ案件に遭遇するかも分からない)、純粋にプログラムの問題(バグは出るもんさ)で、解決に思ったより時間がかかってしまうことが多い。そう、容疑者がいっぱいいる。しかも犯人も一人とは限らない。大変だね、ワトソン君。

だから、いざの時のリスク回避手段としての画像ベースリップシンクである。音が死んでるときは画像ベースに切り替えることでその場は乗り切れる。犯人探しは後でもできる。

平たく言うと、
音声ベースのリップシンクはだけでは不安定なので、画像ベースとスイッチングできるようにしてリスク分散を行う

追記
どうやら認識されない声もあるみたいですね。ちゃんと音が入っていてもどうやらロストすることがあるみたいです。やっぱり音声ベース100%ってのも不安ですね。

表現の拡張

いろんなVtuber見てて、画像ベースのリップシンクも素敵なところいっぱいあるなと(本間ひまわりさんとか画像ベースの良さが遺憾なく発揮されている)。口を動かすときって喋るときだと思ってたけど、全然そんなことない。声に出ない口の表現いっぱいあった。あくびとか声ほとんど出ないけど、口あんぐり開けるもんだし、それでアバターも口あんぐりあけてくれるとすごい可愛い。突然のアクシデントに「ハッ.....!!」ってリアクションで口を開けて止まってるリアクションとかある、可愛い。他にもいっぱいある、素敵。その素敵な表現を捨てるのは勿体ない。

なので、画像ベースのリップシンクも取り入れる。とはいえ、画像ベースのリップシンクに頼り切れるほどの精度は今使っているもので出せない(高い認識精度を持つものは機材やライセンスでお値段がすごい)し、音声ベースにもメリットはあるので、組み合わせて使うようにする。

平たく言うと、
画像ベースでしか出せない素敵な表現もあるのでそれもできるようにするが、画像ベースだけでは精度維持が大変なので音声ベースと組み合わせる

実装のお話

Vtuberシステムとして、以下の記事のようなシステムを使っている(この時からだいぶ変わってるけど)。雑に説明するとOpenCVとDlibによる画像認識、ovrLipSyncによる音素解析が動いている。
qiita.com

とりあえず、インスペクターからリップシンク方式の切り替えを自由にできると楽なのと、後々分かりやすくするために以下のようなenumを作っておく。

public enum  LipSyncType{
    OVRLipSync,
    OpenCV,
    Mix
}

それでできたのが以下のようなコード。LipSyncTypeがOVRLipSyncの時は音ベース100%で適応し、OpenCVの時は画像ベース100%で適応し、Mixのときは音と画像を組み合わせて使う。
getMouthOpenYRatio()は口の縦幅の開き具合、getMouthOpenXRatio ()は口の横幅の開き具合を取得できる。

if(lipSyncType == LipSyncType.OpenCV){
    float mouthOpen = getMouthOpenYRatio (points);

    if (mouthOpen >= 0.7f) {
        mouthOpen = 1.0f;
    } else if (mouthOpen >= 0.25f) {
        mouthOpen = 0.5f;
    } else {
        mouthOpen = 0.0f;
    }
    CVMouthOpenParam = Mathf.Lerp (CVMouthOpenParam, mouthOpen, mouthLeapT);
    Skin.SetBlendShapeWeight((int)OVRLipSync.Viseme.aa,CVMouthSizeParam*100);
    Skin.SetBlendShapeWeight((int)OVRLipSync.Viseme.oh,CVMouthOpenParam*100);
    float mouthSize = getMouthOpenXRatio (points);
    if (mouthSize >= 0.8f) {
        mouthSize = 1.0f;
    } else if (mouthSize >= 0.6f) {
        mouthSize = 0.5f;
    } else {
        mouthSize = 0.0f;
    }
    CVMouthSizeParam = Mathf.Lerp (CVMouthSizeParam, mouthSize, mouthLeapT);
    Skin.SetBlendShapeWeight((int)OVRLipSync.Viseme.ih,CVMouthOpenParam*70);
}else if(lipSyncType == LipSyncType.OVRLipSync){
    ovrLipSyncContext.DoOVRlipsync();
}else if(lipSyncType == LipSyncType.Mix){
    float mouthOpen = getMouthOpenYRatio (points);
    CVMouthOpenParam = Mathf.Lerp (CVMouthOpenParam, mouthOpen, mouthLeapT);
    float mouthSize = getMouthOpenXRatio (points);
    CVMouthSizeParam = Mathf.Lerp (CVMouthSizeParam, mouthSize, mouthLeapT);
    ovrLipSyncContext.DoMixLipsync(CVMouthOpenParam,CVMouthSizeParam);
}

音声ベース(LipSyncTypeがOVRLipSyncのとき)が選択されたときに実行される関数が以下のようなもの。ovrLipSyncでやってる処理ほぼそのままである。frame.Visemes[]に各音素の大きさが入っている。

public void DoOVRlipsync(){
        if((lipsyncContext != null) && (skinnedMeshRenderer != null)){
            OVRLipSync.Frame frame = lipsyncContext.GetCurrentPhonemeFrame();
            // get the current viseme frame
            if (frame != null)
            {
                SetVisemeToMorphTarget(frame);

                SetLaughterToMorphTarget(frame);
            }else{
                Debug.Log("frame null");
            }

            // Update smoothing value
            if (smoothAmount != lipsyncContext.Smoothing)
            {
                lipsyncContext.Smoothing = smoothAmount;
            }
        }
    }

void SetVisemeToMorphTarget(OVRLipSync.Frame frame)
    {
        for (int i = (int)OVRLipSync.Viseme.aa; i < visemeToBlendTargets.Length; i++)
        {
            if (visemeToBlendTargets[i] != -1)
            {
                // Viseme blend weights are in range of 0->1.0, we need to make range 100
                skinnedMeshRenderer.SetBlendShapeWeight(
                    visemeToBlendTargets[i],
                    frame.Visemes[i] * 100.0f);
            }
        }
    }

void SetLaughterToMorphTarget(OVRLipSync.Frame frame)
    {
        if (laughterBlendTarget != -1)
        {
            // Laughter score will be raw classifier output in [0,1]
            float laughterScore = frame.laughterScore;

            // Threshold then re-map to [0,1]
            laughterScore = laughterScore < laughterThreshold ? 0.0f : laughterScore - laughterThreshold;
            laughterScore = Mathf.Min(laughterScore * laughterMultiplier, 1.0f);
            laughterScore *= 1.0f / laughterThreshold;

            skinnedMeshRenderer.SetBlendShapeWeight(
                laughterBlendTarget,
                laughterScore * 100.0f);
        }
    }

画像ベースと音声ベースを組み合わせる場合(LipSyncTypeがMixのとき)が選択されたときに実行される関数が以下のようなもの。画像ベースと音声ベースの値で大きく差がある場合は、大きな値を出している方を信頼する(大体大きく差が出ていて小さい値を出している方はロストしている場合のため)。これで、普通に喋ってる場合は画像ベースと音声ベースによる相互補完で精度向上が見込め、不運にもマイクに音がのらなかった場合でも画像ベースに切り替わって口が動き、カメラに見切れてしまっても音声ベースに切り替わって口が動く。もちろん声を出さないリアクションを取っても口を開いていれば画像ベースのリアクションに切り替わって口が動く。

public void DoMixLipsync(float CVMouthOpenParam,float CVMouthSizeParam){
        if((lipsyncContext != null) && (skinnedMeshRenderer != null))
        {
            OVRLipSync.Frame frame = lipsyncContext.GetCurrentPhonemeFrame();
            // get the current viseme frame
            if (frame != null)
            {
                MixSetVisemeToMorphTarget(frame,CVMouthOpenParam,CVMouthSizeParam);

                SetLaughterToMorphTarget(frame);
            }else{
                Debug.Log("frame null");
            }

            // Update smoothing value
            if (smoothAmount != lipsyncContext.Smoothing)
            {
                lipsyncContext.Smoothing = smoothAmount;
            }
        }
    }

void MixSetVisemeToMorphTarget(OVRLipSync.Frame frame,float cvMouthOpenParam,float cvMouthSizeParam){
        for (int i = (int)OVRLipSync.Viseme.aa; i < visemeToBlendTargets.Length; i++)
        {
            if (visemeToBlendTargets[i] == -1){
                continue;
            }
            //アとオとイの口形状は画像からも判断しやすいので、この3つの母音要素は音声ベースだけじゃなく画像ベースの情報も混ぜる。
            if(i == (int)OVRLipSync.Viseme.aa){
                SetMixMouthMorph(frame.Visemes[(int)OVRLipSync.Viseme.aa],cvMouthOpenParam,visemeToBlendTargets[(int)OVRLipSync.Viseme.aa]);
            }else if(i == (int)OVRLipSync.Viseme.oh){
                SetMixMouthMorph(frame.Visemes[(int)OVRLipSync.Viseme.oh],cvMouthOpenParam,visemeToBlendTargets[(int)OVRLipSync.Viseme.oh]);
            }else if(i == (int)OVRLipSync.Viseme.ih){
                SetMixMouthMorph(frame.Visemes[(int)OVRLipSync.Viseme.ih],cvMouthSizeParam,visemeToBlendTargets[(int)OVRLipSync.Viseme.ih]);
            }else{
                skinnedMeshRenderer.SetBlendShapeWeight(visemeToBlendTargets[i],frame.Visemes[i] * 100.0f);
            }
        }
    }

void SetMixMouthMorph(float ovrMouthParam,float cvMouthParam,int morphNumber){
            //画像解析と音素解析の差
            float defference = ovrMouthParam-cvMouthParam;
            if(Math.Abs(defference) >= 0.5){
                //音素解析の方が圧倒的に大きい:音素解析結果を8割適応
                if(defference >= 0){
                    skinnedMeshRenderer.SetBlendShapeWeight(morphNumber,(((ovrMouthParam*1.8f)+(cvMouthParam*0.2f))*100));
                //画像解析の方が圧倒的に大きい:画像解析結果を8割適応
                }else{
                    skinnedMeshRenderer.SetBlendShapeWeight(morphNumber,(((ovrMouthParam*0.2f)+(cvMouthParam*0.8f))*100));
                }
            //差があまりない場合、同じ割合で適応する
            }else{
                skinnedMeshRenderer.SetBlendShapeWeight(morphNumber,(((ovrMouthParam*0.8f)+(cvMouthParam*0.2f))*100));
            }
        }

おわりに

やっぱ腹話術にならないのってとっても大事だなと。音がロストした時の処理はぜひみんなもしてほしい。とはいえ、僕の実装は雑にやってみたものなので、しっかり設計して作り直さないとなぁ...。(元々画像ベースのフェイシャル変更プログラムをovrLipSyncのリップシンクに無理やり繋げた感じになっているので、リップシンク用のプログラムが散らばった上に依存性が出てきてしまった。)