FaceRig無しでも中の人(二次元)になりたい!【Unity × OpenCV × Dlib × Live2D】

  • 76
    Like
  • 3
    Comment

※(2017/04/27)公開しているデモアプリをUnityの新しいバージョンでビルドしたものに変更
(WebGL対応については「OpenCV for Unity」AssetのWebGL対応がやって来たヤァ!ヤァ!ヤァ!を参照してください)

はじめに

Unityだけを使ってFaceRigを再現できるのか?

FaceRigとは、Webカメラによる表情モーションキャプチャを行い、その動きを3Dアバターへリアルタイムに反映させることのできるソフトウェアです。
FaceRig Live2D Moduleを追加することによりLive2Dによって生成された2Dアバターを使用することが可能になりました。

ss_a27464e063b50c13e4dc55b00d0e28d9ed5c82ad.600x338.jpg

FaceRig Live2D Module

この記事はUnityと画像処理系Asset(「OpenCV for Unity」&「Dlib FaceLandmark Detector」)とLive2D SDKを駆使して2Dアバターソフトの再現にチャレンジした過程の記録です。

あえてUnityで作るメリットがあるとすれば

モバイルも含めたマルチプラットフォームに対応可能ということぐらいでしょうか。(FaceRigはWindows版のみですが、UnityならLive2D SDKが対応しているWindows、Mac、Android、iOS、WebGLに対応可能)
(と、書いた矢先にこんな記事を発見、FaceRig Mobile Teaser

制作過程

まずは新規プロジェクトを作成。

OpenCV for Unityのインポート

PnP問題を解いて顔器官の頂点群から頭部姿勢の推定(Head Pose Estimation)するために使用。
頭部姿勢推定はDlibFaceLandmarkDetectorWithOpenCVSample内のWebCamTextureARSampleのコードを参考にしました。※仕組みについてはこの記事「OpenCVで顔向き推定を行う」が勉強になります。
(姿勢推定を別の方法で行う場合はOpenCVのインポートは省略してもかまわない)

Dlib FaceLandmark Detectorのインポート

表情モーションのキャプチャに使用。
一緒にDlibFaceLandmarkDetectorWithOpenCVSampleもインポートしておいた。

Live2D Cubism SDK for Unity 2.1のインポート

2Dアバターの表示に使用。モデルはsizukuを使う。
Live2D Cubism SDK 2 のサイトから最新のUnity用のSDKをダウンロードして解凍。
Assetsフォルダに「Live2D」という名前のフォルダを作り、その中にLive2D SDKの「framework」と「lib」フォルダを移動した。
「StreamingAssets」フォルダに、Live2D SDKの「sample/SampleApp1/Assets/Resources/live2d/shizuku」フォルダをコピーした。

(各Assetインポート後のProject)
Qiita_DlibWithLive2D_Assets.jpg

Live2Dモデルの表示はSDKサンプルの「Demo/SimpleModel.cs」をベースに作成した。

Live2DModel.cs
using UnityEngine;
using System;
using System.Collections;
using live2d;
using live2d.framework;

namespace DlibFaceLandmarkDetectorWithLive2DSample
{
    [ExecuteInEditMode]
    public class Live2DModel : MonoBehaviour
    {
        public TextAsset mocFile;
        public TextAsset physicsFile;
        public TextAsset poseFile;
        public Texture2D[] textureFiles;

        public Vector3 PARAM_ANGLE;
        [Range(-1.0f, 2.0f)]
        public float PARAM_EYE_L_OPEN;
        [Range(-1.0f, 2.0f)]
        public float PARAM_EYE_R_OPEN;

        [Range(-1.0f, 1.0f)]
        public float PARAM_EYE_BALL_X;
        [Range(-1.0f, 1.0f)]
        public float PARAM_EYE_BALL_Y;

        [Range(-1.0f, 1.0f)]
        public float PARAM_BROW_L_Y;
        [Range(-1.0f, 1.0f)]
        public float PARAM_BROW_R_Y;

        [Range(0.0f, 2.0f)]
        public float PARAM_MOUTH_OPEN_Y;

        [Range(-1.0f, 1.0f)]
        public float PARAM_MOUTH_SIZE;


        private Live2DModelUnity live2DModel;
        private L2DPhysics physics;
        private L2DPose pose;
        private Matrix4x4 live2DCanvasPos;

        void Start()
        {
            Live2D.init();

            load();
        }


        void load()
        {
            live2DModel = Live2DModelUnity.loadModel(mocFile.bytes);

            for (int i = 0; i < textureFiles.Length; i++)
            {
                live2DModel.setTexture(i, textureFiles[i]);
            }

            float modelWidth = live2DModel.getCanvasWidth();
            live2DCanvasPos = Matrix4x4.Ortho(0, modelWidth, modelWidth, 0, -50.0f, 50.0f);

            if (physicsFile != null) physics = L2DPhysics.load(physicsFile.bytes);

            pose = L2DPose.load(poseFile.bytes);
        }


        void Update()
        {
            if (live2DModel == null) load();
            live2DModel.setMatrix(transform.localToWorldMatrix * live2DCanvasPos);
            if (!Application.isPlaying)
            {
                live2DModel.update();
                return;
            }

            double timeSec = UtSystem.getUserTimeMSec() / 1000.0;
            double t = timeSec * 2 * Math.PI;
            live2DModel.setParamFloat("PARAM_BREATH", (float)(0.5f + 0.5f * Math.Sin(t / 3.0)));

            //
            live2DModel.setParamFloat("PARAM_ANGLE_X", PARAM_ANGLE.x);
            live2DModel.setParamFloat("PARAM_ANGLE_Y", PARAM_ANGLE.y);
            live2DModel.setParamFloat("PARAM_ANGLE_Z", PARAM_ANGLE.z);
            live2DModel.setParamFloat("PARAM_EYE_L_OPEN", PARAM_EYE_L_OPEN);
            live2DModel.setParamFloat("PARAM_EYE_R_OPEN", PARAM_EYE_R_OPEN);
            live2DModel.setParamFloat("PARAM_EYE_BALL_X", PARAM_EYE_BALL_X);
            live2DModel.setParamFloat("PARAM_EYE_BALL_Y", PARAM_EYE_BALL_Y);
            live2DModel.setParamFloat("PARAM_BROW_L_Y", PARAM_BROW_L_Y);
            live2DModel.setParamFloat("PARAM_BROW_R_Y", PARAM_BROW_R_Y);
            live2DModel.setParamFloat("PARAM_MOUTH_OPEN_Y", PARAM_MOUTH_OPEN_Y);
            live2DModel.setParamFloat("PARAM_MOUTH_SIZE", PARAM_MOUTH_SIZE);

            live2DModel.setParamFloat("PARAM_MOUTH_FORM", 0.0f);
            //

            if (physics != null) physics.updateParam(live2DModel);

            if (pose != null) pose.updateParam(live2DModel);


            live2DModel.update();
        }


        void OnRenderObject()
        {
            if (live2DModel == null) load();
            if (live2DModel.getRenderMode() == Live2D.L2D_RENDER_DRAW_MESH_NOW) live2DModel.draw();
        }
    }
}

(無事にLive2Dモデルが表示できた)
Qiita_DlibWithLive2D_Hierar.jpg

表情モーションを2Dキャラクターに適用する

今回のアプリはwebカメラからの画像の入力が必要になるのでDlibFaceLandmarkDetectorWithOpenCVSample内のWebCamTextureToMatSampleをベースにして作成を始めた。

DlibFaceLandmarkDetectorで検出した顔器官の頂点群から、顔の向き、目の開閉、眉の上下、口の幅、口の開閉の度合いを割り出し、毎フレームLive2Dモデルに適用している。

WebCamTextureLive2DSample.cs
 一部抜粋 
        // Update is called once per frame
        void Update ()
        {

            if (webCamTextureToMatHelper.isPlaying () && webCamTextureToMatHelper.didUpdateThisFrame ()) {

                Mat rgbaMat = webCamTextureToMatHelper.GetMat ();

                OpenCVForUnityUtils.SetImage (faceLandmarkDetector, rgbaMat);

                List<UnityEngine.Rect> detectResult = faceLandmarkDetector.Detect ();

                foreach (var rect in detectResult) {

                    OpenCVForUnityUtils.DrawFaceRect (rgbaMat, rect, new Scalar (255, 0, 0, 255), 2);

                    List<Vector2> points = faceLandmarkDetector.DetectLandmark (rect);

                    if (points.Count > 0) {

                        //OpenCVForUnityUtils.DrawFaceLandmark (rgbaMat, points, new Scalar (0, 255, 0, 255), 2);

                        live2DModelUpdate(points);

                        currentFacePoints = points;

                        break;
                    }
                }



                if (isHideCameraImage)
                    Imgproc.rectangle(rgbaMat, new Point(0, 0), new Point(rgbaMat.width(), rgbaMat.height()), new Scalar(0, 0, 0, 255), -1);

                if(currentFacePoints != null)
                    OpenCVForUnityUtils.DrawFaceLandmark(rgbaMat, currentFacePoints, new Scalar(0, 255, 0, 255), 2);


                Imgproc.putText (rgbaMat, "W:" + rgbaMat.width () + " H:" + rgbaMat.height () + " SO:" + Screen.orientation, new Point (5, rgbaMat.rows () - 10), Core.FONT_HERSHEY_SIMPLEX, 0.5, new Scalar (255, 255, 255, 255), 1, Imgproc.LINE_AA, false);

                OpenCVForUnity.Utils.matToTexture2D (rgbaMat, texture, colors);
            }

        }

        private void live2DModelUpdate(List<Vector2> points)
        {

            if (live2DModel != null) {

                //angle
                Vector3 angles = getFaceAngle(points);
                float rotateX = (angles.x > 180) ? angles.x - 360 : angles.x;
                float rotateY = (angles.y > 180) ? angles.y - 360 : angles.y;
                float rotateZ = (angles.z > 180) ? angles.z - 360 : angles.z;
                live2DModel.PARAM_ANGLE.Set(-rotateY, rotateX, -rotateZ);//座標系を変換して渡す


                //eye_open_L
                float eyeOpen_L = getRaitoOfEyeOpen_L(points);
                Debug.Log(eyeOpen_L);
                if (eyeOpen_L > 0.8f && eyeOpen_L < 1.1f) eyeOpen_L = 1;
                if (eyeOpen_L >= 1.1f) eyeOpen_L = 2;
                if (eyeOpen_L < 0.7f) eyeOpen_L = 0;
                live2DModel.PARAM_EYE_L_OPEN = eyeOpen_L;

                //eye_open_R
                float eyeOpen_R = getRaitoOfEyeOpen_R(points);
                if (eyeOpen_R > 0.8f && eyeOpen_R < 1.1f) eyeOpen_R = 1;
                if (eyeOpen_R >= 1.1f) eyeOpen_R = 2;
                if (eyeOpen_R < 0.7f) eyeOpen_R = 0;
                live2DModel.PARAM_EYE_R_OPEN = eyeOpen_R;

                //eye_ball_X
                live2DModel.PARAM_EYE_BALL_X = rotateY / 60f;//視線が必ずカメラ方向を向くようにする
                //eye_ball_Y
                live2DModel.PARAM_EYE_BALL_Y = -rotateX / 60f - 0.25f;//視線が必ずカメラ方向を向くようにする

                //brow_L_Y
                float brow_L_Y = getRaitoOfBROW_L_Y(points);
                live2DModel.PARAM_BROW_L_Y = brow_L_Y;

                //brow_R_Y
                float brow_R_Y = getRaitoOfBROW_R_Y(points);
                live2DModel.PARAM_BROW_R_Y = brow_R_Y;

                //mouth_open
                float mouthOpen = getRaitoOfMouthOpen_Y(points) * 2f;
                if (mouthOpen < 0.3f) mouthOpen = 0;
                live2DModel.PARAM_MOUTH_OPEN_Y = mouthOpen;

                //mouth_size
                float mouthSize = getRaitoOfMouthSize(points);
                live2DModel.PARAM_MOUTH_SIZE = mouthSize;

            }
        }

        //目の開き具合を算出
        private float getRaitoOfEyeOpen_L(List<Vector2> points)
        {
            if (points.Count != 68)
                throw new ArgumentNullException("Invalid landmark_points.");

            return Mathf.Clamp(Mathf.Abs(points[43].y - points[47].y) / (Mathf.Abs(points[43].x - points[44].x) * 0.75f), -0.1f, 2.0f);
        }

        private float getRaitoOfEyeOpen_R(List<Vector2> points)
        {
            if (points.Count != 68)
                throw new ArgumentNullException("Invalid landmark_points.");

            return Mathf.Clamp(Mathf.Abs(points[38].y - points[40].y) / (Mathf.Abs(points[37].x - points[38].x) * 0.75f), -0.1f, 2.0f);
        }

        //眉の上下
        private float getRaitoOfBROW_L_Y(List<Vector2> points)
        {
            if (points.Count != 68)
                throw new ArgumentNullException("Invalid landmark_points.");

            float y = Mathf.Abs(points[24].y - points[27].y) / Mathf.Abs(points[27].y - points[29].y);
            y -= 1;
            y *= 4f;

            return Mathf.Clamp(y, -1.0f, 1.0f);
        }

        private float getRaitoOfBROW_R_Y(List<Vector2> points)
        {
            if (points.Count != 68)
                throw new ArgumentNullException("Invalid landmark_points.");

            float y = Mathf.Abs(points[19].y - points[27].y) / Mathf.Abs(points[27].y - points[29].y);
            y -= 1;
            y *= 4f;

            return Mathf.Clamp(y, -1.0f, 1.0f);
        }

        //口の開き具合を算出
        private float getRaitoOfMouthOpen_Y(List<Vector2> points)
        {
            if (points.Count != 68)
                throw new ArgumentNullException("Invalid landmark_points.");

            return Mathf.Clamp01(Mathf.Abs(points[62].y - points[66].y) / (Mathf.Abs(points[51].y - points[62].y) + Mathf.Abs(points[66].y - points[57].y)));
        }

        //口の幅サイズを算出
        private float getRaitoOfMouthSize(List<Vector2> points)
        {
            if (points.Count != 68)
                throw new ArgumentNullException("Invalid landmark_points.");

            float size = Mathf.Abs(points[48].x - points[54].x) / (Mathf.Abs(points[31].x - points[35].x) * 1.8f);
            size -= 1;
            size *= 4f;

            return Mathf.Clamp(size, -1.0f, 1.0f);
        }
 一部抜粋 

表情モーションはかなり適当&力技な計算式で取得していて、とりあえず表情を作るのに最低限必要なパーツのみに適用している状態。
例えば口の開閉度合いなら上下の唇の厚さの合計と口の縦幅の大きさの比率から割り出しているだけ。
なお、Dlibで検出できるFace Landmarkポイントと顔器官の対応関係はここのページにある画像を見て確認した。
DlibFaceLandmarkDetectorでは黒目の動きが検出できないので、手軽に視線の方向を算出する方法があればぜひ知りたい。

デモアプリ

※要求するカメラ入力サイズによってシーンをを切り替えて試せるように(640x480 or 320x240)デモアプリを修正しました。

  • Android (Unity5.5.1p4)

  • WebGL_asm.jp (Unity5.6.0f3)(WebGL対応のブラウザで実行可能)

  • WebGL_webassembly (Unity5.6.0f3)(WebGL対応かつwebassemblyが有効なブラウザならより高速に実行可能)

動作確認

(動画はiPhone6実機からキャプチャして10fpsのGIF動画に変換したもの)
DlibFaceDetectorWithLive2DSample_cap.gif

iPhone6で試した結果、動きのラグも無いし中の人(二次元)になるのは普通に楽しいことが判明。
android端末でも問題なく動きました。

最後に

中の人(二次元)になる方法【FaceRig × Live2D × Unity × OBS × AVVoiceChanger × 気合】」の記事に触発されたのがきっかけとなり突貫工事で作った割にはそこそこ動いてよかったです。
顔器官検出Asset「Dlib FaceLandmark Detector」の性能を測ることもできました。
Live2Dモデルへの表情の適用はまだまだ改善の余地がありそうです。(特に眉毛の位置や角度が人の感情表現にとって重要な要素である事が分かったので時間があったら手を加えたいです)

実用的に2Dアバターを利用したいなら素直にFaceRigを購入することをオススメします。(¥ 1,480)

追記

3Dモデルに適用した事例を発見!このように3Dモデルにも発展できるのはUnity+Dlibならではですね。

関連記事