iOS相机简单封装及理解(tl:tr)

猪小花1号2018-08-27 09:13

1. 前言(可跳过)

     眼睛是心灵的窗户,在现在,把这句话换成手机是心灵的窗户也好像一点不过分。多年以前,可能人类自己也没有想到手机摄像头变成了日常生活中无所不在的观察者。借着集成化、无线网络的普及(主要是3G、4G通信技术)。在这个信息爆炸的年代,通过手机摄像头捕获到的信息变成了图片和视频,填满了离不开网络的现代人生活。
    苹果的iPhone在差不多十年的时间里,从200W像素、1/4µ感光组件、f/2.8 的相机发展到了iPhone 8 plus上的12MP、1.22µ感光组件、广角f/1.8 长焦f/2.4的双摄像头相机,甚至还支持60fps 4K录像。相机充斥着我们的生活:拍照、摄影、滤镜、二维码、AR、OCR、机器学习甚至翻译……每一个iPhone使用者似乎都经历过以上某种使用场景,以及每个iOS开发者或多或少都会遇到一些和相机相关的开发工作。

2. iOS中的那些相机实现

2.1 UIImagePickerController

UIImagePickerController位于UIKit层级中,作为Cocoa Touch甚至iOS开发中最顶层的和相机相关的类。UIImagePickerController提供了很简单的功能和API用于我们的相机和相册相关的功能,包括以下几个功能:

  • 相册选取功能
  • 照片简单编辑
  • 照片或者视频流捕获

缺点:

  • 无法获取相机每一帧的源数据
  • 原生样式没法做太多的自定义

  • 系统相册默认样式

  • 系统相机的默认样式

     UIImagePickerController就不写Demo了,似乎每个iOS开发者的都应该接触过……

2.2 AVFoundation

    AVFoundation 并不是Cocoa Touch中的普遍一样上的框架概念。AVFoundation是iOS中一系列iOS多媒体相关框架的总和。AVFoundation用作控制设备相机、对音频流的处理,及和其他多媒体文件读写之类操作的交互。
    一方面AVFoundation提供了大量的面向对象的强大的API(Objective-C、swift);另一方面,AVFoundation在整个Cocoa Touch中自顶而下将一系列框架的相关功能组合在一起。在上层的AVKit、UIKit不能满足对相机、相册、录音等定制化需求的时候,如对每一帧的音视频的处理,由更底层的Core Audio、Core Media、Core Animation来完成。一个简单的例子是,一个应用中,可能播放录制的视频用的是AVKit,而处理视频流用的是Core Media (CMSampleBuffer)。

贯穿Cocoa Touch中的AVFoundation

3. MMCamera简单封装及API

    MMCamera是一个简单的相机类,只要将需要使用相机的类直接继承MMCamera,该类就可以"变成"一个相机。点击此处参考Demo代码。MMCamera的头文件中暴露了简单的一些用法。高级用法可以参考.m文件中的实现,包括对焦、焦距、ISO、色彩空间、色温、闪光灯模式、白平衡等等(直接暴露可用)。

    swift实现可以参考CoreMLDemo 中的VideoCapture.swift

3.1 主要接口

/*32 bit BGRA 视频流 */
 - (void)startCamera;//初始化方法一

/*初始化方法二,可以自定义sessionPreset 和AVCaptureDevicePosition*/
- (void)startCameraWithSessionPreset:(NSString    *)sessionPreset cameraPosition:        (AVCaptureDevicePosition)cameraPosition;

/*开启相机 */
- (void)startRunning;

 /*停止相机*/
- (void)stopRunning;

/*暂停相机*/
- (void)pauseCamera;//需要退出界面的时候暂停相机

/*继续相机*/
- (void)resumeCamera; //需要在退出界面的时候暂停

/*设置焦距*/
-(void)changeLensPosition:(float)value;

/*拍照并保存照片到相册*/
-(void)snapStillImage;

3.2 代理

