话不多说,首先贴上OpenCV的相关链接:
API文档:
OpenCV: OpenCV modulesdocs.opencv.org
官方教程:
OpenCV教程_w3cschoolwww.w3cschool.cn
就如标题所说,今天我们来解析一下OpenCVForUnity在AR应用当中的一个例子——人脸的3d姿态估计。这个示例也是插件作者写的,但是没有默认包括在基础插件中,需要额外下载一个FaceTracker包,Unity应用商店有(免费),如下图
导入后需要按照提供的文档进行一下配置,最终结果如下
人脸的3d实时姿态估计,可以看出效果还是不错的
可以想象,这样的应用场景是非常多的,比如我们常见的FaceMask!
下面我们来详细分析它的原理!
打开ARHeadWebCamTextureExample.cs,先从
开始,当WebCam初始化完成后,这个方法被调用。它通过webCamTextureToMatHelper.GetMat ()获得相机图像。根据图像信息设置图像平面的大小,设置了正交相机的视口尺寸以适应图像网格的大小。这是每个例子的通用操作,不再赘述。
//获取相机图像
Mat webCamTextureMat = webCamTextureToMatHelper.GetMat ();
...
//设置展示图像的网格的尺寸
gameObject.transform.localScale = new Vector3 (webCamTextureMat.cols (), webCamTextureMat.rows (), 1);
//设置相机视口
float width = webCamTextureMat.width ();
float height = webCamTextureMat.height ();
float imageSizeScale = 1.0f;
float widthScale = (float)Screen.width / width;
float heightScale = (float)Screen.height / height;
if (widthScale < heightScale) {
Camera.main.orthographicSize = (width * (float)Screen.height / (float)Screen.width) / 2;
imageSizeScale = (float)Screen.height / (float)Screen.width;
} else {
Camera.main.orthographicSize = height / 2;
}
接着开始进入正题。开始前可以先看下官方关于OpenCV中进行姿态估计的教程:
使用OpenCV相机校准_w3cschoolwww.w3cschool.cn
OpenCV纹理对象的实时姿态估计_w3cschoolwww.w3cschool.cn
在进行姿态估计之前,需要先对相机进行校准,也在初始化中完成。
校准通过
//camMatrix:相机矩阵
//imageSize:图像尺寸
//apertureWidth:sensor宽度,单位mm
//apertureHeight:sensor高度
//fovx ,fovy :fov
//focalLength:焦距,单位mm
//principalPoint:主点(mm)
//高宽比 fy/fx
Calib3d.calibrationMatrixValues (camMatrix, imageSize, apertureWidth, apertureHeight, fovx, fovy, focalLength, principalPoint, aspectratio);
来完成,通过输入camMatrix,imageSize从先前估计的摄像机矩阵中计算出各种有用的摄像机特性。这些参数会在后面的姿态估计中使用。具体用法参看OpenCV的API文档。
- 相机矩阵准备
其中cx,cy为图像中心点(像素坐标表示的光学中心),fx,fy表示摄像机焦距(fx,fy通常相等,且等于比较大的那一个)。相机矩阵设置代码如下:
//set cameraparam
int max_d = (int)Mathf.Max (width, height);
double fx = max_d;
double fy = max_d;
double cx = width / 2.0f;
double cy = height / 2.0f;
camMatrix = new Mat (3, 3, CvType.CV_64FC1);
camMatrix.put (0, 0, fx);
camMatrix.put (0, 1, 0);
camMatrix.put (0, 2, cx);
camMatrix.put (1, 0, 0);
camMatrix.put (1, 1, fy);
camMatrix.put (1, 2, cy);
camMatrix.put (2, 0, 0);
camMatrix.put (2, 1, 0);
camMatrix.put (2, 2, 1.0f);
Debug.Log ("camMatrix " + camMatrix.dump ());
//畸变系数设为0表示没有畸变
distCoeffs = new MatOfDouble (0, 0, 0, 0);
- Unity与OpenCV之间的转换
unity与opencv之间存在一些区别:OpenCV使用右手坐标系,Unity为左手坐标系;OpenCV中FOV与Unity中FOV也存在区别;相机坐标系中Z轴的前后关系等。所里在初始化方法中也对这些进行了一些转换。
FOV:
//To convert the difference of the FOV value of the OpenCV and Unity.
double fovXScale = (2.0 * Mathf.Atan ((float) (imageSize.width / (2.0 * fx)))) / (Mathf.Atan2 ((float) cx, (float) fx) + Mathf.Atan2 ((float) (imageSize.width - cx), (float) fx));
double fovYScale = (2.0 * Mathf.Atan ((float) (imageSize.height / (2.0 * fy)))) / (Mathf.Atan2 ((float) cy, (float) fy) + Mathf.Atan2 ((float) (imageSize.height - cy), (float) fy));
Debug.Log ("fovXScale " + fovXScale);
Debug.Log ("fovYScale " + fovYScale);
//Adjust Unity Camera FOV https://github.com/opencv/opencv/commit/8ed1945ccd52501f5ab22bdec6aa1f91f1e2cfd4
if (widthScale < heightScale) {
ARCamera.fieldOfView = (float) (fovx[0] * fovXScale);
} else {
ARCamera.fieldOfView = (float) (fovy[0] * fovYScale);
}
左右手坐标系转换矩阵
invertYM = Matrix4x4.TRS (Vector3.zero, Quaternion.identity, new Vector3 (1, -1, 1));
Z轴向转换矩阵
invertZM = Matrix4x4.TRS (Vector3.zero, Quaternion.identity, new Vector3 (1, 1, -1));
姿态估计
完整的姿态估计在Update方法中。
- 为了尽量提高运行效率,首先获取人脸的矩形区域detectResult,这样后面进行人脸特征点检测就只需要在这一小片区域进行。最终获得特征点List<Vector2> points。
//给faceLandmarkDetector提供图像,faceLandmarkDetector为人脸特征点检测类
OpenCVForUnityUtils.SetImage (faceLandmarkDetector, rgbaMat);
//获得人脸的矩形区域
List<UnityEngine.Rect> detectResult = faceLandmarkDetector.Detect ();
......
//检测人脸特征点
List<Vector2> points = faceLandmarkDetector.DetectLandmark (detectResult[0]);
- 通过标定头模的3d空间位置objectPoints和对应的人脸特征点imagePoints,就可以通过solvePnP进行姿态估计了。最终的姿态数据就保存在rvec,tvec中。
姿态估计示意图
头部姿态估计
//如果tvec是错误的数据或物体不在相机的视场中,则不对估计出的数据进行优化,降低计算量
if (double.IsNaN (tvec_z) || isNotInViewport) {
Calib3d.solvePnP (objectPoints, imagePoints, camMatrix, distCoeffs, rvec, tvec);
} else {
//objectPoints :世界空间中物体上的对象点数组
//imagePoints:与objectPoints对应的图像特征点
//camMatrix:相机矩阵
//distCoeffs:畸变参数(为0表示没有畸变)
//rvec:输出旋转向量(见Rodrigues),它与tvec一起,将模型坐标系中的点引入摄像机坐标系
//tvec:输出和缩放向量
//useExtrinsicGuess:参数用于SOLVEPNP_ITERATIVE。若为真(1),函数分别将提供的rvec和tvec值作为旋转向量和平移向量的初始逼近,并对其进行进一步优化。
//flags:指定求解PnP问题的方法。
Calib3d.solvePnP (objectPoints, imagePoints, camMatrix, distCoeffs, rvec, tvec, true, Calib3d.SOLVEPNP_ITERATIVE);
}
对于objectPoints和imagePoints示例提供了多种特征点数量的版本(68,17,6,5点,数量越多,人脸特征信息越多,开销越大)。不同特征点数量的版本,每个特征点所表示的人脸位置也是不一样的,从下面的示意图就可以很明显的看出。
68个特征点
17个特征点
objectPoints与imagePoints的填充,它们存在一一对应的关系:
//set 3d face object points.
objectPoints68 = new MatOfPoint3f (
new Point3 (-34, 90, 83), //l eye (Interpupillary breadth)
new Point3 (34, 90, 83), //r eye (Interpupillary breadth)
new Point3 (0.0, 50, 117), //nose (Tip)
new Point3 (0.0, 32, 97), //nose (Subnasale)
new Point3 (-79, 90, 10), //l ear (Bitragion breadth)
new Point3 (79, 90, 10) //r ear (Bitragion breadth)
);
//68特征点
imagePoints.fromArray (
new Point ((points[38].x + points[41].x) / 2, (points[38].y + points[41].y) / 2), //l eye (Interpupillary breadth)
new Point ((points[43].x + points[46].x) / 2, (points[43].y + points[46].y) / 2), //r eye (Interpupillary breadth)
new Point (points[30].x, points[30].y), //nose (Tip)
new Point (points[33].x, points[33].y), //nose (Subnasale)
new Point (points[0].x, points[0].y), //l ear (Bitragion breadth)
new Point (points[16].x, points[16].y) //r ear (Bitragion breadth)
);
注意到左图中箭头所指处的坐标z轴为-97,与new Point3 (0.0, 32, 97)相反,这是因为OpenCV中默认使用的右手坐标系
- 不过为了去除不必要的计算,在进行姿态估计前,可以判断前一帧物体是否在相机视场内。
//剔除不在相机视野内的情况
double tvec_x = tvec.get (0, 0) [0], tvec_y = tvec.get (1, 0) [0], tvec_z = tvec.get (2, 0) [0];
bool isNotInViewport = false;
Vector4 pos = VP * new Vector4 ((float) tvec_x, (float) tvec_y, (float) tvec_z, 1.0f);
if (pos.w != 0) {
float x = pos.x / pos.w, y = pos.y / pos.w, z = pos.z / pos.w;
if (x < -1.0f || x > 1.0f || y < -1.0f || y > 1.0f || z < -1.0f || z > 1.0f)
isNotInViewport = true;
}
PV矩阵通过在初始化方法中通过下面方法计算得来:
// 计算AR相机的P*V矩阵,后面用来判断追踪物体是否超出相机范围
// 下面方法可用此方法代替 Matrix4x4 P = ARUtils.CalculateProjectionMatrix (width, height, 0.3f, 2000f);
Matrix4x4 P = ARUtils.CalculateProjectionMatrixFromCameraMatrixValues ((float) fx, (float) fy, (float) cx, (float) cy, width, height, 0.3f, 2000f);
Matrix4x4 V = Matrix4x4.TRS (Vector3.zero, Quaternion.identity, new Vector3 (1, 1, -1));
VP = P * V;
//CalculateProjectionMatrixFromCameraMatrixValues
/// <summary>
/// Calculate projection matrix from camera matrix values.
/// </summary>
/// <param name="fx">Focal length x.</param>
/// <param name="fy">Focal length y.</param>
/// <param name="cx">Image center point x.(principal point x)</param>
/// <param name="cy">Image center point y.(principal point y)</param>
/// <param name="width">Image width.</param>
/// <param name="height">Image height.</param>
/// <param name="near">The near clipping plane distance.</param>
/// <param name="far">The far clipping plane distance.</param>
/// <returns>
/// Projection matrix.
/// </returns>
public static Matrix4x4 CalculateProjectionMatrixFromCameraMatrixValues(float fx, float fy, float cx, float cy, float width, float height, float near, float far)
{
Matrix4x4 projectionMatrix = new Matrix4x4();
projectionMatrix.m00 = 2.0f * fx / width;
projectionMatrix.m02 = 1.0f - 2.0f * cx / width;
projectionMatrix.m11 = 2.0f * fy / height;
projectionMatrix.m12 = -1.0f + 2.0f * cy / height;
projectionMatrix.m22 = -(far + near) / (far - near);
projectionMatrix.m23 = -2.0f * far * near / (far - near);
projectionMatrix.m32 = -1.0f;
return projectionMatrix;
}
至于这个投影矩阵的计算方法我也没搞太懂(后面搞懂了再来补充),不过这里我们也可以通过更简单的方式(通过Unity自带方法)来计算:
public static Matrix4x4 CalculateProjectionMatrix(float width,float height,float near,float far){
//https://docs.unity3d.com/Manual/FrustumSizeAtDistance.html
float fov = 2 * Mathf.Atan2(height * 0.5f,far) * Mathf.Deg2Rad;
float aspect = width / height;
return Matrix4x4.Perspective(fov,aspect,near,far);
}
注意到前面计算P*V矩阵中的V矩阵时,只是简单的给V矩阵的Scale的Z轴填充-1(就是沿Z轴翻转),这是因为:从姿态估计运算中得到的tvec时基于OpenCV的从物体空间->相机坐标系的转换。而在OpenCV中使用右手坐标系,相机Z轴指向前方,这与Unity中有些区别(Unity中,相机空间使用右手坐标系,Z轴指向后方)。所以这里的V只需要将tvec沿Z轴翻转就行了。
转换
完成姿态估计得到rvec,tvec后,由于OpenCV空间和Unity空间的差别,还需要进行转换相关的操作。
- 从rvec,tvec提取转换信息。首先通过ARUtils.ConvertRvecTvecToPoseData将rvec,tvec转换为Unity适用的poseData,然后通过简单低通滤波LowpassPoseData抑制小幅的抖动。最后将poseData转换为变换矩阵,方便下一步使用。
// Convert to unity pose data.
double[] rvecArr = new double[3];
rvec.get (0, 0, rvecArr);
double[] tvecArr = new double[3];
tvec.get (0, 0, tvecArr);
//转换成适用于Unity的PoseData
PoseData poseData = ARUtils.ConvertRvecTvecToPoseData (rvecArr, tvecArr);
//低通滤波,pos/rot中低于这些阈值的更改将被忽略。
if (enableLowPassFilter) {
ARUtils.LowpassPoseData (ref oldPoseData, ref poseData, positionLowPass, rotationLowPass);
}
oldPoseData = poseData;
//创建适用于Unity的变换矩阵
transformationM = Matrix4x4.TRS (poseData.pos, poseData.rot, Vector3.one);
转换到Unity适应的坐标系
//右手坐标系(OpenCV)到左手坐标系(Unity)
ARM = invertYM * transformationM;
//翻转Z轴(OpenCV相机坐标系,z轴指向前面)
ARM = ARM * invertZM;
最终的到了Unity中 物体坐标系->相机坐标系的变换矩阵ARM。
应用
最后就是应用变换了,例子里提供了两种方式:移动物体或者移动相机,来匹配图像与模型。一般情况下我们会选择移动相机(通常着更符合显示规律)。
//shouldMoveARCamera==true:移动相机,不移动物体
if (shouldMoveARCamera) {
//相机空间-》物体空间-》世界空间
ARM = ARGameObject.transform.localToWorldMatrix * ARM.inverse;
ARUtils.SetTransformFromMatrix (ARCamera.transform, ref ARM);
} else {
ARM = ARCamera.transform.localToWorldMatrix * ARM;
ARUtils.SetTransformFromMatrix (ARGameObject.transform, ref ARM);
}
至此整个基于人脸的3d姿态就完成了。
优化
如果感觉运行效率还是太低,可以参考FrameOptimizationExample中的方法:
- 降低用于图像检测和估算的图像分辨率。
- 不要每帧都进行估算,可以选择隔几帧估算一次。
当然上面还有一些用到了的方法没有讲到,比如ARUtils中就有很多。这个就留给下一期吧。