在相机应用中,实时贴纸、实时瘦脸是比较常见的功能,它们的实现基础是人脸关键点检测。本文主要介绍,如何在 GPUImage 中检测人脸关键点。ios
咱们要经过某一种方式,获取视频中每一帧的人脸关键点,而后经过 OpenGL ES 将关键点绘制到屏幕上。最终呈现效果以下:git
这里分为两个步骤:关键点获取、关键点绘制。github
在苹果自带的 SDK 中,已经包含了一部分的人脸识别功能。好比在 CoreImage、AVFoundation 中,就提供了相关的接口。可是,它们提供的接口功能有限,并不具有人脸关键点检测功能。算法
咱们要在视频中进行实时的人脸关键点检测,还须要借助第三方的库。这里主要介绍两种方式:bash
Face++ 的人脸关键点 SDK 是收费的,可是它也提供免费试用的版本。网络
在免费试用的版本中,试用的 API Key 天天能够发起 5 次联网受权,每次受权的时长为 24 小时。也就是说,在不删除 APP 的状况下,只要测试设备不超过 5 台,就能够一直使用下去。async
这对于开发者来讲仍是很是友好的,并且 Face++ 的注册集成也比较简单,建议你们都尝试一下。ide
人脸关键点 SDK 的集成能够参照 官方文档 ,先注册再下载 SDK 压缩包,压缩包里有详细的集成步骤。函数
人脸关键点 SDK 的使用主要分为三步:post
第一步:发起联网受权
受权的操做不必定发起网络请求,而是会先检查本地的受权信息是否过时,过时了才会发起网络请求。
@weakify(self);
[MGFaceLicenseHandle licenseForNetwokrFinish:^(bool License, NSDate *sdkDate) {
@strongify(self);
dispatch_async(dispatch_get_main_queue(), ^{
if (License) {
[[UIApplication sharedApplication].keyWindow makeToast:@"Face++ 受权成功!"];
[self setupFacepp];
} else {
[[UIApplication sharedApplication].keyWindow makeToast:@"Face++ 受权失败!"];
}
});
}];
复制代码
第二步:初始化人脸检测器
受权成功后,开始人脸检测器的初始化。初始化过程会进行模型数据加载,而后对识别模式、视频流格式、视频旋转角度等进行设置。
NSString *modelPath = [[NSBundle mainBundle] pathForResource:KMGFACEMODELNAME
ofType:@""];
NSData *modelData = [NSData dataWithContentsOfFile:modelPath];
self.markManager = [[MGFacepp alloc] initWithModel:modelData
faceppSetting:^(MGFaceppConfig *config) {
config.detectionMode = MGFppDetectionModeTrackingRobust;
config.pixelFormatType = PixelFormatTypeNV21;
config.orientation = 90;
}];
复制代码
第三步:检测视频帧
人脸检测器初始化成功后,能够对视频流每一帧进行检测,这里传入的是 CMSampleBufferRef
类型的数据。因为顶点坐标的范围是 -1 ~ 1
,因此还须要根据当前的视频尺寸比例,对识别的结果进行坐标转换。
- (float *)detectInFaceppWithSampleBuffer:(CMSampleBufferRef)sampleBuffer
facePointCount:(int *)facePointCount
isMirror:(BOOL)isMirror {
if (!self.markManager) {
return nil;
}
MGImageData *imageData = [[MGImageData alloc] initWithSampleBuffer:sampleBuffer];
[self.markManager beginDetectionFrame];
NSArray *faceArray = [self.markManager detectWithImageData:imageData];
// 人脸个数
NSInteger faceCount = [faceArray count];
int singleFaceLen = 2 * kFaceppPointCount;
int len = singleFaceLen * (int)faceCount;
float *landmarks = (float *)malloc(len * sizeof(float));
for (MGFaceInfo *faceInfo in faceArray) {
NSInteger faceIndex = [faceArray indexOfObject:faceInfo];
[self.markManager GetGetLandmark:faceInfo isSmooth:YES pointsNumber:kFaceppPointCount];
[faceInfo.points enumerateObjectsUsingBlock:^(NSValue *value, NSUInteger idx, BOOL *stop) {
float x = (value.CGPointValue.y - self.sampleBufferLeftOffset) / self.videoSize.width;
x = (isMirror ? x : (1 - x)) * 2 - 1;
float y = (value.CGPointValue.x - self.sampleBufferTopOffset) / self.videoSize.height * 2 - 1;
landmarks[singleFaceLen * faceIndex + idx * 2] = x;
landmarks[singleFaceLen * faceIndex + idx * 2 + 1] = y;
}];
}
[self.markManager endDetectionFrame];
if (faceArray.count) {
*facePointCount = kFaceppPointCount * (int)faceCount;
return landmarks;
} else {
free(landmarks);
return nil;
}
}
复制代码
OpenCV 是一个开源的跨平台计算机视觉库,实现了图像处理方面的不少通用算法。Stasm 是用于检测人脸特征的开源算法库,依赖于 OpenCV 。
咱们知道,iPhone 屏幕的刷新频率能够达到 60 帧每秒。在相机预览时,出于功耗方面的考虑,通常会将帧率限制到 30 帧每秒左右,且不会引发明显的卡顿。
因此,咱们要对每一帧数据进行识别,则要求每一帧的识别时间要小于 1 / 30 秒,不然图像数据的渲染操做就要等待识别结果,从而致使帧率降低,引发卡顿。
遗憾的是,采用 OpenCV + Stasm 的方式,每一帧的识别时间是超过 1 / 30 秒的。它或许更适合用来作静态图片的识别。
因此也更推荐使用 Face++ 的方式。
OpenCV 经过 CocoPods 的方式来引入:
pod 'OpenCV2-contrib'
复制代码
OpenCV2-contrib 相比于 OpenCV2 多包含了一些拓展包,好比 face 模块,而 Stasm 算法库须要依赖 face 模块。
Stasm 算法库能够从 这个地址 下载,须要将 stasm 和 haarcascades 文件夹都加入工程中。
人脸关键点的识别主要经过调用 stasm_search_single
函数来实现。
因为这个方法的检测时间较长,所以咱们在将视频帧数据传入以前,会先作单通道化、尺寸压缩等处理。这样的话, Stasm 拿到的每一帧的数据量会减小,能够有效地缩短检测的时长,但相应地也会损失检测的精度。
关键的代码:
- (float *)detectInOpenCVWithSampleBuffer:(CMSampleBufferRef)sampleBuffer
facePointCount:(int *)facePointCount
isMirror:(BOOL)isMirror {
cv::Mat cvImage = [self grayMatWithSampleBuffer:sampleBuffer];
int resultWidth = 250;
int resultHeight = resultWidth * 1.0 / cvImage.rows * cvImage.cols;
cvImage = [self resizeMat:cvImage toWidth:resultHeight]; // 此时还没旋转,因此传入高度
cvImage = [self correctMat:cvImage isMirror:isMirror];
const char *imgData = (const char *)cvImage.data;
// 是否找到人脸
int foundface;
// stasm_NLANDMARKS 表示人脸关键点数,乘 2 表示要分别存储 x, y
int len = 2 * stasm_NLANDMARKS;
float *landmarks = (float *)malloc(len * sizeof(float));
// 获取宽高
int imgCols = cvImage.cols;
int imgRows = cvImage.rows;
// 训练库的目录,直接传 [NSBundle mainBundle].bundlePath 就能够,会自动找到全部文件
const char *xmlPath = [[NSBundle mainBundle].bundlePath UTF8String];
// 返回 0 表示出错
int stasmActionError = stasm_search_single(&foundface,
landmarks,
imgData,
imgCols,
imgRows,
"",
xmlPath);
// 打印错误信息
if (!stasmActionError) {
printf("Error in stasm_search_single: %s\n", stasm_lasterr());
}
// 释放cv::Mat
cvImage.release();
// 识别到人脸
if (foundface) {
// 转换坐标
for (int index = 0; index < len; ++index) {
if (index % 2 == 0) {
float scale = (self.videoSize.height / self.videoSize.width) / (16.0 / 9.0);
scale = MAX(1, scale); // 比例超过 16 : 9 进行横向缩放
landmarks[index] = (landmarks[index] / imgCols * 2 - 1) * scale;
} else {
float scale = (16.0 / 9.0) / (self.videoSize.height / self.videoSize.width);
scale = MAX(1, scale); // 比例小于 16 : 9 进行纵向缩放
landmarks[index] = (landmarks[index] / imgRows * 2 - 1) * scale;
}
}
*facePointCount = stasm_NLANDMARKS;
return landmarks;
} else {
free(landmarks);
return nil;
}
}
复制代码
经过上面的步骤,咱们已经有了顶点数据,区别只是两种方式的顶点数量不一样。
顶点数据的绘制,要在 GPUImageFilter
中进行。咱们要自定义一个滤镜,而后在这个滤镜中实现人脸关键点的绘制逻辑。
在 GPUImageFilter
中,渲染的流程是在 -renderToTextureWithVertices:textureCoordinates:
这个方法里执行的。所以在自定义的滤镜中,咱们须要重写这个方法。
在这个方法里,咱们须要作两件事情,一是将输入的纹理原封不动地绘制,二是对人脸关键点的绘制。
纹理的绘制使用的是三角形图元,人脸关键点的绘制使用的是点图元,所以咱们须要分红两次绘制。在原来的绘制方法中,已经有了纹理的绘制逻辑。因此,咱们只须要在纹理绘制结束后,加上人脸关键点的绘制。
完整的重写后的方法:
- (void)renderToTextureWithVertices:(const GLfloat *)vertices
textureCoordinates:(const GLfloat *)textureCoordinates {
if (self.preventRendering)
{
[firstInputFramebuffer unlock];
return;
}
[GPUImageContext setActiveShaderProgram:filterProgram];
outputFramebuffer = [[GPUImageContext sharedFramebufferCache] fetchFramebufferForSize:[self sizeOfFBO] textureOptions:self.outputTextureOptions onlyTexture:NO];
[outputFramebuffer activateFramebuffer];
if (usingNextFrameForImageCapture)
{
[outputFramebuffer lock];
}
[self setUniformsForProgramAtIndex:0];
glClearColor(backgroundColorRed, backgroundColorGreen, backgroundColorBlue, backgroundColorAlpha);
glClear(GL_COLOR_BUFFER_BIT);
glActiveTexture(GL_TEXTURE2);
glBindTexture(GL_TEXTURE_2D, [firstInputFramebuffer texture]);
glUniform1i(filterInputTextureUniform, 2);
glUniform1i(self.isPointUniform, 0); // 表示是绘制纹理
glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, vertices);
glVertexAttribPointer(filterTextureCoordinateAttribute, 2, GL_FLOAT, 0, 0, textureCoordinates);
glDrawArrays(GL_TRIANGLE_STRIP, 0, 4);
// 绘制点
if (self.facesPoints) {
glUniform1i(self.isPointUniform, 1); // 表示是绘制点
glUniform1f(self.pointSizeUniform, self.sizeOfFBO.width * 0.006); // 设置点的大小
glVertexAttribPointer(filterPositionAttribute, 2, GL_FLOAT, 0, 0, self.facesPoints);
glDrawArrays(GL_POINTS, 0, self.facesPointCount);
}
[firstInputFramebuffer unlock];
if (usingNextFrameForImageCapture)
{
dispatch_semaphore_signal(imageCaptureSemaphore);
}
}
复制代码
在绘制点图元的时候,能够经过对 gl_PointSize
进行赋值,来指定点的大小。而后在外部经过 uniform
变量传值的方式进行控制。
顶点着色器代码:
precision highp float;
attribute vec4 position;
attribute vec4 inputTextureCoordinate;
varying vec2 textureCoordinate;
uniform float pointSize;
void main()
{
gl_Position = position;
gl_PointSize = pointSize;
textureCoordinate = inputTextureCoordinate.xy;
}
复制代码
因为两次渲染的逻辑是独立的,因此通常来讲,应该使用不一样的 Shader 来实现。但因为这里的渲染逻辑比较简单,因此直接将两次渲染的逻辑都放到同一个 Shader 中。这也能够避免 Program 的来回切换,而后用一个 uniform
变量来判断当前的绘制类型。
片断着色器代码:
precision highp float;
varying vec2 textureCoordinate;
uniform sampler2D inputImageTexture;
uniform int isPoint;
void main()
{
if (isPoint != 0) {
gl_FragColor = vec4(1.0, 1.0, 1.0, 1.0);
} else {
gl_FragColor = texture2D(inputImageTexture, textureCoordinate);
}
}
复制代码
最后,只须要将这个滤镜加入到滤镜链里,就能够看到人脸关键点的绘制效果了。
请到 GitHub 上查看完整代码。
获取更佳的阅读体验,请访问原文地址 【Lyman's Blog】在 GPUImage 中检测人脸关键点