@protocol MMCameraDelegate <NSObject>

@optional

/*32 bit BGRA 视频流 */
- (void)willOutputSampleBuffer:(CMSampleBufferRef )sampleBuffer;

@end

3.3 主要属性

@property (nonatomic) dispatch_queue_t sessionQueue; 

相机的相关处理(AVCaptureSession)非常消耗CPU和内存资源,所以核心相关的操作最好是放在异步线程中处理

@property (nonatomic) AVCaptureSession *session;

AVCaptureSession管理和记录从输入设备捕获的数据流,并将数据流传出到设备outPuts(本例子中为AVCaptureVideoDataOutput,为获取的视频流),为AVFoundation核心类

@property (nonatomic) AVCaptureDeviceInput *videoDeviceInput;

将数据流从设备捕获,然后提供AVCaptureSession

@property (nonatomic) AVCaptureDevice *videoDevice;

提供音频和视频的源数据和设备相机等控制相关的参数

@property (nonatomic) AVCaptureVideoDataOutput *avCaptureVideoDataOutput;

捕获的视频流输出

3.4 初始化核心代码

初始化大致分为几个步骤

  • 1. 获取相机权限(AVCaptureDevice)
  • 2. 获取需要控制的设备分辨率、相机镜头等(AVCaptureDevice)
  • 3. 初始化输入(AVCaptureDeviceInput)
  • 4. 初始化并设置核心控制类 (AVCaptureSession)
  • 5. 设置输出 (AVCaptureVideoDataOutput)
  • 6. 设置相机视频流预览 (AVCaptureVideoPreviewLayer)

初始化核心代码:

-(void)startCameraWithSessionPreset:(NSString *)sessionPreset cameraPosition:(AVCaptureDevicePosition)cameraPosition{

    // 1. 获取相机权限(AVCaptureDevice)
    [self checkDeviceAuthorizationStatus]; //需要检测是否已经获取相机的权限
    [self addCameraPreviewView]; //增加相机预览界面

    capturePaused = NO;
    lensLocked = NO;

    //2. 获取需要控制的设备分辨率、相机镜头等(AVCaptureDevice)
    NSArray *cameras = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo]; //获取用到的设备控制类型
    for (AVCaptureDevice *device in cameras) {
        if (device.position == AVCaptureDevicePositionBack) {
            //设置使用的相机(前置或后置)
            _videoDevice = device;
        }
    }

    //3. 初始化输入(AVCaptureDeviceInput)
    NSError *error = nil;
    _videoDeviceInput = [AVCaptureDeviceInput deviceInputWithDevice:_videoDevice error:&error]; //用AVCaptureDevice进行AVCaptureDeviceInput的初始化设置
    if (!_videoDeviceInput){
        NSLog(@"error is %@",error);
        return;
    }

    //4. 初始化并设置核心控制类 (AVCaptureSession)
    _session = [[AVCaptureSession alloc] init]; //AVFoundation核心类初始化,控制相机的整个生命周期
    _session.sessionPreset = sessionPreset; //sessionPreset决定相机使用何种分辨率的预览以及视频流帧率和分辨率,具体需要查阅苹果的文档

    [_session beginConfiguration]; //需要在设置之前调用,这个时候AVCaptureSession类会自动断开输出或者输出,并在commitConfiguration设置结束之后

    if ([_session canAddInput:_videoDeviceInput]){ 
        [self setVideoDevice:_videoDeviceInput.device];
        [_session addInput:_videoDeviceInput]; //为AVCaptureSession增加相机捕获
    }

    //5. 设置输出 (AVCaptureVideoDataOutput)
    _avCaptureVideoDataOutput = [[AVCaptureVideoDataOutput alloc] init];
    NSDictionary*settings = @{(__bridge id)kCVPixelBufferPixelFormatTypeKey: @(kCVPixelFormatType_32BGRA)}; //设置色彩空间,后文会继续谈到色彩空间的概念
    _avCaptureVideoDataOutput.videoSettings = settings;
    _sessionQueue = dispatch_queue_create("com.netease.multimedia", NULL);
    [_avCaptureVideoDataOutput setSampleBufferDelegate:self queue:_sessionQueue];
    [_session addOutput:_avCaptureVideoDataOutput];

    //6. 设置相机视频流预览 (AVCaptureVideoPreviewLayer)
    dispatch_async(dispatch_get_main_queue(), ^{//界面相关,在主线程中处理
        [[(AVCaptureVideoPreviewLayer *)[_previewView layer] connection] setVideoOrientation:(AVCaptureVideoOrientation)[self interfaceOrientation]];
    });

    [_previewView setSession:_session]; //将Session给 _previewView类,用于显示相机预览
    AVCaptureVideoPreviewLayer* layer = (AVCaptureVideoPreviewLayer*)_previewView.layer;
    layer.videoGravity = AVLayerVideoGravityResizeAspectFill; //设置相机填充模式
    [[self session] commitConfiguration]; //提交设置
}

