0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

🎯 Unityでの顔向き・位置推定 3手法の比較検証レポート

Posted at

🧩 概要

単眼カメラを用いて、**顔の向き(姿勢)Z方向(奥行)**をリアルタイムに取得するための3つの手法を比較検証しました。
環境はすべて Unity をベースに構築し、外部ライブラリやAIモデルによる違いを確認しています。


🧠 検証した3手法

No 手法 概要 評価
MediaPipe FaceMesh Googleの3D顔ランドマークモデル(468点)。GPU対応で高精度。 精度は高くZ方向も安定。外部依存(Python/Plugin)が多く、Unity統合にやや手間。
OpenCV + solvePnP 顔検出+PnP法で姿勢を算出する古典的手法。 構造は理解しやすいが、顔点やカメラ行列が仮定値のためZ方向が不安定。リアルタイムでは値が揺れやすい。
ONNX + Barracuda (FaceMeshBarracuda) Unity内でONNXモデルをBarracuda推論。MediaPipe相当の精度をUnityのみで再現。 高速・安定・外部依存なし。Z方向もスムーズに追従。初期設定に少し手間。

⚙️ 総合比較

評価項目 比較結果
精度 ③ ≧ ① > ②
処理速度 ③ > ① > ②
安定性 ③ ≧ ① > ②
導入難易度 ②(簡単)< ① < ③(やや手間)

📸 各手法の実装ポイント

① MediaPipe FaceMesh

  • 使用リポジトリ:omuler/MediaPipeUnityPlugin
  • 出力:468点の3Dランドマーク(x, y, z)
  • 強み:安定した推定、Z方向も良好
  • 注意点:導入時にPythonモデルとUnityプラグインの整合が必要
using Mediapipe.Tasks.Vision.FaceLandmarker;
using UnityEngine;
using UnityEngine.UI;

public class FaceZReader : MonoBehaviour
{
	public Mediapipe.Unity.Sample.FaceLandmarkDetection.FaceLandmarkerRunner runner;

	[SerializeField]
	Text debugText;

	void Update()
	{
		if (runner == null)
			return;

		var result = runner.latestResult;
		if (result.faceLandmarks == null || result.faceLandmarks.Count == 0)
			return;

		// faceLandmarks は List<NormalizedLandmarks>
		var faceLandmarks = result.faceLandmarks[0];
		if (faceLandmarks.landmarks == null || faceLandmarks.landmarks.Count == 0)
			return;

		// 鼻先(index 1)取得
		var nose = faceLandmarks.landmarks[1];

		Debug.Log($"Nose: X={nose.x:F3}, Y={nose.y:F3}, Z={nose.z:F3}");

		if (debugText != null)
		{
			debugText.text = $"Nose: X={nose.x:F3}, Y={nose.y:F3}, Z={nose.z:F3}";
		}
	}
}


② OpenCV + solvePnP

 使用アセット:OpenCV for Unity

  • 顔検出(Haar Cascade)+ランドマーク推定(LBF)
  • solvePnP() で3Dモデルと2D点の対応から姿勢を計算
using UnityEngine;
using UnityEngine.UI;
using System.Collections.Generic;
using OpenCVForUnity.CoreModule;
using OpenCVForUnity.ImgprocModule;
using OpenCVForUnity.ObjdetectModule;
using OpenCVForUnity.Calib3dModule;
using OpenCVForUnity.UnityUtils;
using OpenCVForUnity.FaceModule;

using CvRect = OpenCVForUnity.CoreModule.Rect;

public class FacePoseTracker : MonoBehaviour
{
	[Header("追従オブジェクト(例:Cube)")]
	public GameObject headObject;

	[Header("デバッグテキスト(任意)")]
	public Text debugText;

	[SerializeField]
	private float scaleFactor = 4f;

	private WebCamTexture webCamTexture;
	private CascadeClassifier faceCascade;
	private Facemark facemark;
	private Mat rgbaMat;

	private Vector3 smoothPos = Vector3.zero;
	private Vector3 smoothRot = Vector3.zero;

	void Start()
	{
		webCamTexture = new WebCamTexture();
		webCamTexture.Play();
		rgbaMat = new Mat(webCamTexture.height, webCamTexture.width, CvType.CV_8UC4);

		string cascadePath = Utils.getFilePath("haarcascade_frontalface_default.xml");
		faceCascade = new CascadeClassifier(cascadePath);
		if (faceCascade.empty())
		{
			Debug.LogError("顔カスケード読み込み失敗: " + cascadePath);
			return;
		}

		string facemarkPath = Utils.getFilePath("lbfmodel.yaml");
		facemark = Face.createFacemarkLBF();
		facemark.loadModel(facemarkPath);
		Debug.Log("Facemark model loaded.");
	}

