前言
本文是讲解特效相机中的大眼瘦脸的实现,完整源码可查看AwemeLike。
要实现瘦脸大眼,首先需要获取到人脸特征点,在本项目中使用的是Face++的人脸识别库,它可以获取到106个人脸特征点,接着再通过变形算法就可以实现了。
1. 瘦脸
项目使用的瘦脸算法是参照这篇文章在OpenGL中利用shader进行实时瘦脸大眼等脸型微调
vec2 curveWarp(vec2 textureCoord, vec2 originPosition, vec2 targetPosition, float delta) {
vec2 offset = vec2(0.0);
vec2 result = vec2(0.0);
vec2 direction = (targetPosition - originPosition) ;
float radius = distance(vec2(targetPosition.x, targetPosition.y / aspectRatio), vec2(originPosition.x, originPosition.y / aspectRatio));
float ratio = distance(vec2(textureCoord.x, textureCoord.y / aspectRatio), vec2(originPosition.x, originPosition.y / aspectRatio)) / radius;
ratio = 1.0 - ratio;
ratio = clamp(ratio, 0.0, 1.0);
offset = direction * ratio * delta;
result = textureCoord - offset;
return result;
}
textureCoord
表示当前要修改的坐标,originPosition
表示圆心坐标,targetPosition
表示目标坐标,delta
用来控制变形强度。
上述shader方法可以这样理解,首先确定一个以originPosition
为圆心、targetPosition
和 originPosition
之间的距离为半径的圆,然后将圆内的像素朝着同一个方向移动一个偏移值,且偏移值在距离圆心越近时越大,最终将变换后的坐标返回。
如果将方法简化为这样的表达式变换后的坐标 = 原坐标 - (目标坐标 - 圆心坐标) * 变形强度
,也就是说,方法的作用就是要在原坐标的基础上减去一个偏移值,而(targetPosition - originPosition)
决定了移动的方向和最大值。
式子中的变形强度
可以这样表示:
(targetPosition - originPosition) * (1- |textureCoord - originPosition| / |targetPosition - originPosition| ) * delta
除了项目中使用的算法,还有另外两种脸部变形算法可以使用,一个是基于Interactive Image Warping的局部调整算法(其原理可查看文章),我们在项目中使用的算法其实可以看做是它的一个变种,都可以用表达式变换后的坐标 = 原坐标 - (目标坐标 - 圆心坐标) * 变形强度
来表示,不同之处在于变形强度
的取值不同。;另一个是基于Image deformation using moving least squares的全局点位变形算法(其原理可查看文章)。
当我们要使用上述瘦脸算法时,只需要选取多对特征点作为originPosition
和targetPosition
,使得它们作用范围覆盖的两个脸颊和下巴,然后通过改变delta
来控制瘦脸的强度。
在Face++中,获取的106个特征点分布如下
将这106个特征点上传到片元着色器
uniform float facePoints[106 * 2];
设置统一的变形强度
uniform float thinFaceDelta;
指定圆心坐标和目标坐标,共有9对
vec2 thinFace(vec2 currentCoordinate) {
vec2 faceIndexs[9];
faceIndexs[0] = vec2(3., 44.);
faceIndexs[1] = vec2(29., 44.);
faceIndexs[2] = vec2(7., 45.);
faceIndexs[3] = vec2(25., 45.);
faceIndexs[4] = vec2(10., 46.);
faceIndexs[5] = vec2(22., 46.);
faceIndexs[6] = vec2(14., 49.);
faceIndexs[7] = vec2(18., 49.);
faceIndexs[8] = vec2(16., 49.);
for(int i = 0; i < 9; i++)
{
int originIndex = int(faceIndexs[i].x);
int targetIndex = int(faceIndexs[i].y);
vec2 originPoint = vec2(facePoints[originIndex * 2], facePoints[originIndex * 2 + 1]);
vec2 targetPoint = vec2(facePoints[targetIndex * 2], facePoints[targetIndex * 2 + 1]);
currentCoordinate = curveWarp(currentCoordinate, originPoint, targetPoint, thinFaceDelta);
}
return currentCoordinate;
}
2. 大眼
大眼算法也是参照这篇文章在OpenGL中利用shader进行实时瘦脸大眼等脸型微调
//圓內放大
vec2 enlargeEye(vec2 textureCoord, vec2 originPosition, float radius, float delta) {
float weight = distance(vec2(textureCoord.x, textureCoord.y / aspectRatio), vec2(originPosition.x, originPosition.y / aspectRatio)) / radius;
weight = 1.0 - (1.0 - weight * weight) * delta;
weight = clamp(weight,0.0,1.0);
textureCoord = originPosition + (textureCoord - originPosition) * weight;
return textureCoord;
}
textureCoord
表示当前要修改的坐标,originPosition
表示圆心坐标,radius
表示圆的半径,delta
用来控制变形强度。
和瘦脸的算法类似,根据originPosition
和targetPosition
确定一个圆,圆内的坐标会参与计算,圆外的不变。
圆内的坐标围绕圆心originPosition
在变化,最终的坐标完全是由weight
的值决定,weight
越大,最终的坐标变化越小,当weight
为1,即坐标处于圆边界或圆外时,最终的坐标不变;当weight
小于1时,最终的坐标会落在原坐标和圆点之间,也就是说最终返回的像素点比原像素点距离圆点更近,这样就产生了以圆点为中心的放大效果。
3. 完整的shader代码
NSString *const kGPUImageThinFaceFragmentShaderString = SHADER_STRING
(
precision highp float;
varying highp vec2 textureCoordinate;
uniform sampler2D inputImageTexture;
uniform int hasFace;
uniform float facePoints[106 * 2];
uniform highp float aspectRatio;
uniform float thinFaceDelta;
uniform float bigEyeDelta;
//圓內放大
vec2 enlargeEye(vec2 textureCoord, vec2 originPosition, float radius, float delta) {
float weight = distance(vec2(textureCoord.x, textureCoord.y / aspectRatio), vec2(originPosition.x, originPosition.y / aspectRatio)) / radius;
weight = 1.0 - (1.0 - weight * weight) * delta;
weight = clamp(weight,0.0,1.0);
textureCoord = originPosition + (textureCoord - originPosition) * weight;
return textureCoord;
}
// 曲线形变处理
vec2 curveWarp(vec2 textureCoord, vec2 originPosition, vec2 targetPosition, float delta) {
vec2 offset = vec2(0.0);
vec2 result = vec2(0.0);
vec2 direction = (targetPosition - originPosition) * delta;
float radius = distance(vec2(targetPosition.x, targetPosition.y / aspectRatio), vec2(originPosition.x, originPosition.y / aspectRatio));
float ratio = distance(vec2(textureCoord.x, textureCoord.y / aspectRatio), vec2(originPosition.x, originPosition.y / aspectRatio)) / radius;
ratio = 1.0 - ratio;
ratio = clamp(ratio, 0.0, 1.0);
offset = direction * ratio;
result = textureCoord - offset;
return result;
}
vec2 thinFace(vec2 currentCoordinate) {
vec2 faceIndexs[9];
faceIndexs[0] = vec2(3., 44.);
faceIndexs[1] = vec2(29., 44.);
faceIndexs[2] = vec2(7., 45.);
faceIndexs[3] = vec2(25., 45.);
faceIndexs[4] = vec2(10., 46.);
faceIndexs[5] = vec2(22., 46.);
faceIndexs[6] = vec2(14., 49.);
faceIndexs[7] = vec2(18., 49.);
faceIndexs[8] = vec2(16., 49.);
for(int i = 0; i < 9; i++)
{
int originIndex = int(faceIndexs[i].x);
int targetIndex = int(faceIndexs[i].y);
vec2 originPoint = vec2(facePoints[originIndex * 2], facePoints[originIndex * 2 + 1]);
vec2 targetPoint = vec2(facePoints[targetIndex * 2], facePoints[targetIndex * 2 + 1]);
currentCoordinate = curveWarp(currentCoordinate, originPoint, targetPoint, thinFaceDelta);
}
return currentCoordinate;
}
vec2 bigEye(vec2 currentCoordinate) {
vec2 faceIndexs[2];
faceIndexs[0] = vec2(74., 72.);
faceIndexs[1] = vec2(77., 75.);
for(int i = 0; i < 2; i++)
{
int originIndex = int(faceIndexs[i].x);
int targetIndex = int(faceIndexs[i].y);
vec2 originPoint = vec2(facePoints[originIndex * 2], facePoints[originIndex * 2 + 1]);
vec2 targetPoint = vec2(facePoints[targetIndex * 2], facePoints[targetIndex * 2 + 1]);
float radius = distance(vec2(targetPoint.x, targetPoint.y / aspectRatio), vec2(originPoint.x, originPoint.y / aspectRatio));
radius = radius * 5.;
currentCoordinate = enlargeEye(currentCoordinate, originPoint, radius, bigEyeDelta);
}
return currentCoordinate;
}
void main()
{
vec2 positionToUse = textureCoordinate;
if (hasFace == 1) {
positionToUse = thinFace(positionToUse);
positionToUse = bigEye(positionToUse);
}
gl_FragColor = texture2D(inputImageTexture, positionToUse);
}
);
4. face++的使用
项目中的FaceDetector
是一个专门用来处理Face++相关的操作的类,其头文件如下
@interface FaceDetector : NSObject
@property (assign, nonatomic) BOOL isAuth;
@property (copy, nonatomic, readonly) NSArray *faceModels;
@property (strong, nonatomic, readonly) FaceModel *oneFace;
@property (assign, nonatomic, readonly) BOOL isWorking;
@property (assign, nonatomic) int faceOrientation;
@property (assign, nonatomic) FaceDetectorSampleBufferOrientation sampleBufferOrientation;
@property (assign, nonatomic) FaceDetectorSampleType sampleType;
+ (instancetype)shareInstance;
- (void)getLandmarksFromSampleBuffer:(CMSampleBufferRef)detectSampleBufferRef;
- (void)auth;
@end
4.1 授权
使用前需要替换Face++的key和secret,在项目中,它的路径是Face++/MGNetAccount.h
,然后调用授权方法,授权成功之后才能使用face++的人脸检测。
- (void)auth {
self.isAuth = false;
[MGFaceLicenseHandle licenseForNetwokrFinish:^(bool License, NSDate *sdkDate){
if (!License) {
NSLog(@"联⽹网授权失败 !!!");
self.isAuth = false;
} else {
NSLog(@"联⽹网授权成功");
self.isAuth = true;
}
}];
}
4.2 配置视频帧格式
face++接受的是CMSampleBufferRef
类型的视频帧,但他不支持YUV格式,所以在解码时需要选择BGRA格式。
项目使用GPUImage
来做解码,但GPUImage
库在将视频帧解码为BGRA格式时有一些实现问题,所以我们在使用相机和读取视频文件时,一定要使用项目自己创建的GPUImageFaceCamera
和GPUImageFaceMovie
,它们分别继承自GPUImage
的GPUImageVideoCamera
和GPUImageMovie
,在内部重写了一些配置方法,使得返回的视频帧格式都是BGRA。
除此以外,这两个类在获取到视频帧之后还会自行调用FaceDetector
的- (void)getLandmarksFromSampleBuffer:(CMSampleBufferRef)detectSampleBufferRef;
方法获取人脸信息,使得我们可以在之后的滤镜类中直接使用它解析出来的人脸数据了。
4.3 设置人脸方向
人脸方向是指人脸在视频帧中的逆时针偏移角度,偏移角度为0表示人脸是正的,处于竖直方向。
- (void)setFaceOrientation:(int)faceOrientation {
[self.markManager updateFaceppSetting:^(MGFaceppConfig *config) {
config.orientation = faceOrientation;
}];
}
如果给我们一张人脸的图片,我们的肉眼很容易判断出人脸在图片中的偏移角度,但是传递给face++的是一个来自相机或视频文件视频帧,那么我们应该如何获取人脸的偏移角度呢。
1. 相机拍摄(GPUImageFaceCamera
)
当使用相机拍摄时,相机产生的视频帧默认情况下和我们看到的并不一样,可以通过AVCaptureConnection
类的属性videoOrientation
来指定视频帧的方向,其取值有如下几种
typedef NS_ENUM(NSInteger, AVCaptureVideoOrientation) {
AVCaptureVideoOrientationPortrait = 1,//当手机水平竖直放置,home键在下边时,相机产生的图片和原图一致。
AVCaptureVideoOrientationPortraitUpsideDown = 2,//当手机水平竖直放置,home键在上边时,相机产生的图片和原图一致
AVCaptureVideoOrientationLandscapeRight = 3,//当手机水平方向放置,home键在右边时,相机产生的图片和原图一致。
AVCaptureVideoOrientationLandscapeLeft = 4,//当手机水平方向放置,home键在左边时,相机产生的图片,如果是前置摄像头,和原图是水平镜像的关系;如果是后置摄像头,和原图一致。
}
上面提到的原图就是我们肉眼看到的场景,它和相机或视频文件产生的视频帧可能是不一样的。
因为项目是通过GPUImage
库来调用相机的,而GPUImage
并没有设置这个属性——它使用的是默认值,所以项目使用的是这个属性的默认值。(使用默认值可能是性能原因,因为设置这个属性会导致系统应用一个相应matrix来旋转视频帧,GPUImage选择将旋转操作放到了GPU中执行)
后置摄像头
在使用后置摄像头时,videoOrientation
属性的默认值是AVCaptureVideoOrientationLandscapeRight
,也就是说当手机水平方向放置,且home键在右边时,相机产生的图片和原图一致。
根据这个,我们可以得出下面两个变换图,左边的图片代表原图(实际场景),右边代表相机产生的视频帧,也是传递给face++的视频帧,每一行代表一个放置手机的方向。
我们需要根据上面两个图来得到人脸的偏移角度
首先我们需要设置了一个前提,人脸在原图中总是处于竖直方向的,即偏移角度为0,这其实是符合逻辑的,我们的脑袋不可能歪到大于90度。
然后我们可以将上图中的3和4看做是人脸,计算出右图中的3和4的旋转角度,就可以得出在当前手机方向下人脸的偏移角度。
最终我们得出了下面这个对照表
手机方向 | 摄像头位置 | 是否水平翻转 | 人脸在图片中的旋转角度(逆时针) |
---|---|---|---|
竖直,home键在下 | 后置 | false | 90 |
竖直,home键在上 | 后置 | false | 270 |
水平,home键在左 | 后置 | false | 180 |
水平,home键在右 | 后置 | false | 0 |
前置摄像头
使用前置摄像头时,videoOrientation
的默认值为AVCaptureVideoOrientationLandscapeLeft
,同样的,当手机水平方向放置进行拍摄,home键在左边时,相机产生的图片和原图是水平镜像的关系,所以还需要额外做一个水平方向的翻转,这时的图片才和原图一样。
同样给出手机处于竖直方向或水平方向拍摄时,原图和视频帧的变换
手机方向和人脸偏移角度的对照表
手机方向 | 摄像头位置 | 是否水平翻转 | 人脸在图片中的旋转角度(逆时针) |
---|---|---|---|
竖直,home键在下 | 前置 | true | 90 |
竖直,home键在上 | 前置 | true | 270 |
水平,home键在左 | 前置 | true | 0 |
水平,home键在右 | 前置 | true | 180 |
相比后置摄像头,使用前置摄像头时需要多做一个额外的水平翻转,由于face++并没有提供设置水平翻转的接口,所以在识别前置摄像头产生的图片时,face++返回的人脸数据有一些小问题——人脸特征点的排列顺序左右颠倒了,不过这个问题是可以忽略的,因为人脸两边是对齐的;还有一个问题就是欧拉角的方向会取反,这个我们后面会讲到怎么解决。
检测手机的方向
由于App只支持Portrait
方向,所以无法使用类似- (void)willRotateToInterfaceOrientation:(UIInterfaceOrientation)toInterfaceOrientation duration:(NSTimeInterval)duration
方法来获取手机方向。
一个更好的方法是通过CoreMotion
检测xyz方向的加速度,以此来判断当前手机的朝向
- (void)startMotion {
self.motionManager = [[CMMotionManager alloc] init];
self.motionManager.accelerometerUpdateInterval = 0.3f;
NSOperationQueue *motionQueue = [[NSOperationQueue alloc] init];
[motionQueue setName:@"com.megvii.gryo"];
__weak typeof(self) weakSelf = self;
[self.motionManager startAccelerometerUpdatesToQueue:motionQueue withHandler:^(CMAccelerometerData * _Nullable accelerometerData, NSError * _Nullable error) {
__strong typeof(weakSelf) self = weakSelf;
if (fabs(accelerometerData.acceleration.z) > 0.7) {
self.orientation = 90;
}else{
if (AVCaptureDevicePositionBack == self->devicePosition) {
if (fabs(accelerometerData.acceleration.x) < 0.4) {
self.orientation = 90;
}else if (accelerometerData.acceleration.x > 0.4){
self.orientation = 180;
}else if (accelerometerData.acceleration.x < -0.4){
self.orientation = 0;
}
}else{
if (fabs(accelerometerData.acceleration.x) < 0.4) {
self.orientation = 90;
}else if (accelerometerData.acceleration.x > 0.4){
self.orientation = 0;
}else if (accelerometerData.acceleration.x < -0.4){
self.orientation = 180;
}
}
if (accelerometerData.acceleration.y > 0.6) {
self.orientation = 270;
}
}
}];
}
2. 从视频文件获取(GPUImageFaceMovie
)
通过AVAssetTrack
的preferredTransform
属性获得变换矩阵,再通过矩阵来判断视频的旋转角度,也即是人脸的旋转角度。
- (NSInteger)orientation {
NSInteger degree = 0;
NSArray *tracks = [self.asset tracksWithMediaType:AVMediaTypeVideo];
if([tracks count] > 0) {
AVAssetTrack *videoTrack = [tracks objectAtIndex:0];
CGAffineTransform t = videoTrack.preferredTransform;
if(t.a == 0 && t.b == 1.0 && t.c == -1.0 && t.d == 0){
// Portrait
degree = 90;
}else if(t.a == 0 && t.b == -1.0 && t.c == 1.0 && t.d == 0){
// PortraitUpsideDown
degree = 270;
}else if(t.a == 1.0 && t.b == 0 && t.c == 0 && t.d == 1.0){
// LandscapeRight
degree = 0;
}else if(t.a == -1.0 && t.b == 0 && t.c == 0 && t.d == -1.0){
// LandscapeLeft
degree = 180;
}
}
return degree;
}
4.4 人脸的特征点坐标和欧拉角
face++返回的人脸数据会在哪里被使用?
GPUImage
会将视频帧上传到纹理中,然后将纹理传递给后续的targets
,targets
是指那些遵守了GPUImageInput
协议的类,在这里我们简称它们为滤镜类。
face++返回的人脸数据只会被使用在这些滤镜类中,这些滤镜类中的纹理图片和传递给face++做人脸检测的视频帧是不一样的,也就是说生成人脸数据时的参考坐标系和使用人脸数据时的参考坐标系是不同的。所以在使用人脸数据之前,我们还需要对人脸数据做一些转换操作。
问题是如何转换
之前在设置人脸方向时,我们已经了解了视频帧,那么滤镜类中的纹理又是什么样的,可以参考下面这张图
第一行使用后置摄像头,第二行使用前置摄像头;
第一列和第二列分别表示原图和相机产生的视频帧;
第三列表示视频帧上传到纹理时的情形,因为OpenGL的原点是在左下角,所以需要上下颠倒;
第四列表示变换后的纹理图片,也就是滤镜类中的纹理图片。
(GPUImage
是如何从上图的第三列变换到第四列的,查看GPUImageVideoCamera
的方法updateOrientationSendToTargets
可知,按照本项目对GPUImageVideoCamera
的配置(outputImageOrientation=UIInterfaceOrientationPortrait, _horizontallyMirrorFrontFacingCamera=true
,前置),当使用后置摄像头时,用来指定旋转的枚举是kGPUImageRotateRight
,当使用前置摄像头时,用来指定旋转的枚举是kGPUImageRotateRightFlipVertical
,这两个枚举的名字刚好是反向变换——第四列变换到第三列所需要的步骤)
特征点坐标
假设point
表示使用后置摄像头时Face++的特征点坐标,对应上图的第一行的视频帧,它是以4号位作为原点,也就是说point
的值是相对于4号位的,然后我们再看第一行的最终纹理图片,原点是3号位,4号位变换到右下角了,我们需要做的是将point
变换到以3号位为原点。
所以变换后的point
应该等于(height - point.y, point.x)
,其中width是第二列视频帧的4号位和2号位所在的那条边的长度,height是视频帧的4号位和3号位所在的那条边的长度。
特征点变换规则如下
- (CGPoint)transformPointoPortrait:(CGPoint)point {
CGFloat width = frameWidth;
CGFloat height = frameHeight;
switch (self.sampleBufferOrientation) {
case FaceDetectorSampleBufferOrientationCameraFrontAndHorizontallyMirror:
return CGPointMake(point.y/height, point.x/width);
case FaceDetectorSampleBufferOrientationCameraBack:
return CGPointMake(1 - point.y/height, point.x/width);
case FaceDetectorSampleBufferOrientationRatation90:
return CGPointMake(1 - point.y/height, point.x/width);
case FaceDetectorSampleBufferOrientationRatation180:
return CGPointMake(1 - point.x/width, 1 - point.y/height);
case FaceDetectorSampleBufferOrientationRatation270:
return CGPointMake(point.y/height, 1 - point.x/width);
case FaceDetectorSampleBufferOrientationNoRatation:
return CGPointMake(point.x/width, point.y/height);
}
}
欧拉角
faceinfo
的三个属性pitch
、yaw
、roll
分别表示人脸在未变换的视频帧中围绕x、y、z轴的旋转角度。
model.pitchAngle = -faceInfo.pitch;
model.yawAngle = isFront ? -faceInfo.yaw : faceInfo.yaw;
model.rollAngle = isFront ? -(M_PI/2 - faceInfo.roll) : (M_PI/2 - faceInfo.roll);