🧩 概要
単眼カメラを用いて、**顔の向き(姿勢)と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方向が大きく揺れやすい。実用には不向き。 |