3.5 获取相机视频流源数据

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

需要获取视频流,需要遵循AVCaptureVideoDataOutputSampleBufferDelegate协议,CMSampleBufferRef即我们获取到的视频流

3.6 设置白平衡的代码示例

- (void)setWhiteBalanceGains:(AVCaptureWhiteBalanceGains)gains{
    NSError *error = nil;
    if ([self.videoDevice lockForConfiguration:&error]){
        AVCaptureWhiteBalanceGains normalizedGains = [self normalizedGains:gains]; // Conversion can yield out-of-bound values, cap to limits
        [self.videoDevice setWhiteBalanceModeLockedWithDeviceWhiteBalanceGains:normalizedGains completionHandler:nil];
        [self.videoDevice unlockForConfiguration];
    }else{
        NSLog(@"%@", error);
    }
}

4. 相机的各种玩法

     iOS的相机可以和好多内容联系再一起,比如13、14年那会和滤镜渲染相关的很火的VSCOCam、美图秀秀;之后和人脸跟踪相关的Faceu等。现在,则是AR、深度学习大行其道,代表框架比如ARKit、比如CoreML。另外还有很多好玩的应用有比如微软识花,比如实时翻译等。下面来简单介绍滤镜相关的GPUImage和Core Image以及和openCV算法相关的FaceAlignmentSDK 和 MMTextDetectorSDK(这两个不提供源码,只提供接口实现和效果参考)。

4.1 GPUImage

     GPUImage使用OpenGL ES 2.0着色器进行图像和视频的处理速度。通过对OpenGL ES 2.0良好的封装,抽象出简单的Objective-C接口。该接口定义了图像和视频输入信号、滤镜链接和处理并输出图像的整个流程。图像或视频源的帧从GPUImageOutput的子类读取,这些子类包括GPUImageVideoCamera(从iOS的摄像头的采集的实况视频),GPUImageStillCamera(摄像头捕捉的照片),GPUImagePicture(静止图像),和GPUImageMovie(电影)。
     源图像帧在OpenGL ES转换中转换成纹理,然后处理成下一个环节中需要的对象。滤镜和处理环节中后续的元素遵循GPUImageInput协议,将该纹理从上一步环节中获取纹理并添加目标(Target),进一步的处理,可以添加单个或者多个目标到同一个问题,然后输出一个滤镜。一个典型的应用,在实时相机上添加上深褐色效果,然后显示在屏幕上。

GPUImage整体链接流程

     GPUImage使用上非常简单:

//1 初始化相机
videoCamera = [[GPUImageVideoCamera alloc] initWithSessionPreset:AVCaptureSessionPreset640x480 cameraPosition:AVCaptureDevicePositionBack]; 
videoCamera.outputImageOrientation = UIInterfaceOrientationPortrait; 
videoCamera.horizontallyMirrorFrontFacingCamera = NO;  
videoCamera.horizontallyMirrorRearFacingCamera = NO;

