在 GPUImage 中检测人脸关键点

在相机应用中,实时贴纸、实时瘦脸是比较常见的功能,它们的实现基础是人脸关键点检测。本文主要介绍,如何在 GPUImage 中检测人脸关键点。

前言

我们要通过某一种方式,获取视频中每一帧的人脸关键点,然后通过 OpenGL ES 将关键点绘制到屏幕上。最终呈现效果如下:

这里分为两个步骤:关键点获取关键点绘制

一、关键点获取

在苹果自带的 SDK 中,已经包含了一部分的人脸识别功能。比如在 CoreImage、AVFoundation 中,就提供了相关的接口。但是,它们提供的接口功能有限,并不具备人脸关键点检测功能。

我们要在视频中进行实时的人脸关键点检测,还需要借助第三方的库。这里主要介绍两种方式:

  1. Face++
  2. OpenCV + Stasm

1、Face++

1、简介

Face++ 的人脸关键点 SDK 是收费的,但是它也提供免费试用的版本。

在免费试用的版本中,试用的 API Key 每天可以发起 5 次联网授权,每次授权的时长为 24 小时。也就是说,在不删除 APP 的情况下,只要测试设备不超过 5 台,就可以一直使用下去。

这对于开发者来说还是非常友好的,而且 Face++ 的注册集成也比较简单,建议大家都尝试一下。

2、如何集成

人脸关键点 SDK 的集成可以参照 官方文档 ,先注册再下载 SDK 压缩包,压缩包里有详细的集成步骤。

3、如何使用

人脸关键点 SDK 的使用主要分为三步:

第一步:发起联网授权

授权的操作不一定发起网络请求,而是会先检查本地的授权信息是否过期,过期了才会发起网络请求。

@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;
    }
}

2、OpenCV + Stasm

1、简介

OpenCV 是一个开源的跨平台计算机视觉库,实现了图像处理方面的很多通用算法。Stasm 是用于检测人脸特征的开源算法库,依赖于 OpenCV 。

我们知道,iPhone 屏幕的刷新频率可以达到 60 帧每秒。在相机预览时,出于功耗方面的考虑,一般会将帧率限制到 30 帧每秒左右,且不会引起明显的卡顿。

所以,我们要对每一帧数据进行识别,则要求每一帧的识别时间要小于 1 / 30 秒,否则图像数据的渲染操作就要等待识别结果,从而导致帧率下降,引起卡顿。

遗憾的是,采用 OpenCV + Stasm 的方式,每一帧的识别时间是超过 1 / 30 秒的。它或许更适合用来做静态图片的识别。

所以也更推荐使用 Face++ 的方式。

2、如何集成

OpenCV 通过 CocoPods 的方式来引入:

pod 'OpenCV2-contrib'

OpenCV2-contrib 相比于 OpenCV2 多包含了一些拓展包,比如 face 模块,而 Stasm 算法库需要依赖 face 模块。

Stasm 算法库可以从 这个地址 下载,需要将 stasm 和 haarcascades 文件夹都加入工程中。

3、如何使用

人脸关键点的识别主要通过调用 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 中检测人脸关键点

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345