	void Update()
	{
		if (webCamTexture == null || !webCamTexture.isPlaying)
			return;

		Utils.webCamTextureToMat(webCamTexture, rgbaMat);
		Imgproc.cvtColor(rgbaMat, rgbaMat, Imgproc.COLOR_RGBA2RGB);

		// 顔検出
		Mat gray = new Mat();
		Imgproc.cvtColor(rgbaMat, gray, Imgproc.COLOR_RGB2GRAY);
		MatOfRect detectedFaces = new MatOfRect();
		faceCascade.detectMultiScale(gray, detectedFaces, 1.1, 4, 0, new Size(100, 100), new Size());
		CvRect[] faces = detectedFaces.toArray();
		if (faces.Length == 0)
		{
			if (debugText) debugText.text = "No face detected";
			return;
		}

		// 最大の顔
		CvRect face = faces[0];
		foreach (var f in faces)
		{
			if (f.width * f.height > face.width * face.height)
				face = f;
		}

		// --- XY位置をFaceTracker式に処理 ---
		float faceCenterX = face.x + face.width / 2f;
		float faceCenterY = face.y + face.height / 2f;

		// 左右反転補正
		float normX = -(faceCenterX / webCamTexture.width - 0.5f) * scaleFactor;
		float normY = (faceCenterY / webCamTexture.height - 0.5f) * scaleFactor;

		// --- Z方向をsolvePnPで求める ---
		MatOfRect faceMat = new MatOfRect(face);
		List<MatOfPoint2f> landmarks = new List<MatOfPoint2f>();
		bool success = facemark.fit(rgbaMat, faceMat, landmarks);
		if (!success || landmarks.Count == 0)
		{
			if (debugText) debugText.text = "Facemark fitting failed";
			return;
		}

		Point[] pts = landmarks[0].toArray();
		if (pts.Length < 68)
		{
			if (debugText) debugText.text = "Landmark insufficient";
			return;
		}

		float cx = (float)face.x + face.width / 2f;
		float cy = (float)face.y + face.height / 2f;

		List<Point> imagePts = new List<Point>
		{
			new Point(pts[30].x - cx, pts[30].y - cy), // nose
            new Point(pts[8].x - cx,  pts[8].y - cy),  // chin
            new Point(pts[36].x - cx, pts[36].y - cy), // left eye
            new Point(pts[45].x - cx, pts[45].y - cy), // right eye
            new Point(pts[48].x - cx, pts[48].y - cy), // left mouth
            new Point(pts[54].x - cx, pts[54].y - cy)  // right mouth
        };
		MatOfPoint2f imagePoints = new MatOfPoint2f();
		imagePoints.fromList(imagePts);

		List<Point3> modelPts = new List<Point3>
		{
			new Point3(0.0f, 0.0f, 0.0f),
			new Point3(0.0f, -330.0f, -65.0f),
			new Point3(-225.0f, 170.0f, -135.0f),
			new Point3(225.0f, 170.0f, -135.0f),
			new Point3(-150.0f, -150.0f, -125.0f),
			new Point3(150.0f, -150.0f, -125.0f)
		};
		MatOfPoint3f modelPoints = new MatOfPoint3f();
		modelPoints.fromList(modelPts);

		double focalLength = webCamTexture.width;
		Point center = new Point(webCamTexture.width / 2, webCamTexture.height / 2);
		Mat camMat = new Mat(3, 3, CvType.CV_64F);
		camMat.put(0, 0,
			focalLength, 0, center.x,
			0, focalLength, center.y,
			0, 0, 1);
		MatOfDouble distCoeffs = new MatOfDouble(0, 0, 0, 0);

		Mat rvec = new Mat();
		Mat tvec = new Mat();
		bool ok = Calib3d.solvePnP(modelPoints, imagePoints, camMat, distCoeffs, rvec, tvec, false, Calib3d.SOLVEPNP_ITERATIVE);
		if (!ok)
		{
			if (debugText) debugText.text = "solvePnP failed";
			return;
		}

		// 回転行列→Quaternion
		Mat rotMat = new Mat();
		Calib3d.Rodrigues(rvec, rotMat);
		Quaternion rot = RotationMatrixToQuaternion(rotMat);

		// tvec(m単位に変換)
		double[] t = new double[3];
		tvec.get(0, 0, t);
		float zVal = (float)t[2] * 0.001f;

		// --- XYとZを統合 ---
		Vector3 targetPos = new Vector3(normX * 5f, -normY * 3f, zVal);

		// スムージング
		smoothPos = Vector3.Lerp(smoothPos, targetPos, 0.1f);
		smoothRot = Vector3.Lerp(smoothRot, rot.eulerAngles, 0.3f);

		if (headObject != null)
		{
			//var smoothPos2 = new Vector3( smoothPos.x, smoothPos.y, smoothPos.z*4f);
			headObject.transform.localPosition = smoothPos;
			headObject.transform.localRotation = Quaternion.Euler(smoothRot);
		}

		if (debugText != null)
		{
			debugText.text =
				$"Pos=({smoothPos.x:F2},{smoothPos.y:F2},{smoothPos.z:F2}) \n" +
				$"Rot=({smoothRot.x:F1},{smoothRot.y:F1},{smoothRot.z:F1})";
		}
	}

