基于 iOS 11 Vision 的人脸识别和特征点提取的表情包模仿功能的实践

前面

想起来去年在上家公司做的个小 Demo, 记录一下吧, 背景是当时食堂空间承受不住了, 大佬们组织了一场黑客马拉松, 实现摇号吃饭, 各个部门的大佬前后端开发设计师产品都组队参赛, 我当时所在的部门都在忙着产品上线没人愿意参加, 介于我逗比的潜质, 独自一人邀请了一个产品小伙伴组成了唯一一个只有两个人参赛的队伍…大概是花了2天时间完成…

感谢我的产品小伙伴 @刘海滨, 点子都是他想的.

初衷是, 每日推荐一个表情包, 然后大家进行模仿自拍, 匹配相似度. 进行排队吃饭. 大致效果图如下…惨不忍睹…

效果图

功能点

  • AVFoundation 实现自拍的相机功能
  • 基于 Vision 进行实时人脸追踪
  • 拍照后基于 Vision 进行人脸特征点提取, 并且展示出来
  • 表情包的特征点和自拍的特征点进行相似度匹配
  • 对自拍进行剪裁, 表情包中的文字添加, 人脸磨皮美颜, 完成表情包制作, 分享.

实现

搭建项目

因为要使用 Vision 框架所以只能支持 iOS 11 及以上版本.

本来最开始是希望可以做完整的, 但是时间太少了, 只做了上面提到的这些功能, 功能少, 所以结构非常简单, 在”我”的和”我们的”两个标签下面内容几乎没有….

项目结构图

那后面就只说一下核心功能的结构吧.

核心功能结构

从下至上, 短时间快速实现的简单的结构, 可能考虑不充分导致不是特别理想.

  • 人脸模型 : 封装 Vison 的人脸模型, 方便自己使用
  • 人脸工具类 : 提供根据图片的人脸检测和特征点提取等方法
  • 特征比较 : 提供两个特征的相似度比较
  • 相机工具类 : 完整的处理相机的逻辑一直到拍好一张照片交给控制器
  • 视图控制器 : 初始化相机工具类, 控制相机会话的启动和停止, 以及得到照片的得分和保存.

下面是各个核心功能的一些实现过程.

自定义相机

自定义相机这部分.走了一点弯路, 因为只支持 iOS 11, 原来熟悉的到处可以 Copy 的 AVFoundation 代码有一些 API 分别在 iOS 10 和 iOS 11 中被弃用了…我有看不惯弃用 API 的警告, 于是走上了填坑之路…

首先 XYCaptureController.m 为自拍的视图控制器, 我将相机的相关处理封装提取出来 XYCaptureHelper.m 好处是

  • 控制器逻辑简单, 算上空行也才100多行代码;
  • 如果别的项目还需要相机可以直接拽过去稍微修改一下就能用了.

XYCaptureHelper.h 中暴露最核心的方法是创建相机会话

/**
在View上创建扫描会话
*/
- (void)setupCaptureSessionDisplayOnView:(UIView *)captureView;

方法实现先校验了一下权限, 感兴趣的可以看 XYAuthUtil 对权限和跳转设置界面的简单封装.

校验权限后, 创建相机会话分为三步

/**
创建相机扫描视图
*/
- (void)setupCaptureOnView:(UIView *)captureView {
    //  弱引用父视图, 方便布局等其他控制
    self.captureView = captureView;
    //  创建相机会话
    [self setupCaptureSession];
    //  创建相机预览层
    [self setupCaptureDisplay];
    //  创建人脸预览层
    [self setupFacePreview   ];
}

创建相机会话

