LoginSignup
2
0

Unityでフェイストラッキング

Last updated at Posted at 2023-12-20

これは鈴鹿高専Advent Calendar 2023 21日目の記事です。

2023年1月~4月の間で作ってみたので備忘録的な感じでまとめます。雑なところが多いとは思いますがご了承ください。

フェイストラッキングとは?

直訳すると「顔追跡」で、顔の回転や移動、そして表情の変化をトラッキングすることです。フェイストラッキングで顔の状態をリアルタイムで取得することで様々なことができます。
この記事ではその一例としてVTuberになってみましょう!

Tech Stack

まずは使う技術スタックからです。

  • Unity
  • OpenCVSharp
  • DlibDotNet

今回はこのスタックで実装しまし。ただしDlibの精度がそこそこでパラメータ調整(後述)に結構苦しんでいるので、Mediapipeなど新しい機械学習ライブラリを使うとより精度の高いトラッキングが実現できるかもしれません。機会があればMediapipeで実装し直して記事を書いてみようと思います。

カメラからの入力

まずリアルタイムでトラッキングするにはカメラから顔が写った映像を取得する必要があります。
OpenCVSharpを使って映像を取得することもできますが、カメラデバイスが複数ある際にカメラを選択するUIを作ることができませんでした。そこで今回はUnityの標準的なカメラのオブジェクトを用います。
UnityではWebCamTextureクラスを使うことでデバイスに接続されている任意のカメラにアクセスができます。また、デバイスはWebCamTexture.devicesで取得することができます。

WebCamTextureのメンバにGetPixels32()があり、これを使うことでカメラ映像のデータを取得できます。このときのカラーはRGBAになっています。
DlibDotNetでトラッキングするためにはOpenCVSharpのMatオブジェクトである必要があるため、次にこの映像データを変換していきます。
DlibではカラーはBGRを使っているためカラーを変更し、またOpenCVとUnityでは座標系が違うためここも変換します。
以下がここまでをまとめたコードです。

using System;
using OpenCVSharp;
using UnityEngine;

public sealed class Camera{
    private int _width = 640;
    private int _height = 360;
    private int _fps = 30:

    private WebCamTexture _webCamTexture;
    private string _currentDevice;

    public int Width{
        get{
            return _width;
        }
    }
    
    public int Height{
        get{
            return _height;
        }
    }

    // singleton
    private static Camera _instance;
    public static Camera Instance{
        get{
            return _instance ??= new Camera();
        }
    }

    // コンストラクタ
    private Camera(){
        var devices = WebCamTexture.devices;
        _currentDevice = devices[0].name;
        _webCamTexture = new WebCamTexture(_currentDevice, _width, _height, _fps);
    }

    // デバイス変更
    public void ChangeCamera(string device){
        if(_currentDevice == device) return;
        if(_webCamTexture != null) _webCamTexture.Stop();
        _currentDevice = device;
        _webCamTexture = new WebCamTexture(_currentDevice, _width, _height, _fps);
    }

    // カメラ起動
    public void Start(){
        if(_webCamTexture != null) _webCamTexture.Play();
    }

    // カメラ停止
    public void Stop(){
        _webCamTexture.Stop();
    }

    // 映像データ取得&Matへ変換
    public Mat GetFrame(){
        if(!_webCamTexture.isPlaying){
            _webCamTexture = new WebCamTexture(_currentDevice, _width, _height, _fps);
            _webCamTexture.Play();
        }

        var height = _webCamTexture.height;
        var width = _webCamTexture.width;

        var mat = new Mat(height, width, MatType.CV_8UC4, _webCamTexture.GetPixels32());
        Cv2.Resize(mat, mat, new Size(_widht, _height));
        Cv2.CvtColor(mat, mat, ColorConversionCodes.RGBA2BGR);
        Cv2.Flip(mat, mat, FlipMode.X);
        return mat;
    }
}

フェイストラッキングしてみる!

映像データを取得できたので顔を検出してトラッキングしてみましょう。
その前に、そもそもフェイストラッキングでは何を取得するのでしょうか。
どの機械学習ライブラリでもフェイストラッキングでは顔の特徴点の座標を取得する場合が多いです。

このように目や鼻など顔を特徴づけるパーツの座標を取得できます。ではまずはこの座標を取得してみましょう。

