はじめに
この記事は、↓のものの Face Tracking の部分についての解説です。
FaceRigなんてもう古い!Vをやるならこれを使え!
Unity上でやっていますが、ほぼC#の領域です。C#の基本的なところ(C言語の基礎レベル)がわからないとたぶんわかんない。(ポインタは除く)
~~僕の環境ではDlibDotNetの一部分が動かなかったのでちょこっと改造しています。(該当部分)~~近々、修正が入る予定。
環境
- Unity 2018.3.8f1
- OpenCvSharp3 4.1.1(Nuget)
- DlibDotNet 19.17.0.20191202(Nuget)
OpenCvSharp3・DlibDotNetの基礎
以下、ソースコードでは省略していますが、
OpenCvSharp3を使うときはusing OpenCvSharp;
を、
DlibDotNetを使うときはusing DlibDotNet;
を
以下のようにファイルの先頭に書いてあります。
using OpenCvSharp;
using DlibDotNet;
/* 以下ソースコード
ほげほげ */
OpenCvSharp3 に画像を読み込む
画像を読み込むサンプルです。
Mat mat = new Mat(); //空のMat生成
Mat mat2 = new Mat("image.png", ImreadModes.Grayscale); //画像読み込み
Webカメラの場合こうします。
Mat image = new Mat(); //画像を入れる用のMat生成
VideoCapture caputure = new VideoCapture(); //Webカメラを取り扱うためのオブジェクト生成
caputure.Open(0); //Webカメラ起動
caputure.Read(image); //Webカメラの画像を読み込み
ここで出てくるMat
というのは2(3)次元配列のクラスで、OpenCvの中核的なクラスで、ここに画像を入れます。細かいところまで書くと、とても長くなるのでここではあまり書きません。困ったら公式リファレンスを見てください。
※公式リファレンス : OpenCV cv::Mat Reference
また、VideoCapture.Read(Mat)
で取得したMat
は、VideoCapture
側で管理するのが望ましく、手を加えないほうがよいです。手を加えたいときは以下のように 深い コピーをして、そちらを処理しましょう。
Mat mat_copy = image; // × 浅いコピー
Mat mat_clone = image.Clone(); // 〇 深いコピー
※参考 : OpenCV-CookBook cv::Matの基本処理
また、Mat
は勝手には解放されません。このままだと動画を処理しているとメモリを使い果たすので、以下のように使い終わったら開放してやりましょう。なお、VidoCapture.Read(Mat)
の引数として渡したMat
は自動で管理されるので何もしないのが賢明です。(先ほどだと image
)
//usingパターンで自動的に破棄されるようにする。
using(Mat mat = new Mat()){
//ほげほげ
}
Mat mat = new Mat();
mat.Dispose(); //明示的にリソースを破棄する。
DlibDotNet に画像を読み込む
DlibDotNet 自体には直接Webカメラの画像を読み込みできないので、以下のようにして OpenCvSharp のMat
を変換して使います。これは正直おまじないだと思っても大丈夫です。
using System.Runtime.InteropServices; //これいるよ
Mat mat = new Mat();
caputure.Read(mat);
byte array = new byte[mat.Width * mat.Height * mat.ElemSize()];
Marshal.Copy(mat.Data, array, 0, array.Length);
Array2D<RgbPixel> image = Dlib.LoadImageData<RgbPixel>(array, (uint)mat.Height, (uint)mat.Width, (uint)(mat.Width * mat.ElemSize()));
// imageに画像が入ってる
※参考 : DlibDotNet Wiki
Face Tracking
さて、本題のFace Trackingをする方法を紹介していきますが、その前に画像の前処理についてやっていきます。
画像の前処理
リサイズ
顔を検出するというのは非常に重いです。そこで、画像を小さくしてやることで軽くするというのがこの処理です。なお、顔検出の重さは画像の大きさの2乗(総ピクセル数)に比例します。
このリサイズの重さはほぼないと言っていいぐらいの軽さなので、安心してください。
Mat sourse = new Mat("image.png", ImreadModes.Grayscale); //元画像
Mat output = new Mat(); //フィルター後の画像
Cv2.Resize(sourse, output, new Size(320, 180)); //リサイズ
(第一引数, 第二引数, 第三引数)は(入力画像, 出力画像, リサイズ後の大きさ)です。
また、Mat
クラスは mat.Cols
で横幅を、 mat.Rows
で縦の高さを取得できるので、以下のようにして元画像の n分の1の画像を生成できます。
Mat sourse = new Mat("image.png", ImreadModes.Grayscale);
Mat output = new Mat();
int n = 2; // n分の1のn
Cv2.Resize(sourse, output, new Size(sourse.Cols / n, sourse.Rows / n));
ガウシアンフィルタ・平均化
基本、画像にはノイズがついています。(特に動画だとノイズが大きいです。)そのノイズの影響を少なくするのがこの処理です。なんだか難しそうに聞こえますが、要はこの処理をすると顔の検出が安定するということがわかれば十分です。なお、私は軽量化のためにこの工程を入れていません。
- 平均化 ・・・ 周りの色の平均を真ん中のピクセルの色にする。
- ガウシアンフィルタ ・・・ 上に加えて、中央に近い色ほど重要になるように重み付けする。
Mat sourse = new Mat("image.png", ImreadModes.Grayscale); //元画像
Mat output = new Mat(); //フィルター後の画像
Cv2.GaussianBlur(sourse, output, new Size(5, 5), 0); //ガウシアンフィルタ
Cv2.Blur(sourse, output, new Size(5, 5)); //平均化
それぞれ、(第一引数, 第二引数, 第三引数)は、(入力画像, 出力画像, 「周りの色」の広さ)です。ガウシアンフィルタの(第四引数)は、(どれだけ近さが重要か)を指定します。ここが0だと自動で入れてくれます。
※注 : 第三引数のSize()
は中央のピクセルがある必要があるため、奇数しか許されていません。
※ 参考 : OpenCV Tutorials 画像の平滑化
Face Tracking
それでは本題の Face Tracking をやっていきます。やるのは顔の検出ですが、動画の毎フレームに適応すると Face Tracking となります。
なお、ここから顔の画像が入った画像が OpenCvSharp3 は Mat image
、DlibDotNet は Array2D<RgbPixel> image
があるものとして使っていきます。
OpenCvSharp3 の場合
OpenCvSharp3では、カスケード分類機を作成して顔を検出させます。
なお、顔のカスケードファイルは公式が出してるのでそれを使いましょう。
OpenCVのカスケードファイル in github
CascadeClassifier cascade = new CascadeClassifier(); // カスケード分類機作成
cascade.Load("CASCADE_FILE_NAME.xml"); // カスケード分類機の特徴が書かれたファイルを読み込む
そして、作成したカスケード分類機で顔を検出します。
Rect[] faces = cascade.DetectMultiScale(image); // これで検出
Rect[] faces2 = cascade.DetectMultiScale(image, 1.1, 3, HaarDetectionType.FindBiggestObject, new Size(10, 10), new Size(300,300)); // こんな感じで設定もできる
引数には、画像が入った Mat
を、第二引数以降を与えてパラメーターを調整できます。
返り値は、顔が入っていると思われる長方形の 配列 。Rect
の中身は以下の通り。
プロパティ | 説明 |
---|---|
X | 長方形の左上のx座標。 元画像の左上が0で、右向き正。 |
Y | 長方形の左上のy座標。 元画像の左上が0で、下向き正。 |
Width | 長方形の横幅。 |
Height | 長方形の縦幅。 |
実際に使うときは配列の中身があったり無かったり、複数あったりするので気を付けてください。
さらに詳しく知りたい方は、こちらの記事を見るとよいです。よくまとまっていてわかりやすかったです。
※ 参考 : 【入門者向け解説】openCV顔検出の仕組と実践(detectMultiScale)
DlibDotNet の場合
DlibDotNet では、FrontalFaceDetector
を作成して検出させます。
FrontalFaceDetector detector = Dlib.GetFrontalFaceDetector(); // ForntalFaceDetector作成
そして、作成したForntalFaceDetector
で顔を検出します。
Rectangle[] rectangles = detector.Operator(image); // これで検出
Rectangle[] rectangles2 = detector.Operator(image, 0); // こんな感じで設定もできる
引数には、画像が入ったArray2D<RgbPixel>
を、第二引数を与えてパラメーターを調整できます。
返り値は、顔が入っていると思われる長方形の 配列 。Rectangle
の中身は以下の通り。
プロパティ | 説明 |
---|---|
Left | 長方形の左側のx座標。 元画像の左上が0で、右向き正。 |
Right | 長方形の右側のx座標。 元画像の左上が0で、右向き正。 |
Top | 長方形の上側のy座標。 元画像の左上が0で、下向き正。 |
Bottom | 長方形の下側のy座標。 元画像の左上が0で、下向き正。 |
Width | 長方形の横幅。 |
Height | 長方形の縦幅。 |
実際に使うときは OpenCvSharp3 のほうと同じように配列の中身があったり無かったり、複数あったりするので気を付けてください。
FaceRecognitionDotNetをつかう
DlibDotNetの作者が作ったもので、顔・特徴点の検出、顔の照合ができます。Nugetで配布中。
FaceRecognition.Net
2D上の点を3D上の点に
2D上の物を3D上の物に変換する問題をPnP問題と言い、それを解くための関数が OpenCvSharp3 にあります。・・・しかし、この問題(2D→3D、位置・回転特定)を解くには最低でも6点の3D上の位置関係がわかっている点が必要です。そのために顔の特徴的な点を見つけます。なお、以下の記事がよくまとまっていて、どういう問題か把握するのに役にたちました。(問題を解くところは置いといて)
※参考 : カメラの位置・姿勢推定2 PNP問題 理論編
顔の特徴点を特定
それでは顔の特徴点を見つけていきます。そのために、ShapePredictor
を作成します。
Face Landmarkのファイルは公式が配布しているのでそれを使いましょう(クリックするとダウンロードされます。)
shape pridictor 5 face landmarks
shape pridictor 68 face landmarks
ShapePredictor shape = ShapePredictor.Deserialize("FACE_LANDMARK_FILE_NAME.dat"); // ShapePredictor 作成
作成したShapePreidictor
を使って顔の特徴点を抽出します。
// 5 landmarks だったらここも5
DlibDotNet.Point[] points = new DlibDotNet.Point[68];
//特徴点抽出
using (FullObjectDetection shapes = shape.Detect(image, rectangles))
{ // 5 landmarks だったらここも5
for (uint i = 0; i < 68; i++)
{ // DlibDotNet.Point という扱いやすい形に変える。
points[i] = shapes.GetPart(i);
}
}
ここで注意したいのは、ただのPoint
だと、UnityのものとDlibDotNetのものがあり、エラーが出るということです。 DlibDotNet.Point
としましょう。
Detect( , )の(第一引数, 第二引数)は、(入力画像, 顔がある領域)です。返り値は、検出点をまとめたオブジェクト FuLLObjectDetection
です。
GetPart( )の(引数)は、(ほしい特徴点のインデックス)です。返り値は、指定されたインデックスの点 DlibDotNet.Point
です。
上記のものがどのように検出されるかとか、他の使い方とかは以下の記事に詳しくのっているので、そちらを見てください。
※参考 : Facial landmarks with dlib
※参考 : 機械学習のライブラリ dlib
DlibDotNet を改造
私の環境では ShapePredictor.Deserialize("")
がうまく動作しなかった(Dlibのdllにはなぜか渡すはずの文字列にランダムで何か追加された)ので、DlibDotNetに修正を入れてビルドしました。該当箇所は以下です。
修正が入ました。2019.1202以降は問題ありません。ここは読み飛ばしてください。
60 - var str = Dlib.Encoding.GetBytes(path); //削除
61 - var ret = NativeMethods.deserialize_shape_predictor(str, //削除
60 + var ret = NativeMethods.deserialize_shape_predictor(path, //追加
26 + [DllImport(NativeLibrary, CallingConvention = CallingConvention)] //追加
27 + public static extern ErrorType deserialize_shape_predictor(string filName, out IntPtr predictor, out IntPtr errorMesage); //追加
28 + //追加
PnP問題を解く
PnP問題を解くのですが、私の場合、なぜか関数にMat
を引数として渡してやる方法しか成功しなかったのでそれを紹介します。
まず、現実の物体の3次元の座標をセットします。私の場合、両目の内側・外側・鼻先・口の両端・あごをセットしました。
Point3f[] model_points = new Point3f[8]; // Matにセットする用の配列
model_points[0] = new Point3f(0.0f, 0.03f, 0.11f); // 鼻先
model_points[1] = new Point3f(0.0f, -0.06f, 0.08f); // あご
model_points[2] = new Point3f(-0.048f, 0.07f, 0.066f); // 右目外側
model_points[3] = new Point3f(0.048f, 0.07f, 0.066f); // 左目外側
model_points[4] = new Point3f(-0.03f, -0.007f, 0.088f); // 右くちびる
model_points[5] = new Point3f(0.03f, -0.007f, 0.088f); // 左くちびる
model_points[6] = new Point3f(-0.015f, 0.07f, 0.08f); // 右目内側
model_points[7] = new Point3f(0.015f, 0.07f, 0.08f); // 左目内側
model_points_mat = new Mat(model_points.Length, 1, MatType.CV_32FC3, model_points); // Matに変換
そして、画像から検出された特徴点の使うやつをまとめます。
Point2f[] image_points = new Point2f[8]; // Matにセットする用の配列
image_points[0] = new Point2f(points[30].X, points[30].Y); // 鼻先
image_points[1] = new Point2f(points[8].X, points[8].Y); // あご
image_points[2] = new Point2f(points[45].X, points[45].Y); // 右目外側
image_points[3] = new Point2f(points[36].X, points[36].Y); // 左目外側
image_points[4] = new Point2f(points[54].X, points[54].Y); // 右くちびる
image_points[5] = new Point2f(points[48].X, points[48].Y); // 左くちびる
image_points[6] = new Point2f(points[42].X, points[42].Y); // 右目内側
image_points[7] = new Point2f(points[39].X, points[39].Y); // 左目内側
Mat image_points_mat = new Mat(image_points.Length, 1, MatType.CV_32FC2, image_points); // Matに変換
さらに、PnP問題を解く関数を使うための設定を作っていきます。カメラのゆがみを設定するそうですが、よくわからなかったので理想モデルを元にしています。
Mat dist_coeffs_mat = new Mat(4, 1, MatType.CV_64FC1, 0);
int focal_length = image.Cols;
Point2d center = new Point2d(image.Cols / 2, image.Rows / 2);
double[,] camera_matrix = new double[3, 3] { { focal_length, 0, center.X }, { 0, focal_length, center.Y }, { 0, 0, 1 } };
Mat camera_matrix_mat = new Mat(3, 3, MatType.CV_64FC1, camera_matrix);
結果受け取り用の変数を作り、関数を適用させれば完成...
Mat rvec_mat = new Mat(); // 回転ベクトル
Mat tvec_mat = new Mat(); // 位置ベクトル
Cv2.SolvePnP(model_points_mat, image_points_mat, camera_matrix_mat, dist_coeffs_mat, rvec_mat, tvec_mat); // 関数適応!
と書きたいところですが、このままじゃ使えないのでUnityで扱える形にかえてやります。
Mat projMatrix_mat = new Mat(); // 投影ベクトル
double[] pos_double = double[3]; // 位置の受け取り用
double[] proj = double[9]; // 回転行列受け取り用
Marshal.Copy(tvec_mat.Data, pos_double, 0, 3); // 位置の受け渡し
Cv2.Rodrigues(rvec_mat, projMatrix_mat); // 回転行列化
Marshal.Copy(projMatrix_mat.Data, proj, 0, 9); // 回転行列受け渡し
Vector3 obj_position = default; // 最終的な位置
Vector3 obj_rotation = default; // 最終的な回転
obj_position.x = -(float)pos_double[0]; // 座標軸が違うのでこのよう
obj_position.y = (float)pos_double[1];
obj_position.z = (float)pos_double[2];
obj_rotation = RotMatToQuatanion(proj).eulerAngles; // 回転行列からクォータニオン、クォータニオンからオイラー角に変換
// なお、RotMatToQuatanion()は自作関数で、下に畳んであります。
`Quaternion RotMatToQuatanion(double[] projmat)`
Quaternion RotMatToQuatanion(double[] projmat)
{
Quaternion quaternion = new Quaternion();
double[] elem = new double[4]; // 0:x, 1:y, 2:z, 3:w
elem[0] = projmat[0] - projmat[4] - projmat[8] + 1.0f;
elem[1] = -projmat[0] + projmat[4] - projmat[8] + 1.0f;
elem[2] = -projmat[0] - projmat[4] + projmat[8] + 1.0f;
elem[3] = projmat[0] + projmat[4] + projmat[8] + 1.0f;
uint biggestIndex = 0;
for (uint i = 1; i < 4; i++)
{
if (elem[i] > elem[biggestIndex])
{
biggestIndex = i;
}
}
if (elem[biggestIndex] < 0.0f)
{
return quaternion;
}
float v = (float)Math.Sqrt(elem[biggestIndex]) * 0.5f;
float mult = 0.25f / v;
switch (biggestIndex)
{
case 0:
quaternion.x = v;
quaternion.y = (float)(projmat[1] + projmat[3]) * mult;
quaternion.z = (float)(projmat[6] + projmat[2]) * mult;
quaternion.w = (float)(projmat[5] - projmat[7]) * mult;
break;
case 1:
quaternion.x = (float)(projmat[1] + projmat[3]) * mult;
quaternion.y = v;
quaternion.z = (float)(projmat[5] + projmat[7]) * mult;
quaternion.w = (float)(projmat[6] - projmat[2]) * mult;
break;
case 2:
quaternion.x = (float)(projmat[6] + projmat[2]) * mult;
quaternion.y = (float)(projmat[5] + projmat[7]) * mult;
quaternion.z = v;
quaternion.w = (float)(projmat[1] - projmat[3]) * mult;
break;
case 3:
quaternion.x = (float)(projmat[5] - projmat[7]) * mult;
quaternion.y = (float)(projmat[6] - projmat[2]) * mult;
quaternion.z = (float)(projmat[1] - projmat[3]) * mult;
quaternion.w = v;
break;
}
return quaternion;
}
なお、ここの内容はほぼここを元にしました。
※参考 : OpenCVで顔向き推定を行う
※参考 : Head Pose Estimation using OpenCV and Dlib
画像を表示したい
デバッグ用とかで、カメラ画像、もしくは加工した画像を見たいと思うことがあると思います。たとえば、正確に特徴点がとれているか調べるとか。
そこで、Cv2.Imshow(string name, Mat image)
という関数があるのですが、罠です。
もう一度書きます。Cv2.Imshow(string name, Mat image)は罠です。
もしそのプログラムを走らせたらフリーズして、再起動しないといけません。
ではどうするかというと、 Mat
をTexture2D
に変換して、それを Plane などのオブジェクトに反映させてやります。
おわりに
ここまでとても長かったと思いますが、読んでくださりありがとうございます。何か助けになれたのなら幸いです。