- (void)setupCaptureSession {

    _captureQueue = dispatch_queue_create("com.hanyx.PassionForEating.Capture", DISPATCH_QUEUE_SERIAL);

    self.session = [[AVCaptureSession alloc] init];
    self.session.sessionPreset = AVCaptureSessionPresetPhoto;

    self.device = [self cameraWithPostion:AVCaptureDevicePositionFront];

    self.input = [[AVCaptureDeviceInput alloc] initWithDevice:self.device error:nil];

    AVCaptureVideoDataOutput *dataOutput = [[AVCaptureVideoDataOutput alloc] init];
    [dataOutput setAlwaysDiscardsLateVideoFrames:YES];
    [dataOutput setVideoSettings:@{(id)kCVPixelBufferPixelFormatTypeKey:@(kCVPixelFormatType_32BGRA)}];
    [dataOutput setSampleBufferDelegate:self queue:_captureQueue];
    self.dataOutput = dataOutput;

    self.imageOutput = [[AVCapturePhotoOutput alloc] init];

    if ([self.session canAddInput:self.input]) {
        [self.session addInput:self.input];
    }

    if ([self.session canAddOutput:dataOutput]) {
        [self.session addOutput:dataOutput];
    }

    if ([self.session canAddOutput:self.imageOutput]) {
        [self.session addOutput:self.imageOutput];
    }

    AVCaptureConnection *connection = [dataOutput.connections firstObject];
    [connection setVideoOrientation:AVCaptureVideoOrientationPortrait];

其实就是主流的写法, 获取相机设备, 根据设备创建输入, 输出对象, 都添加到相机会话中, 比较重要的是, 实现了 AVCaptureVideoDataOutputSampleBufferDelegate 代理, 来获得到相机捕获的缓冲帧信息.

关于 iOS 11 第一处不同是 imageOutput 之前我们习惯去使用 AVCaptureStillImageOutput 对象来获取相机拍摄的高清照片, 但是这个类在 iOS 10 中被弃用了, 需要使用 AVCapturePhotoOutput 对象代替.

创建相机预览层

- (void)setupCaptureDisplay {

    //  预览层的生成
    self.previewLayer = [[AVCaptureVideoPreviewLayer alloc] initWithSession:self.session];
    self.previewLayer.videoGravity = AVLayerVideoGravityResizeAspectFill;
    [self.captureView.layer insertSublayer:self.previewLayer atIndex:0];
}

创建预览层主要要设置的就是显示的缩放模式, 和 UIView 的 contentMode 很相似提供了三种, 非常简单, 一看就明白了.我这里使用 AVLayerVideoGravityResizeAspectFill 保持缩放比例来填充视图, 防止出现黑边的情况. 因为在父视图中, 可能存在其他视图, 比如拍照按钮等等, 所以预览图层我这里插入到最底层

创建人脸预览层

- (void)setupFacePreview {

    CAShapeLayer *faceShape = [[CAShapeLayer alloc] init];
    faceShape.fillColor     = [UIColor clearColor].CGColor;
    faceShape.strokeColor   = [UIColor colorNamed:COLOR_Main].CGColor;
    faceShape.lineWidth     = 1;
    [self.captureView.layer addSublayer:faceShape];
    self.faceShapeLayer     = faceShape;
}

人脸预览层我这里使用 CAShapeLayer 指定了一个橙色的颜色. 用来框柱人脸区域.

会话控制

可以发现. 预览层我都没有指定 frame 可能由于各种问题, 获取父视图的 frame
可能并不如预期, 所以, 我在开始回话的地方, 进行了 frame 的获取和对预览层 frame 的设置


- (void)startRunning {
    [self.session startRunning];
    dispatch_async(dispatch_get_main_queue(), ^{
        //  计算各个视图的尺寸
        self.captureFrame = self.captureView.frame;
    });
}

- (void)stopRunning {
    [self.session stopRunning];
}

- (void)setCaptureFrame:(CGRect)captureFrame {
    if (CGRectEqualToRect(_captureFrame, captureFrame)) {
        return ;
    }
    _captureFrame = captureFrame;
    CGRect bounds = (CGRect){0, 0, captureFrame.size.width, captureFrame.size.height};
    self.previewLayer.frame = bounds;
    self.faceShapeLayer.frame = bounds;
}

这个地方我创建了 self.captureFrame 的属性, 而不是直接使用 self.captureView.frame 是因为在捕获的过程中, 很多操作可能都需要该 frame 中的信息, 但是 UIViewframe 实际上只允许在主线程调用. 所以这里使用属性方便了操作.

实时缓冲帧的获取

之前已经说过, 实现了 AVCaptureVideoDataOutputSampleBufferDelegate 代理, 来获得到相机捕获的缓冲帧信息.实现方法

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {

}

可以根据 CMSampleBufferRef 来获取到缓冲帧的图片, 图片是低质量的.

拍照

因为照片输出对象换成了新的 AVCapturePhotoOutput 所以拍照部分也有所变化,

- (void)captureWithSender:(UIButton *)sender {

    //  震动反馈
    [self.taptic impactOccurred];

    AVCaptureConnection *con = [self.imageOutput connectionWithMediaType:AVMediaTypeVideo];
    UIDeviceOrientation curDeviceOrientation = [[UIDevice currentDevice] orientation];
    AVCaptureVideoOrientation avcaptureOrientation = [self avOrientationForDeviceOrientation:curDeviceOrientation];
    [con setVideoOrientation:avcaptureOrientation];

    [self.imageOutput capturePhotoWithSettings:[AVCapturePhotoSettings photoSettings] delegate:self];
}

获取连接, 提前设置了方向, 防止照出来的图片可能会旋转90°的问题. 拍照的过程也有变换, 原来使用 AVCaptureStillImageOutput 的时候很方便通过 block 的方式获取原图, 现在需要使用代理. 实现 AVCapturePhotoCaptureDelegate 代理, 当拍照成功时, 会调用如下方法来获取高清图片

- (void)captureOutput:(AVCapturePhotoOutput *)output didFinishProcessingPhoto:(nonnull AVCapturePhoto *)photo error:(nullable NSError *)error {

    NSData *imgData = photo.fileDataRepresentation;
    UIImage *img = [UIImage imageWithData:imgData];
}

需要多说一句的是, AVCapturePhotoSettings 我添加了一个属性, 通过懒加载的方式初始化来进行使用, 可是在第二次拍摄的时候就会导致崩溃, 因为 AVCapturePhotoSettings 对象不能重复使用, 所以我这里每次拍照的时候创建了一个对象.

关于自定义相机大概内容就是这么多, 接下来看如何使用 Vision 来对捕获到的缓冲帧进行人脸追踪.

人脸追踪

如何根据一张照片获取人脸位置?

我在 XYFaceHelper 中封装了如下方法, 根据图片 CIImage 获取一个人脸矩形区域 CGRect

/**
 人脸矩形区域检测
 */
+ (void)detectFaceRectWithImage:(CIImage *)image completion:(void(^)(CGRect faceRect))completion;

内部实现其实就是调用了 Vision 框架, 简单看一下如何使用 Vision 框架.

/**
 人脸矩形区域检测
 */
+ (void)detectFaceRectWithImage:(CIImage *)image completion:(void (^)(CGRect faceRect))completion {

    if (!image || !completion) { return ; }

    CGFloat imgW = image.extent.size.width;
    CGFloat imgH = image.extent.size.height;

    VNImageRequestHandler *vnHandler = [[VNImageRequestHandler alloc] initWithCIImage:image orientation:kCGImagePropertyOrientationUp options:@{}];
    VNDetectFaceRectanglesRequest *request = [[VNDetectFaceRectanglesRequest alloc] initWithCompletionHandler:^(VNRequest * _Nonnull request, NSError * _Nullable error) {

        NSArray *results = request.results;
        if (!results.count) {
            completion(CGRectZero);
            return ;
        }

        //  获取结果
        VNFaceObservation *item = [self biggestObservationInArray:results];
        CGRect rect = item.boundingBox;

        //  根据比例偏移量计算在 View 中的位置
        CGFloat x = rect.origin.x * imgW;
        CGFloat y = rect.origin.y * imgH;
        CGFloat w = rect.size.width * imgW;
        CGFloat h = rect.size.height * imgH;

        //  返回结果, 坐标系转换, y值翻转
        CGRect result = CGRectMake(x, imgH - y - h, w, h);
        completion(result);
    }];
    [vnHandler performRequests:@[request] error:nil];
}

关于使用的逻辑很清晰:

  • 使用 CIImage 创建一个 VNImageRequestHandler 图片请求处理器
  • 创建 VNDetectFaceRectanglesRequest 人脸矩形区域识别的请求
  • 处理器 处理 请求.

关于处理完成的 Block:

Vison 返回的结果是一个数组, 因为图片中可能会有多个人脸. 那么基于我们的需求, 我这里取人脸区域面积最大的人脸进行处理. 处理时, 有两点需要注意:

  1. Vison 的识别结果 VNFaceObservation 所返回的矩形区域实际上值都是相对于图片宽高的比例, 假设图片宽 1000 像素, 人脸区域宽 100 像素, 则返回的 CGRect 中 width 其实为 0.1, 我们需要乘以图片的宽 1000 才能得到人脸区域的宽 100.

  2. CoreImage 的坐标系和 UIKit 中的坐标系是不一样的, 相信使用过 CoreImage 的同学都了解, 在 UIKit 中, 坐标原点是在屏幕左上角, 在 CoreImage 中, 坐标原点是在屏幕左下角, 我这里希望坐标转换成熟悉的 UIKit 中的坐标系. 转换关系如图:

坐标转换

简单来说, 只有 Y 坐标需要转换, 记一个公式也可以 : Y(UIKit) = 总高 - Y(CoreImage) - 区域高

这样已经封装好一个根据一张图片返回人脸矩形区域的方法了.非常简单. 那么我们只要获取到摄像头捕捉的图片, 然后调用刚刚封装好的方法就可以了.

获取缓冲帧照片

因为 Vision 框架需要的图片类型是 CIImage, 所以我这刚刚说的代理方法中, 直接通过缓冲帧获得一个 CIImage 的对象.

- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {

    CVPixelBufferRef pixelBuffer = (CVPixelBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
    CIImage *ciImage = [CIImage imageWithCVPixelBuffer:pixelBuffer];

    ...

    [XYFaceHelper detectFaceRectWithImage:image completion:^(CGRect faceRect) {

        ...

    }];

}

实时显示人脸框

获取到人脸的区域 CGRect 之后就非常简单了. 改变之前创建的人脸预览层 CGShapeLayerpath 即可.

因为前置摄像头看见的我们是镜子中的我们, 和现实中的我们是镜像关系, 所以相机捕获到照片后, 会做一个水平翻转, 所以

        //  人脸检测
        [XYFaceHelper detectFaceRectWithImage:image completion:^(CGRect faceRect) {

            CGPathRef path;
            if (faceRect.size.height && faceRect.size.width) {
                //  根据比例偏移量计算在 View 中的位置
                CGFloat x = faceRect.origin.x - offsetX;
                CGFloat y = faceRect.origin.y - offsetY;
                CGFloat w = faceRect.size.width;
                CGFloat h = faceRect.size.height;
                //  坐标系转换, 自拍 x 值翻转
                CGRect drawRect = CGRectMake(viewW - x - w, y, w, h);
                path = [UIBezierPath bezierPathWithRoundedRect:drawRect cornerRadius:7].CGPath;
            } else {
                path = nil;
            }

            //  绘制人脸框框
            dispatch_sync(dispatch_get_main_queue(), ^{
                self.faceShapeLayer.path = path;
            });
        }];

特征点提取

相似度匹配

表情包制作