Dlibは機械学習ライブラリのため、使うには自分で学習させるか学習済みデータをロードするかしなければなりません。今回は学習済みデータを使います。
ダウンロードはこちら
このファイルをStreamingAssets配下に置きプログラム上で読み込むことで学習済みデータを用いて特徴点検出ができます。

特徴点検出までの流れですが

  1. 画像の前処理
  2. 顔を検出
  3. 検出した顔の切り抜き
  4. 特徴点を検出

このように行います。

前処理ではブラーをかけたり色を調整したりしてより精度が出るように画像を加工します。しかし私の環境では前処理の重さに対しての性能向上がバランスが悪かったので前処理工程はスキップしています。

残りの顔検出や切り抜きなどの処理はDlibに実装された関数やクラスを利用すればできます。以下にMatオブジェクトを受け取って特徴点を取得するところの処理を載せます。私は2次元座標は複素数で計算するのが好きなのでそのように変換します。

using System.IO;
using DlibDotNet;
using System.Runtime.InteropServices;
using Cysharp.Threading.Tasks;
using OpenCvSharp;

public static class Detection{
    // 顔検出器
    private static readonly FrontalFaceDetector FaceDetector;
    // 特徴点検出器
    private static readonly ShapePredictor ShapePredictor;

    static Detection(){
        // 各検出器の初期化    
        FaceDetector = Dlib.GetFrontalFaceDetector();

        // 学習済みデータのロード
        using var fs = File.OpenRead(UnityEngine.Application.dataPath +
            "/StreamingAssets/shape_predictor_68_face_landmarks.dat");
        var bytes = new byte[fs.Length];
        fs.Read(bytes, 0, bytes.Length);
        ShapePredictor = ShapePredictor.Deserialize(bytes);
    }

    private static Complex[] Detect(in Mat mat){
        // MatオブジェクトをDlibで扱える型に変換
        var steps = Mat.ElemSize() * Camera.Width;
        var array = new byte[Camera.Height * steps];
        Marshal.Copy(mat.Data, array, 0, array.Length);
        using var image = Dlib.LoadImageData<RgbPixel>(
            array,
            (uint)Camera.Height,
            (uint)Camera.Width,
            (uint)steps
        );

        // 顔検出
        var faces = FaceDetector.Operator(image);
        if(faces.Length is 0) throw new Exception("Cannot find faces");

        // 特徴点取得
        var shapes = ShapePredictor.Detect(image, faces[0]);

        var points = new Complex[68];
        for(uint i = 0; i < 68; i++){
            var point = shapes.GetPart(i);
            points[i] = new Complex(point.X, point.Y);
        }

        return points;
    }
}

顔の状態を計算する

VTuberになるためにはLive2Dのパラメータをこれらの特徴点から計算しなければなりません。ではやっていきましょう。

顔の方向

まずは顔の方向です、回転というとイメージがつくと思います。
先程の処理で取得できた特徴点は2次元空間のものです。この座標系はカメラ座標系ですが、もとはWorld座標系に存在する顔の特徴点のためWorld座標系の透視投影になっているはずです。つまりWorld座標系という3次元空間での特徴点がカメラ座標系の2次元空間のものに変換されたのです。
顔の方向を計算するためにはWorld座標系の3次元での特徴点座標が必要ですが、通常カメラ座標系からWorld座標系に変換するのはz成分の情報が消えているため不可能です。しかし、もしWorld座標系での特徴点のいち関係が予め分かっていたらある程度は推測ができます。

具体的な推測方法ですが、PnP問題と呼ばれるものを解くことで可能となります。推測と言っているようにこれは近似計算になるため正確な値ではないです。しかしLive2Dを動かす目的であれば十分な精度を持っているため問題ありません。
PnP問題を解くためには

  • World座標系での特徴点座標
  • カメラ座標系での特徴点座標
  • カメラの特性・歪みの情報

が必要となっています。魚眼レンズや超広角レンズでない通常のカメラであればカメラの歪みは無いと判断して問題ありません。
PnP問題自体はOpenCVにSolvePnP関数があり、先程のデータを投げれば良い感じに解いてくれます。しかしこの関数で計算できるのは回転ベクトルで、Live2Dを動かすにはオイラー角での回転を計算しなければなりません。そのためロドリゲスの公式を用いて回転ベクトルを回転行列に変換した後にオイラー角へ変換します。
では実際に計算してみましょう。

