3D模型人脸驱动

前段时间公司要求调研3D模型通过人脸驱动其运动。这在iOS上可以通过苹果提供的ARKit实现,不过使用ARKit有两个缺点,一是只能使用ARKit支持的3D模型格式,一是不能跨平台。所以还是决定研究一下能支持GLB格式并能跨平台的实现方式。 3D模型人脸驱动大概需要实现如下几个功能:

  1. 摄像头采集图像
  2. 检测图像中的人脸,得到人脸的关键点数据
  3. 通过人脸关键点数据计算头的旋转和位移值
  4. 3D模型的渲染,并将上一步得到数据设置给3D模型

由于我的本职工作是iOS开发,所以实现的时候还是在iOS上进行,不过只是摄像头采集图像在各平台有所不同,其他步骤的实现方式都是可以跨平台的。

摄像头采集图像

这一部分都是常用的代码,就不深入讲了。

检测图像中的人脸,得到人脸的关键点数据

1.什么是人脸的关键点

人脸关键点是指给定一张包含人脸的图像,在人脸的面部定位出一系列的关键点,以此来定位眉毛,眼镜,鼻子,嘴巴和脸部轮廓区域等,这些点通常都是各个部位的轮廓点和角点。下图是使用开源库 dlib 对一张人脸图像进行检测得出的结果:

dlib.jpeg

可以看到图中标记了68个关键点,dlib 会将这些点排序返回。

检测效果如如下视频所示:

cc.fp.ps.netease.com/file/6289e0…

可以看到能实时检测出人脸68个关键点,但有一个问题,就是这些点不稳定,一直是在抖动的,这会导致后面我们用这些点计算出的数据也是抖动的,这样效果就很不好了。开源的 dlib 不怎么好用,经过一番 Google,发现 Google 提供了一个超级强大的免费闭源库 MLKit(估计是要可苹果的 ARKit 对打),MLKit能提供132个关键点,而且点非常稳定。实际效果如下:

cc.fp.ps.netease.com/file/6289eb…

通过人脸关键点数据计算头的旋转和位移值

获取到人脸关键点后,就能计算头的旋转角度和移动距离,最终要得到6个数据,头分别绕x,y,z轴的旋转角度,以及头沿着x,y,z轴的离原点的距离。我们假设相机下的人头一开始是在原点的,然后人头会经过旋转和位移,最终在相机下呈现一张二维的人脸,也就是说,我们只需要知道检测出的人脸关键点在这个人头上的位置(在人头上是3维的点),和相机的一些参数,再加上人脸关键点,就应该能够返回计算出人头的旋转和位移。而opencv就提供了这样的接口:


solvePnP implements several algorithms for pose estimation which can be selected using the parameter 
flag. By default it uses the  flag SOLVEPNP_ITERATIVE which is essentially the DLT solution followed by 
Levenberg-Marquardt optimization. SOLVEPNP_P3P uses  only 3 points for calculating the pose and it 
should be used only when using solvePnPRansac.
 
C++: bool solvePnP(InputArray objectPoints, InputArray imagePoints, InputArray cameraMatrix, InputArray 
distCoeffs, OutputArray rvec, OutputArray tvec, bool useExtrinsicGuess=false, int 
flags=SOLVEPNP_ITERATIVE )
复制代码

传入人头的3D关键点数组和对应的人脸的2D点关键点数组,相机内参,就能得到旋转向量和位移向量。

那么如何能够知道人脸关键点在这个人头上的位置上的位置呢,非常准确的位置是得不到的,因为每个人的头都是不一样的。但是我们可以使用一个标准的3D人头模型,去模拟这个实际的头,虽然会有一些误差,但是藉此计算出来的旋转和位移数据也是可以用的。我从网上找了一个3D人头模型(其中心点是坐标的原点),然后取3D人头的n个关键点,这里可以根据自己对准确度的容忍程度进行定义,取的点越多求出的结果越准确,不过点太多实时性就不满足了,这里我取了8个点:

image.png

接下来调用 solvePnP 函数,就能得到旋转向量和位移向量,由于我们需要得是角度和位移值,还需要对旋转向量和位移向量进行计算得到角度和位移值,关键代码如下(具体见demo):


@interface FaceHelperModel : NSObject

@property(nonatomic, assign) float yaw;
@property(nonatomic, assign) float pitch;
@property(nonatomic, assign) float roll;
@property(nonatomic, assign) float translateX;
@property(nonatomic, assign) float translateY;
@property(nonatomic, assign) float translateZ;

@end

@interface FaceHelper : NSObject

