opencv unity opencvunity姿态估计_特征点


话不多说,首先贴上OpenCV的相关链接:

API文档:

OpenCV: OpenCV modulesdocs.opencv.org


官方教程:


OpenCV教程_w3cschoolwww.w3cschool.cn

opencv unity opencvunity姿态估计_特征点_02


就如标题所说,今天我们来解析一下OpenCVForUnity在AR应用当中的一个例子——人脸的3d姿态估计。这个示例也是插件作者写的,但是没有默认包括在基础插件中,需要额外下载一个FaceTracker包,Unity应用商店有(免费),如下图


opencv unity opencvunity姿态估计_opencv unity_03


导入后需要按照提供的文档进行一下配置,最终结果如下


opencv unity opencvunity姿态估计_3d_04

人脸的3d实时姿态估计,可以看出效果还是不错的

可以想象,这样的应用场景是非常多的,比如我们常见的FaceMask!

下面我们来详细分析它的原理!

打开ARHeadWebCamTextureExample.cs,先从


opencv unity opencvunity姿态估计_3d_05


开始,当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 unity opencvunity姿态估计_特征点_02

OpenCV纹理对象的实时姿态估计_w3cschoolwww.w3cschool.cn

opencv unity opencvunity姿态估计_特征点_02


在进行姿态估计之前,需要先对相机进行校准,也在初始化中完成。

校准通过


//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文档。

  • 相机矩阵准备


opencv unity opencvunity姿态估计_特征点_08


其中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中。


opencv unity opencvunity姿态估计_opencv unity_09

姿态估计示意图

头部姿态估计
                    //如果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点,数量越多,人脸特征信息越多,开销越大)。不同特征点数量的版本,每个特征点所表示的人脸位置也是不一样的,从下面的示意图就可以很明显的看出。


opencv unity opencvunity姿态估计_特征点_10

68个特征点

opencv unity opencvunity姿态估计_3d_11

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)
                        );


opencv unity opencvunity姿态估计_特征点_12

注意到左图中箭头所指处的坐标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中就有很多。这个就留给下一期吧。