using OpenCvSharp;
using System;

public static class HeadPoseEstimation{
    // World座標系での特徴点座標(一般的な顔のサンプルデータ)
    private static readonly float[,] Model = {
        { 0.0f, 0.0f, 0.0f },
        { -30.0f, -125.0f, -30.0f },
        { 30.0f, -125.0f, -30.0f },
        { -60.0f, -70.0f, -60.0f },
        { 60.0f, -70.0f, -60.0f },
        { -40.0f, 40.0f, -50.0f },
        { 40.0f, 40.0f, -50.0f },
        { -70.0f, 130.0f, -100.0f },
        { 70.0f, 130.0f, -100.0f },
        { 0.0f, 158.0f, -10.0f },
        { 0.0f, 250.0f, -50.0f }
    };

    private static readonly Mat RMat = new();
    private static readonly Mat RVec = new();
    private static readonly Mat Vec = new();

    public static (float yaw, float pitch, float roll) eulerVec Solve(in Complex[] points){
        // カメラ特性の行列
        _cameraMatrix = new Mat(3, 3, MatType.CV_32FC1, new[,]
            {
                { Camera.Width, 0, Camera.Width / 2.0f },
                { 0, Camera.Width, Camera.height / 2.0f },
                { 0, 0, 1 }
            });

        // カメラ座標系の特徴点座標
        var imagePoints = new [,]
            {
                { points[30].Real, points[30].Imaginary },
                { points[21].Real, points[21].Imaginary },
                { points[22].Real, points[22].Imaginary },
                { points[39].Real, points[39].Imaginary },
                { points[42].Real, points[42].Imaginary },
                { points[31].Real, points[31].Imaginary },
                { points[35].Real, points[35].Imaginary },
                { points[48].Real, points[48].Imaginary },
                { points[54].Real, points[54].Imaginary },
                { points[57].Real, points[57].Imaginary },
                { points[8].Real, points[8].Imaginary }
            };

        Cv2.SolvePnP(ModelPoints, 
            InputArray.Create(imagePoints), 
            _cameraMatrix, 
            new Mat(4, 1, MatType.CV_64FC1, 0), 
            RVec, 
            Vec);

        Cv2.Rodrigues(Rvec, Rmat);

        const float r2d = 180 / (float)Math.PI;

        // 回転行列からオイラー角を計算
        var yaw = Math.Clamp((float)Math.Asin(-RMat.At<double>(2, 0)) * r2d, -30, 30);
        var pitch = Math.Clamp((float)Math.Atan(
            RMat.At<double>(2, 1) / RMat.At<double>(2, 2)) * r2d, -30, 30);
        var roll = Math.Clamp((float)Math.Atan(
            RMat.At<double>(1, 0) / RMat.At<double>(0, 0)) * r2d, -30, 30);

        return (yaw, pitch, roll);
    }
}

これで顔の方向検出とLive2Dのパラメータに合う形式への変換が実装できました。

他のパラメータ

Live2DでVTuberになるには目パチ口パチと呼ばれるものも計算する必要があります。目や口の開き具合を上手く計算する必要がありますが2次元空間上で行っても問題ありません。これらはどういう式で計算するかをかなりチューニングする必要があり、まだ不完全な部分もあるためここには一部のコードのみ載せます。
なおここで使っている複素数構造体は私がfloatで実装し直したもので実部と虚部の取得に短縮形のエイリアスを追加してます。

using System;
using System.Linq;
using Util;

public static class Parser{
    // 目パチ
    private static (float left, float right) GetEyeRatio(in Complex[] points)
    {
        var leftRatio = 
            Math.Abs((points[37].Im - points[41].Im) / (points[38].Re - points[37].Re));
            
        var rightRatio = 
            Math.Abs((points[44].Im - points[46].Im) / (points[43].Re - points[44].Re));

        return (
            Math.Clamp((int)(leftRatio * 2) * 2 / 2f, 0, 1),
            Math.Clamp((int)(rightRatio * 2) * 2 / 2f, 0, 1)
        );
    }