//2 初始化滤镜
filter = [[GPUImageSepiaFilter alloc] init];
[videoCamera addTarget:filter];

//3 初始化视频预览界面
GPUImageView *filterView = (GPUImageView *)self.view;
[filter addTarget:filterView];

//4 启动相机
[videoCamera startCameraCapture];

断断几行代码就完成了相机的启动,并且添加上了GPUImageSepiaFilter滤镜,而滤镜的定制也非常的简单,GPUImage已经为我们封装了一系列的渲染步骤,只需要编写shader,即可以自定义滤镜,以下为GPUImageSepiaFilter滤镜的shader代码:

NSString *const kGPUImageColorMatrixFragmentShaderString = SHADER_STRING
(
 varying highp vec2 textureCoordinate;
 uniform sampler2D inputImageTexture;
  uniform lowp mat4 colorMatrix;
  uniform lowp float intensity;
  void main(){
      lowp vec4 textureColor = texture2D(inputImageTexture, textureCoordinate);
      lowp vec4 outputColor = textureColor * colorMatrix;
      gl_FragColor = (intensity * outputColor) + ((1.0 - intensity) * textureColor);
  }
);

相机原图预览

相机效果图

4.2 Core Image

     Core Image 预置了一系列的滤镜用于处理iOS图片或者视频。
     CIFunHouse 案例展示了苹果如何将Core Image中的滤镜用于实时相机和滤镜。

核心关键部分代码

  - (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection
{
    CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);
    CIImage *sourceImage = [CIImage imageWithCVPixelBuffer:(CVPixelBufferRef)imageBuffer options:nil];    

    // run the filter through the filter chain
    CIImage *filteredImage = RunFilter(sourceImage, _activeFilters);

    CGRect sourceExtent = sourceImage.extent;

    CGFloat sourceAspect = sourceExtent.size.width / sourceExtent.size.height;
    CGFloat previewAspect = _videoPreviewViewBounds.size.width  / _videoPreviewViewBounds.size.height;

    // we want to maintain the aspect radio of the screen size, so we clip the video image
    CGRect drawRect = sourceExtent;
    if (sourceAspect > previewAspect){
        // use full height of the video image, and center crop the width
        drawRect.origin.x += (drawRect.size.width - drawRect.size.height * previewAspect) / 2.0;
        drawRect.size.width = drawRect.size.height * previewAspect;
    }else {
        // use full width of the video image, and center crop the height
        drawRect.origin.y += (drawRect.size.height - drawRect.size.width / previewAspect) / 2.0;
        drawRect.size.height = drawRect.size.width / previewAspect;
    }

    if (_assetWriter == nil){
        [_videoPreviewView bindDrawable];

        if (_eaglContext != [EAGLContext currentContext])
            [EAGLContext setCurrentContext:_eaglContext];

        // clear eagl view to grey
        glClearColor(0.5, 0.5, 0.5, 1.0);
        glClear(GL_COLOR_BUFFER_BIT);

        // set the blend mode to "source over" so that CI will use that
        glEnable(GL_BLEND);
        glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);

        if (filteredImage)
            [_ciContext drawImage:filteredImage inRect:_videoPreviewViewBounds fromRect:drawRect];

        [_videoPreviewView display];
    }
}  

从上到下,首先获取到 CVImageBufferRef结构体,也就是相机的源数据

    CVImageBufferRef imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer);

然后将该帧的CVPixelBufferRef转换成CIImage,用于后续放在Core Image中进行链式处理

    CIImage *sourceImage = [CIImage imageWithCVPixelBuffer:(CVPixelBufferRef)imageBuffer options:nil];    
    CIImage *filteredImage = RunFilter(sourceImage, _activeFilters);