	Quaternion RotationMatrixToQuaternion(Mat m)
	{
		double m00 = m.get(0, 0)[0];
		double m01 = m.get(0, 1)[0];
		double m02 = m.get(0, 2)[0];
		double m10 = m.get(1, 0)[0];
		double m11 = m.get(1, 1)[0];
		double m12 = m.get(1, 2)[0];
		double m20 = m.get(2, 0)[0];
		double m21 = m.get(2, 1)[0];
		double m22 = m.get(2, 2)[0];

		float tr = (float)(m00 + m11 + m22);
		Quaternion q = new Quaternion();

		if (tr > 0)
		{
			float s = Mathf.Sqrt(tr + 1.0f) * 2f;
			q.w = 0.25f * s;
			q.x = (float)((m21 - m12) / s);
			q.y = (float)((m02 - m20) / s);
			q.z = (float)((m10 - m01) / s);
		}
		else if ((m00 > m11) && (m00 > m22))
		{
			float s = Mathf.Sqrt(1.0f + (float)m00 - (float)m11 - (float)m22) * 2f;
			q.w = (float)((m21 - m12) / s);
			q.x = 0.25f * s;
			q.y = (float)((m01 + m10) / s);
			q.z = (float)((m02 + m20) / s);
		}
		else if (m11 > m22)
		{
			float s = Mathf.Sqrt(1.0f + (float)m11 - (float)m00 - (float)m22) * 2f;
			q.w = (float)((m02 - m20) / s);
			q.x = (float)((m01 + m10) / s);
			q.y = 0.25f * s;
			q.z = (float)((m12 + m21) / s);
		}
		else
		{
			float s = Mathf.Sqrt(1.0f + (float)m22 - (float)m00 - (float)m11) * 2f;
			q.w = (float)((m10 - m01) / s);
			q.x = (float)((m02 + m20) / s);
			q.y = (float)((m12 + m21) / s);
			q.z = 0.25f * s;
		}

		return q;
	}
}

🔻 課題

  • 顔点のブレとカメラパラメータ誤差により、Z値が大きく変動。
  • キャリブレーションなしではスケール精度が出にくい。

③ ONNX + Barracuda (FaceMeshBarracuda)

  • 使用リポジトリ:keijiro/FaceMeshBarracuda
  • BarracudaでONNXモデルをGPU実行
  • MediaPipeと同等の3D出力をUnityのみで完結して再現

差分のみ記載

FacePipeline_Process.cs

// 最後に検出した鼻位置(正規化UV: 0..1)
// RunPipeline で更新され、Visualizer などから参照可能
Vector2 _lastNose = Vector2.zero;
    void RunPipeline(Texture input)
    {
      ...
        // Cancel if the face detection score is too low.
        if (_faceDetector.Detections.IsEmpty) {
            // 検出が無い場合は鼻情報をクリアしない(直前の値を保持)、
            // 必要ならここで _lastNose = Vector2.zero; にする
            return;
        }
        ...
        // 検出から鼻位置を保存(正規化座標を想定)
        // 注意: Y軸向きはソースにより異なるため、Visualizer側で flip する場合あり
        _lastNose = new Vector2(face.nose.x, face.nose.y);
        ...
    }
// 他パーシャルで参照できるよう内部フィールドのアクセサを用意
public Vector2 GetLastNoseUV() => _lastNose;

FacePipeline_Public.cs
partial class FacePipeline
{
    ...
    #region Nose accessor
    // 最後に検出された鼻の正規化UV(0..1)。Y軸の向きはソースに依存する点に注意。
    public Vector2 LastNose => GetLastNoseUV();
    #endregion
    ...
}
Visualizer.cs
public sealed class Visualizer : MonoBehaviour
{
    ...
    // カメラから鼻への方向(ワールド空間、単位ベクトル)
    Vector3 _lastNoseDirection = Vector3.forward;
    public Vector3 LastNoseDirection => _lastNoseDirection;

    [SerializeField] private Camera camera;

    [Header("鼻の3D推定設定")]
    [SerializeField, Tooltip("頂点 z を距離に変換するための係数(調整用)")]
    float _depthScale = 2f;

    // Refined バッファの頂点数(MediaPipe FaceMesh は通常 468)
    const int FaceVertexCount = 468;
    ...