    // 口パチ
    private static (float x, float y) GetMouth(in Complex[] points)
    {
        var mouthForm = 
            1 - (points[64].Real - points[61].Real)/ (points[35].Real - points[31].Real) * 2;
        var mouthOpenY = 
            (int)((points[62].Im - points[65].Im) / (points[29].Im - points[30].Im) * 4) / 4f;
        mouthOpenY = Math.Clamp(Math.Abs(mouthOpenY), 0, 1);
        return (mouthForm, Math.Clamp(mouthOpenY, -1, 1));
    }
}

なお毎フレームで微妙にブレがありそのままではLive2Dモデルが震えるため、実際には前回のパラメータを一定の係数を書けて加算して震えを抑制するように実装しています。またこの計算をやりやすくするためにパラメータの構造体を定義しています。

パラメータの構造体
using System;
using UnityEngine;

public struct Param
{
    public float ParamAngleX;
    public float ParamAngleY;
    public float ParamAngleZ;
    public float ParamEyeLOpen;
    public float ParamEyeROpen;
    public float ParamEyeBallX;
    public float ParamEyeBallY;
    public float ParamBrowLY;
    public float ParamBrowRY;
    public float ParamMouthForm;
    public float ParamMouthOpenY;
    public float ParamCheek;
    public float ParamBreath;
    
    private Param Clamp()
    {
        return new Param
        {
            ParamAngleX = Math.Clamp(ParamAngleX, -30, 30),
            ParamAngleY = Math.Clamp(ParamAngleY, -30, 30),
            ParamAngleZ = Math.Clamp(ParamAngleZ, -30, 30),
            ParamEyeLOpen = Math.Clamp(ParamEyeLOpen, 0, 1),
            ParamEyeROpen = Math.Clamp(ParamEyeROpen, 0, 1),
            ParamEyeBallX = Math.Clamp(ParamEyeBallX, -1, 1),
            ParamEyeBallY = Math.Clamp(ParamEyeBallY, -1, 1),
            ParamBrowLY = Math.Clamp(ParamBrowLY, -1, 1),
            ParamBrowRY = Math.Clamp(ParamBrowRY, -1, 1),
            ParamMouthForm = Math.Clamp(ParamMouthForm, -1, 1),
            ParamMouthOpenY = Math.Clamp(ParamMouthOpenY, 0, 1),
            ParamCheek = Math.Clamp(ParamCheek, 0, 1),
            ParamBreath = Math.Clamp(ParamBreath, 0, 1)
        };
    }
        
    public string ToJson()
    {
        return JsonUtility.ToJson(this);
    }
        
    public static Param FromJson(string json)
    {
        return JsonUtility.FromJson<Param>(json);
    }

    public static Param operator -(in Param param1, in Param param2)
    {
        return new Param
        {
            ParamAngleX = param1.ParamAngleX - param2.ParamAngleX,
            ParamAngleY = param1.ParamAngleY - param2.ParamAngleY,
            ParamAngleZ = param1.ParamAngleZ - param2.ParamAngleZ,
            ParamEyeLOpen = param1.ParamEyeLOpen - param2.ParamEyeLOpen,
            ParamEyeROpen = param1.ParamEyeROpen - param2.ParamEyeROpen,
            ParamEyeBallX = param1.ParamEyeBallX - param2.ParamEyeBallX,
            ParamEyeBallY = param1.ParamEyeBallY - param2.ParamEyeBallY,
            ParamBrowLY = param1.ParamBrowLY - param2.ParamBrowLY,
            ParamBrowRY = param1.ParamBrowRY - param2.ParamBrowRY,
            ParamMouthForm = param1.ParamMouthForm - param2.ParamMouthForm,
            ParamMouthOpenY = param1.ParamMouthOpenY - param2.ParamMouthOpenY,
            ParamCheek = param1.ParamCheek,
            ParamBreath = param1.ParamBreath - param2.ParamBreath
        }.Clamp();
    }