RunFilter 方法细节

 static inline CIImage *RunFilter(CIImage *cameraImage, NSArray *filters)
 {
     CIImage *currentImage = nil;
     NSMutableArray *activeInputs = [NSMutableArray array];

     for (CIFilter *filter in filters){
         if ([filter isKindOfClass:[SourceVideoFilter class]]){
            [filter setValue:cameraImage forKey:kCIInputImageKey];
        }else{
            for (NSString *attrName in [filter imageInputAttributeKeys]){
                CIImage* top = [activeInputs lastObject];
                if (top){
                    [filter setValue:top forKey:attrName];
                    [activeInputs removeLastObject];
                }else
                    NSLog(@"failed to set %@ for %@", attrName, filter.name);
            }
        }
        currentImage = filter.outputImage;
        if (currentImage == nil)
            return nil;
        [activeInputs addObject:currentImage];
    }
        if (CGRectIsEmpty(currentImage.extent))
        return nil;
        return currentImage;
}

设置滤镜的核心代码

[filter setValue:top forKey:attrName];

之所以称作链式,是因为Core Image中的多个滤镜的叠加,最后只会认做一次渲染,所以效率会很高,Core Image可以放在GPU上渲染,当在GPU上的时候,基本不会有性能上的瓶颈,不足支出,可能是默认的滤镜可自定义性不够强,自定义滤镜写起来会稍显麻烦。可以注意到一些不一样的地方,CIFunHouse中并没有像MMCamera中一样用默认的Preview去设置和显示相机预览,而是直接将CIImage直接设置EAGLContext的上下文,然后用用glES绘制出来。

// 与OpenGLES绑定
[_videoPreviewView bindDrawable];

//设置EAGLContext为上下文为_eaglContext
if (_eaglContext != [EAGLContext currentContext])
[EAGLContext setCurrentContext:_eaglContext];

// 将 eagl view 置灰
glClearColor(0.5, 0.5, 0.5, 1.0);
glClear(GL_COLOR_BUFFER_BIT);

// 启用混合 ,设置目标颜色
glEnable(GL_BLEND);
glBlendFunc(GL_ONE, GL_ONE_MINUS_SRC_ALPHA);

//将滤镜处理过的图片用EAGLContext绘制
if (filteredImage)
    [_ciContext drawImage:filteredImage inRect:_videoPreviewViewBounds fromRect:drawRect];

//显示该帧
[_videoPreviewView display];

简单的glES绘制视频流即如上代码所示,用以上流程绘制相机预览界面的优势是可定制性更强,我们可以对每一帧视频流数据先进行处理,可以自定义处理区域、处理方案,可以自定义处理的效果。

比如现在有一个需求,需要实时跟踪人脸并进行面部的美白,可能就需要4个方向的同学同时参与这个工程

  • native:iOS或Android选手,负责相机编写数据采集和上层封装
  • 人脸算法:C++(openCV)选手,在CVPixelBufferRef中截取人脸的坐标,然后交给glES进行渲染处理。
  • 渲染:C++、glES 编写glES和渲染相关代码
  • 美术:专门写shader的同学,调节渲染的效果

以上的每一步可能都涉及到许多可以优化的点。所以,为了达到好的效果,可能需要花费的代价是极其大的。当然如果只有一枚iOS程序猿,可能最简单的方式就是全部用iOS原生框架实现。用Core Image去截取人脸坐标,然后用Core Image去叠加滤镜,然后将效果叠加到最终的图片上并且绘制出来。但是可拓展性比不上openCV的跨平台,人脸跟踪和glES渲染等等都比不上专门优化过的glES。比如Core Image只能获取到比较局限的人脸特征点,而openCV则不仅支持跨平台,还能获取到丰富得多的人脸信息,在人脸跟踪的稳定性和延迟上,效果也好很多。



相关阅读:

iOS相机简单封装及理解(tl:tr)(下篇)

网易云新用户大礼包:https://www.163yun.com/gift

本文来自网易实践者社区,经作者金立涨授权发布。