    void LateUpdate()
    {
        ...
         // --- Nose direction を計算して保持 ---
        var noseUV = _pipeline.LastNose; // 正規化UV(0..1)
        if (noseUV != Vector2.zero)
        {
            var cam = camera;
            if (cam != null)
            {
                // 多くのケースで face.nose はテクスチャ座標系で origin が上側なので
                // Viewport に変換する際に Y を反転する(必要に応じて調整)
                var viewportPoint = new Vector3(noseUV.x, 1f - noseUV.y, 0f);

                var ray = cam.ViewportPointToRay(viewportPoint);
                _lastNoseDirection = ray.direction.normalized;

                // デバッグ表示(カメラ位置から鼻方向へ 2m の線を描画)
                Debug.DrawRay(cam.transform.position, _lastNoseDirection * 2f, Color.green);

                // --- 顔メッシュ頂点を読み出して鼻に最も近い頂点を探す ---
                var refined = _pipeline.RefinedFaceVertexBuffer;
                if (refined != null && refined.count >= FaceVertexCount)
                {
                    // CPU バッファに取得
                    var verts = new UnityEngine.Vector4[FaceVertexCount];
                    refined.GetData(verts, 0, 0, FaceVertexCount);

                    // FaceCropMatrix を使って各頂点を変換(crop 空間 -> 元の正規化座標系)
                    var crop = _pipeline.FaceCropMatrix;

                    float bestDist = float.MaxValue;
                    int bestIdx = -1;
                    float4 bestV = new float4(0,0,0,0);

                    for (int i = 0; i < FaceVertexCount; i++)
                    {
                        var v = new float4(verts[i].x, verts[i].y, verts[i].z, verts[i].w);
                        var tv = math.mul(crop, v); // tv: float4
                        // tv.xy は正規化画像空間(想定)
                        var dx = tv.x - noseUV.x;
                        var dy = tv.y - noseUV.y;
                        var d2 = dx * dx + dy * dy;
                        if (d2 < bestDist)
                        {
                            bestDist = d2;
                            bestIdx = i;
                            bestV = tv;
                        }
                    }

                    if (bestIdx >= 0)
                    {
                        // bestV.z を深度として扱う(単位は不定なので _depthScale で調整)
                        var depth = bestV.z * _depthScale;

                        // ViewportPointToRay のレイ上の距離として変換してワールド座標を得る
                        var noseWorld = ray.GetPoint(depth);

                        Debug.DrawLine(cam.transform.position, noseWorld, Color.red);
                        Debug.Log($"Nose UV: {noseUV}  ClosestVertexIdx: {bestIdx}  Vertex (xyzw): {bestV}  depth(raw): {bestV.z}  depth(scaled): {depth}  WorldPoint: {noseWorld}");
                    }
                    else
                    {
                        Debug.LogWarning("Visualizer: 顔メッシュ頂点が見つかりませんでした。");
                    }
                }
                else
                {
                    Debug.LogWarning("Visualizer: RefinedFaceVertexBuffer が未準備か頂点数が不足しています。");
                }
            }
            else
            {
                Debug.LogWarning("Visualizer: camera フィールドに Camera を割り当ててください。");
            }
        }
       ...
    }

✅ 特徴

  • 高速&安定。WebCam入力で60fps近く動作。
  • 外部依存がなく、完全にUnity環境で完結可能

💬 まとめと考察

現時点では、③ Barracuda (ONNX) が最も実用的。
Unity環境で高速・安定動作し、外部環境を必要としないのが最大の利点。

MediaPipe は高精度だが、セットアップが複雑。
OpenCV+solvePnP は構造理解や実験向けで、実用レベルでは不安定。


🚀 今後の方針

  • Unity内でのリアルタイム性と安定性を優先し、
    Barracudaベース(ONNXモデル) を主軸として開発を進行。
  • MediaPipeは精度検証のリファレンスとして活用。
  • OpenCV+solvePnPは理論比較用として位置づけ。

🔗 参考リポジトリ&使用アセット


🧠 総括

優先度 手法 特徴
🥇 ③ Barracuda (ONNX) Unity内で完結。高速・安定・実用的。 GPU対応で高速かつ安定した処理が可能。MediaPipeと同等の精度でZ方向の追従も滑らか。リアルタイム推定やアプリ統合に最適。
🥈 ① MediaPipe 高精度だが外部依存が多い。 GPU対応で高速かつ安定した処理が可能。MediaPipeと同等の精度でZ方向の追従も滑らか。リアルタイム推定やアプリ統合に最適。
🥉 ② OpenCV + solvePnP 理論理解向け。Z方向が不安定。 理論構造が理解しやすく教育・研究用途に向く。ただしランドマーク精度やカメラ行列の誤差により、Z方向が大きく揺れやすい。実用には不向き。

0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?