    public static Param operator +(in Param param1, in Param param2)
    {
        return new Param
            {
            ParamAngleX = param1.ParamAngleX + param2.ParamAngleX,
            ParamAngleY = param1.ParamAngleY + param2.ParamAngleY,
            ParamAngleZ = param1.ParamAngleZ + param2.ParamAngleZ,
            ParamEyeLOpen = param1.ParamEyeLOpen + param2.ParamEyeLOpen,
            ParamEyeROpen = param1.ParamEyeROpen + param2.ParamEyeROpen,
            ParamEyeBallX = param1.ParamEyeBallX + param2.ParamEyeBallX,
            ParamEyeBallY = param1.ParamEyeBallY + param2.ParamEyeBallY,
            ParamBrowLY = param1.ParamBrowLY + param2.ParamBrowLY,
            ParamBrowRY = param1.ParamBrowRY + param2.ParamBrowRY,
            ParamMouthForm = param1.ParamMouthForm + param2.ParamMouthForm,
            ParamMouthOpenY = param1.ParamMouthOpenY + param2.ParamMouthOpenY,
            ParamCheek = param1.ParamCheek,
            ParamBreath = param1.ParamBreath + param2.ParamBreath
        }.Clamp();
    }
        
    public static Param operator *(in Param param, in double rate)
    {
        return new Param
        {
            ParamAngleX = param.ParamAngleX * (float)rate,
            ParamAngleY = param.ParamAngleY * (float)rate,
            ParamAngleZ = param.ParamAngleZ * (float)rate,
            ParamEyeLOpen = param.ParamEyeLOpen * (float)rate,
            ParamEyeROpen = param.ParamEyeROpen * (float)rate,
            ParamEyeBallX = param.ParamEyeBallX * (float)rate,
            ParamEyeBallY = param.ParamEyeBallY * (float)rate,
            ParamBrowLY = param.ParamBrowLY * (float)rate,
            ParamBrowRY = param.ParamBrowRY * (float)rate,
            ParamMouthForm = param.ParamMouthForm * (float)rate,
            ParamMouthOpenY = param.ParamMouthOpenY * (float)rate,
            ParamCheek = param.ParamCheek,
            ParamBreath = param.ParamBreath * (float)rate
        }.Clamp();
    }

    public static Param operator *(in double rate, in Param param)
    {
        return new Param
        {
            ParamAngleX = param.ParamAngleX * (float)rate,
            ParamAngleY = param.ParamAngleY * (float)rate,
            ParamAngleZ = param.ParamAngleZ * (float)rate,
            ParamEyeLOpen = param.ParamEyeLOpen * (float)rate,
            ParamEyeROpen = param.ParamEyeROpen * (float)rate,
            ParamEyeBallX = param.ParamEyeBallX * (float)rate,
            ParamEyeBallY = param.ParamEyeBallY * (float)rate,
            ParamBrowLY = param.ParamBrowLY * (float)rate,
            ParamBrowRY = param.ParamBrowRY * (float)rate,
            ParamMouthForm = param.ParamMouthForm * (float)rate,
            ParamMouthOpenY = param.ParamMouthOpenY * (float)rate,
            ParamCheek = param.ParamCheek,
            ParamBreath = param.ParamBreath * (float)rate
        }.Clamp();
    }
        
    public static Param operator /(in Param param, in double rate)
    {
        return new Param
        {
            ParamAngleX = param.ParamAngleX / (float)rate,
            ParamAngleY = param.ParamAngleY / (float)rate,
            ParamAngleZ = param.ParamAngleZ / (float)rate,
            ParamEyeLOpen = param.ParamEyeLOpen / (float)rate,
            ParamEyeROpen = param.ParamEyeROpen / (float)rate,
            ParamEyeBallX = param.ParamEyeBallX / (float)rate,
            ParamEyeBallY = param.ParamEyeBallY / (float)rate,
            ParamBrowLY = param.ParamBrowLY / (float)rate,
            ParamBrowRY = param.ParamBrowRY / (float)rate,
            ParamMouthForm = param.ParamMouthForm / (float)rate,
            ParamMouthOpenY = param.ParamMouthOpenY / (float)rate,
            ParamCheek = param.ParamCheek,
            ParamBreath = param.ParamBreath / (float)rate
        }.Clamp();
    }
}

Live2Dモデルを動かそう!

これでLive2Dモデルのパラメータを計算できました。あとはLive2Dモデルをロードしてこのパラメータを作用させれば晴れてVTuberになれます!
この記事はフェイストラッキングからLive2Dにあったパラメータを計算する処理の解説なのでモデルのロードはぜひ調べてみてください。もしくは下に貼ってある私のリポジトリを眺めてみてください。

開発途中のリポジトリ
https://github.com/snct-ukai/VTuber_Stream_System

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0