- (FaceHelperModel *)getFaceHelperModelWithModelPoints:(NSArray<NSArray *> *)model3DPoints imagePoints:(NSArray<NSArray *> *)image2DPoints size:(CGSize)size;

@end

@implementation FaceHelper

- (FaceHelperModel *)getFaceHelperModelWithModelPoints:(NSArray<NSArray *> *)model3DPoints imagePoints:(NSArray<NSArray *> *)image2DPoints size:(CGSize)size {
    std::vector<cv::Point3d> objectPoints;
    for (NSArray *item in model3DPoints) {
        cv::Point3d point = cv::Point3d([item[0] doubleValue], [item[1] doubleValue], [item[2] doubleValue]);
        objectPoints.push_back(point);
    }
    
    std::vector<cv::Point2d> imagePoints;
    for (NSArray *item in image2DPoints) {
        cv::Point2d point = cv::Point2d([item[0] doubleValue], [item[1] doubleValue]);
        imagePoints.push_back(point);
    }
    
    double focal_length = MAX(size.width, size.height);
    double centerX = size.width / 2;
    double centerY = size.height / 2;
    cv::Mat cameraMatrix = (cv::Mat_<double>(3, 3) <<
            focal_length, 0.0, centerX,
            0.0, focal_length, centerY,
            0.0, 0.0, 1.0);
    cv::Mat distCoeffs = cv::Mat::zeros(4, 1, CV_64FC1);
    cv::Mat rvec, tvec;
    
    bool success = cv::solvePnP(objectPoints, imagePoints, cameraMatrix, distCoeffs, rvec, tvec, false, cv::SOLVEPNP_ITERATIVE);
    
    if (!success) {
        NSLog(@"getFaceHelperModelWithModelPoints fail");
        return nil;
    }
    
    FaceHelperModel *helperModel = [FaceHelperModel new];
    
    NSArray *angleArr = [self getEulerAngle:rvec];
    helperModel.pitch = [angleArr[0] floatValue];
    helperModel.yaw = [angleArr[1] floatValue];
    helperModel.roll = [angleArr[2] floatValue];
    helperModel.translateX = tvec.at<double>(0);
    helperModel.translateY = tvec.at<double>(1);
    helperModel.translateZ = tvec.at<double>(2);
    
    return helperModel;
}

- (NSArray *)getEulerAngle:(cv::Mat)rvec {
    cv::Mat rotM = cv::Mat::eye(3,3,CV_64F);
    Rodrigues(rvec, rotM);
    double theta_x, theta_y,theta_z;
    double PI = 3.14;
    theta_x = atan2(rotM.at<double>(2, 1), rotM.at<double>(2, 2));
    theta_y = atan2(-rotM.at<double>(2, 0),
    sqrt(rotM.at<double>(2, 1)*rotM.at<double>(2, 1) + rotM.at<double>(2, 2)*rotM.at<double>(2, 2)));
    theta_z = atan2(rotM.at<double>(1, 0), rotM.at<double>(0, 0));
    theta_x = theta_x * (180 / PI);
    theta_y = theta_y * (180 / PI);
    theta_z = theta_z * (180 / PI);
    
    return @[@(theta_x), @(theta_y), @(theta_z)];
}

@end

复制代码

3D模型的渲染,并将上一步得到数据设置给3D模型

3D模型渲染,我选用是 filament,filament 是 Google 开源的3D渲染引擎,支持iOS,Android,PC,Web平台。在上一步中,我们得到了人脸的角度和位移值。那么如何应用到 filament 渲染的 3D模型中呢。首先我们需要把 filament 的坐标设置为和 opencv的坐标一样。opencv 和 filament 都是使用右手坐标系,不过opencv默认是x轴向右,y轴向下,filament默认是x轴向右,y轴向上,我们需要将filamenty轴旋转180度,这样两个坐标系就一样了。fiament坐标系调整之后,你想渲染的3D模型会跟着坐标系一起旋转,因此注意要将3D模型旋转会和上一步的3D人头模型一样的方向,最终渲染出来的结果如下图所示:

IMG_1BD834155F27-1.jpeg

可以看到我的3D模型是一个头盔,正面朝里面,背面朝向观察者,我的3D人头模型也是这个方向。方向正确之后,还需要调整头盔的大小,使之和3D人头模型差不多。还需要微调头盔的位置,使其中心和3D人头模型的中心是吻合的。(由于我的头盔模型和人头模型都是网上随便找的,所以需要调整这些东西,如果由美术设计模型就不需要调整这些了)。关键代码如下(具体见demo):

设置filament坐标轴方向和opencv一致:

[weakSelf.player setDefaultCameraProjection:53 near:0.1f far:10000.f];
[weakSelf.player lookAtEyeX:0 eyeY:0 eyeZ:0 targetX:0 targetY:0 targetZ:1000];

- (void)lookAtEyeX:(CGFloat)eyeX eyeY:(CGFloat)eyeY eyeZ:(CGFloat)eyeZ targetX:(CGFloat)targetX targetY:(CGFloat)targetY targetZ:(CGFloat)targetZ {
    math::vec3<float> eye(eyeX, eyeY, eyeZ);
    math::vec3<float> target(targetX, targetY, targetZ);
    
    if (_Manipulator) {
        delete _Manipulator;
    }
    _Manipulator = camutils::Manipulator<float>::Builder()
        .viewport(self.renderSize.width, self.renderSize.height)
        .upVector(0, -1, 0)
        .orbitHomePosition(eye.x, eye.y, eye.z)
        .targetPosition(target.x, target.y, target.z)
        .build(camutils::Mode::ORBIT);
    [self setCameraByName:nil];
}
复制代码

调整3D头盔的方向和位置和3D人头模型大致吻合:

[_player setScale:scale angleX:angleX angleY:angleY angleZ:angleZ translateX:translateX translateY:translateY translateZ:translateZ assetKey:**nil** nodeName:@"" extraDic:@{

        @"preTranslateX": @(preTranslateX),

        @"preTranslateY": @(preTranslateY),

        @"preTranslateZ": @(preTranslateZ),

        @"preAngleZ": @(180)

    }];
 
- (void)_setTransform:(NSDictionary *)transformDic assetKey:(NSString *)assetKey nodeName:(NSString *)nodeName {
 ...
            nodeTransform = nodeTransform * math::mat4f::translation(math::float3{nodeModel.translateX+nodeModel.preTranslateX, nodeModel.translateY+nodeModel.preTranslateY, nodeModel.translateZ+nodeModel.preTranslateZ}) * math::mat4f::rotation(M_PI / 180.f * nodeModel.angleZ, math::float3{0,0,1}) * math::mat4f::rotation(M_PI / 180.f * nodeModel.angleY, math::float3{0,1,0}) * math::mat4f::rotation(M_PI / 180.f * nodeModel.angleX, math::float3{1,0,0}) * math::mat4f::rotation(M_PI / 180.f * nodeModel.preAngleZ, math::float3{0,0,1}) * math::mat4f::scaling(math::float3{nodeModel.scale,nodeModel.scale, nodeModel.scale});

            transformMgr.setTransform(transformInstance, nodeTransform);
            
 ...
}

复制代码

做好上述准备后,将上一步求出的角度和偏移设置给头盔模型,就能使头盔跟随我们的人头了。效果如下:

cc.fp.ps.netease.com/file/62a1ed…

这里有一个问题,就是3D头盔的的展示图层是覆盖在摄像机的图层上的,这样看上去3D头盔就整个遮挡住了人头,可是真实的效果是头盔的后面部分会被人头遮挡住。为了实现这种效果,我们可以借助opengl的混合模式进行渲染。 具体方案是我们除了渲染头盔模型外,还要将之前的3D人头模型也渲染出来,3D人头模型也设置为跟随我们的人头旋转和位置。filament在渲染时会将图形从远到近进行排序,先绘制远的物体,再绘制近的物体。因此实际展示出来的效果:

cc.fp.ps.netease.com/file/62a2bd…

可以看到3D人头模型可以遮挡住头盔的后面部分,接下里我们设置3D人头模型的混合模式,filament没有开放相关的接口,因此需要对源码做些修改,将混合的设置开放出来,关键代码如下:

        mRasterState.blendFunctionSrcRGB   = backend::RasterState::BlendFunction::ZERO;
        mRasterState.blendFunctionSrcAlpha = backend::RasterState::BlendFunction::ZERO;
        mRasterState.blendFunctionDstRGB   = backend::RasterState::BlendFunction::ZERO;
        mRasterState.blendFunctionDstAlpha = backend::RasterState::BlendFunction::ZERO;
复制代码

这里需要对绘制的原理有一定了解,opengl会将图元一个个绘制到渲染缓冲区中,并根据图元的颜色和透明度以及渲染缓冲区对应位置的颜色决定最终该位置的颜色.具体公式如下:

image.png

我这里把右侧所有的变量都设置为0,相当于3D人头所在的这块区域完全不可见且透明,这样就模拟出了我们实际人头遮挡住头盔的效果,效果如下:

cc.fp.ps.netease.com/file/62a2eb…

猜你喜欢

转载自juejin.im/post/7107501885686284296
今日推荐