31

使用 GPUImage 实现一个简单相机

 4 years ago
source link: https://www.tuicool.com/articles/INfiema
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

y6bAVnJ.jpg!web

作者 |  lymanli,目前在美图担任 iOS 开发,主要工作内容与相机相关,擅长通过阅读或看电影来逃避现实,绘画爱好者。

本文介绍了如何使用 GPUImage 来实现一个简单的相机。具体功能包括拍照、录制视频、多段视频合成、实时美颜、自定义滤镜实现等。

前言

AVFoundation 是苹果提供的用于处理基于时间的媒体数据的一个框架。我们想要实现一个相机,需要从手机摄像头采集数据,离不开这个框架的支持。GPUImage 对 AVFoundation 做了一些封装,使我们的采集工作变得十分简单。

另外,GPUImage 的核心魅力还在于,它封装了一个链路结构的图像数据处理流程,简称滤镜链。滤镜链的结构使得多层滤镜的叠加功能变得很容易实现。

在下面介绍的功能中,有一些和 GPUImage 本身的关系并不大,我们是直接调用 AVFoundation 的 API 来实现的。但是,这些功能也是一个相机应用必不可少的一部分。所以,我们也会简单讲一下每个功能的实现方式和注意事项。

滤镜链简介

在 GPUImage 中,对图像数据的处理都是通过建立 滤镜链 来实现的。

这里就涉及到了一个类 GPUImageOutput 和一个协议 GPUImageInput 。对于继承了 GPUImageOutput 的类,可以理解为具备输出图像数据的能力;对于实现了 GPUImageInput 协议的类,可以理解为具备接收图像数据输入的能力。

顾名思义,滤镜链作为一个链路,具有起点和终点。根据前面的描述,滤镜链的起点应该只继承了 GPUImageOutput 类,滤镜链的终点应该只实现了 GPUImageInput 协议,而对于中间的结点应该同时继承了 GPUImageOutput 类并实现了 GPUImageInput 协议,这样才具备承上启下的作用。

一、滤镜链起点

在 GPUImage 中,只继承了 GPUImageOutput,而没有实现 GPUImageInput 协议的类有六个,也就是说有六种类型的输入源:

1、GPUImagePicture

GPUImagePicture 通过图片来初始化,本质上是先将图片转化为 CGImageRef,然后将 CGImageRef 转化为纹理。

2、GPUImageRawDataInput

GPUImageRawDataInput 通过二进制数据初始化,然后将二进制数据转化为纹理,在初始化的时候需要指明数据的格式(GPUPixelFormat)。

3、GPUImageTextureInput

GPUImageTextureInput 通过已经存在的纹理来初始化。既然纹理已经存在,在初始化的时候就不会重新去生成,只是将纹理的索引保存下来。

4、GPUImageUIElement

GPUImageUIElement 可以通过 UIView 或者 CALayer 来初始化,最后都是调用 CALayer 的 renderInContext: 方法,将当前显示的内容绘制到 CoreGraphics 的上下文中,从而获取图像数据。然后将数据转化为纹理。简单来说就是截屏,截取当前控件的内容。

这个类可以用来实现在视频上添加文字水印的功能。因为在 OpenGL 中不能直接进行文本的绘制,所以如果我们想把一个 UILabel 的内容添加到滤镜链里面去,使用 GPUImageUIElement 来实现是很合适的。

5、GPUImageMovie

GPUImageMovie 通过本地的视频来初始化。首先通过 AVAssetReader 来逐帧读取视频,然后将帧数据转化为纹理,具体的流程大概是:AVAssetReaderOutput -> CMSampleBufferRef -> CVImageBufferRef -> CVOpenGLESTextureRef -> Texture 。

6、GPUImageVideoCamera

GPUImageVideoCamera 通过相机参数来初始化,通过屏幕比例和相机位置(前后置)来初始化相机。这里主要使用 AVCaptureVideoDataOutput 来获取持续的视频流数据输出,在代理方法 captureOutput:didOutputSampleBuffer:fromConnection: 中可以拿到 CMSampleBufferRef ,将其转化为纹理的过程与 GPUImageMovie 类似。

然而,我们在项目中使用的是它的子类 GPUImageStillCamera。 GPUImageStillCamera 在原来的基础上多了一个 AVCaptureStillImageOutput,它是我们实现拍照功能的关键,在 captureStillImageAsynchronouslyFromConnection:completionHandler: 方法的回调中,同样能拿到我们熟悉 CMSampleBufferRef。

简单来说,GPUImageVideoCamera 只能录制视频,GPUImageStillCamera 还可以拍照,因此我们使用 GPUImageStillCamera 。

二、滤镜

滤镜链的关键角色是 GPUImageFilter,它同时继承了 GPUImageOutput 类并实现了 GPUImageInput 协议。GPUImageFilter 实现承上启下功能的基础是「渲染到纹理」,这个操作我们在 《使用 iOS OpenGL ES 实现长腿功能》 一文中已经介绍过了,简单来说就是将结果渲染到纹理而不是屏幕上。

这样,每一个滤镜都能把输出的纹理作为下一个滤镜的输入,实现多层滤镜效果的叠加。

三、滤镜链终点

在 GPUImage 中,实现了 GPUImageInput 协议,而没有继承 GPUImageOutput 的类有四个:

1、GPUImageMovieWriter

GPUImageMovieWriter 封装了 AVAssetWriter,可以逐帧从帧缓存的渲染结果中读取数据,最后通过 AVAssetWriter 将视频文件保存到指定的路径。

2、GPUImageRawDataOutput

GPUImageRawDataOutput 通过 rawBytesForImage 属性,可以获取到当前输入纹理的二进制数据。

假设我们的滤镜链在输入源和终点之间,连接了三个滤镜,而我们需要拿到第二个滤镜渲染后的数据,用来做人脸识别。那我们可以在第二个滤镜后面再添加一个 GPUImageRawDataOutput 作为输出,则可以拿到对应的二进制数据,且不会影响原来的渲染流程。

3、GPUImageTextureOutput

这个类的实现十分简单,提供协议方法 newFrameReadyFromTextureOutput:,在每一帧渲染结束后,将自身返回,通过 texture 属性就可以拿到输入纹理的索引。

4、GPUImageView

GPUImageView 继承自 UIView,通过输入的纹理,执行一遍渲染流程。这次的渲染目标不是新的纹理,而是自身的 layer 。

这个类是我们实现相机功能的重要组成部分,我们所有的滤镜效果,都要依靠它来呈现。

功能实现

一、拍照

拍照功能只需调用一个接口就能搞定,在回调方法中可以直接拿到 UIImage。代码如下:

- (void)takePhotoWtihCompletion:(TakePhotoResult)completion {
GPUImageFilter *lastFilter = self.currentFilterHandler.lastFilter;
[self.camera capturePhotoAsImageProcessedUpToFilter:lastFilter withCompletionHandler:^(UIImage *processedImage, NSError *error) {
if (error && completion) {
completion(nil, error);
return;
}
if (completion) {
completion(processedImage, nil);
}
}];
}

值得注意的是,相机的预览页面由 GPUImageView 承载,显示的是整个滤镜链作用的结果。而我们的拍照接口,可以传入这个链路上的任意一个滤镜,甚至可以在后面多加一个滤镜,然后拍照接口会返回对应滤镜的渲染结果。即我们的拍照结果不一定要和我们的预览一致。

示意图如下:

uMz26n2.jpg!web

二、录制视频

1、单段录制

录制视频首先要创建一个 GPUImageMovieWriter 作为链路的输出,与上面的拍照接口类似,这里录制的视频不一定和我们的预览一样。

整个过程比较简单,当我们调用停止录制的接口并回调之后,视频就被保存到我们指定的路径了。

- (void)setupMovieWriter {
NSString *videoPath = [SCFileHelper randomFilePathInTmpWithSuffix:@".m4v"];
NSURL *videoURL = [NSURL fileURLWithPath:videoPath];
CGSize videoSize = self.videoSize;

self.movieWriter = [[GPUImageMovieWriter alloc] initWithMovieURL:videoURL
size:videoSize];

GPUImageFilter *lastFilter = self.currentFilterHandler.lastFilter;
[lastFilter addTarget:self.movieWriter];
self.camera.audioEncodingTarget = self.movieWriter;
self.movieWriter.shouldPassthroughAudio = YES;

self.currentTmpVideoPath = videoPath;
}

- (void)recordVideo {
[self setupMovieWriter];
[self.movieWriter startRecording];
}

- (void)stopRecordVideoWithCompletion:(RecordVideoResult)completion {
@weakify(self);
[self.movieWriter finishRecordingWithCompletionHandler:^{
@strongify(self);
[self removeMovieWriter];
if (completion) {
completion(self.currentTmpVideoPath);
}
}];
}

2、多段录制

在 GPUImage 中并没有提供多段录制的功能,需要我们自己去实现。

首先,我们要重复单段视频的录制过程,这样我们就有了多段视频的文件路径。然后主要实现两个功能,一个是 AVPlayer 的多段视频循环播放;另一个是通过 AVComposition 来合并多段视频,并用 AVAssetExportSession 来导出新的视频。

整个过程逻辑并不复杂,出于篇幅的考虑,代码就不贴了,请到项目中查看。

三、保存

在拍照或者录视频结束后,通过 PhotoKit 保存到相册里。

1、保存图片

- (void)writeImageToSavedPhotosAlbum:(UIImage *)image
completion:(void (^)(BOOL success))completion {
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
[PHAssetChangeRequest creationRequestForAssetFromImage:image];
} completionHandler:^(BOOL success, NSError * _Nullable error) {
if (completion) {
completion(success);
}
}];
}

2、保存视频

- (void)saveVideo:(NSString *)path completion:(void (^)(BOOL success))completion {
NSURL *url = [NSURL fileURLWithPath:path];
[[PHPhotoLibrary sharedPhotoLibrary] performChanges:^{
[PHAssetChangeRequest creationRequestForAssetFromVideoAtFileURL:url];
} completionHandler:^(BOOL success, NSError * _Nullable error) {
dispatch_async(dispatch_get_main_queue(), ^{
if (completion) {
completion(success);
}
});
}];
}

四、闪光灯

系统的闪光灯类型通过 AVCaptureDevice 的 flashMode 属性来控制,其实只有三种,分别是:

  • AVCaptureFlashModeOff 关闭

  • AVCaptureFlashModeOn 开启(在拍照的时候会闪一下)

  • AVCaptureFlashModeAuto 自动(系统会自动判断当前的环境是否需要闪光灯)

但是市面上的相机应用,一般还有一种常亮类型,这种类型在夜间的时候会比较适用。这个功能需要通过 torchMode 属性来实现,它其实是指手电筒。

我们对这个两个属性做一下封装,允许这四种类型来回切换,下面是根据封装的类型来同步系统类型的代码:

- (void)syncFlashState {
AVCaptureDevice *device = self.camera.inputCamera;
if (![device hasFlash] || self.camera.cameraPosition == AVCaptureDevicePositionFront) {
[self closeFlashIfNeed];
return;
}

[device lockForConfiguration:nil];

switch (self.flashMode) {
case SCCameraFlashModeOff:
device.torchMode = AVCaptureTorchModeOff;
device.flashMode = AVCaptureFlashModeOff;
break;
case SCCameraFlashModeOn:
device.torchMode = AVCaptureTorchModeOff;
device.flashMode = AVCaptureFlashModeOn;
break;
case SCCameraFlashModeAuto:
device.torchMode = AVCaptureTorchModeOff;
device.flashMode = AVCaptureFlashModeAuto;
break;
case SCCameraFlashModeTorch:
device.torchMode = AVCaptureTorchModeOn;
device.flashMode = AVCaptureFlashModeOff;
break;
default:
break;
}

[device unlockForConfiguration];
}

五、画幅比例

相机的比例通过设置 AVCaptureSession 的 sessionPreset 属性来实现。这个属性并不只意味着比例,也意味着分辨率。

由于不是所有的设备都支持高分辨率,所以这里只使用 AVCaptureSessionPreset640x480 和 AVCaptureSessionPreset1280x720 这两个分辨率,分别用来作为 3:4 和 9:16 的输出。

市面上的相机除了上面的两个比例外,一般还支持 1:1 和 Full (iPhoneX 系列的全屏)比例,但是系统并没有提供对应比例的 AVCaptureSessionPreset 。

这里可以通过 GPUImageCropFilter 来实现,这是 GPUImage 的一个内置滤镜,可以对输入的纹理进行裁剪。使用时通过 cropRegion 属性来传入一个归一化的裁剪区域。

切换比例的关键代码如下:

- (void)setRatio:(SCCameraRatio)ratio {
_ratio = ratio;

CGRect rect = CGRectMake(0, 0, 1, 1);
if (ratio == SCCameraRatio1v1) {
self.camera.captureSessionPreset = AVCaptureSessionPreset640x480;
CGFloat space = (4 - 3) / 4.0; // 竖直方向应该裁剪掉的空间
rect = CGRectMake(0, space / 2, 1, 1 - space);
} elseif (ratio == SCCameraRatio4v3) {
self.camera.captureSessionPreset = AVCaptureSessionPreset640x480;
} elseif (ratio == SCCameraRatio16v9) {
self.camera.captureSessionPreset = AVCaptureSessionPreset1280x720;
} elseif (ratio == SCCameraRatioFull) {
self.camera.captureSessionPreset = AVCaptureSessionPreset1280x720;
CGFloat currentRatio = SCREEN_HEIGHT / SCREEN_WIDTH;
if (currentRatio > 16.0 / 9.0) { // 需要在水平方向裁剪
CGFloat resultWidth = 16.0 / currentRatio;
CGFloat space = (9.0 - resultWidth) / 9.0;
rect = CGRectMake(space / 2, 0, 1 - space, 1);
} else { // 需要在竖直方向裁剪
CGFloat resultHeight = 9.0 * currentRatio;
CGFloat space = (16.0 - resultHeight) / 16.0;
rect = CGRectMake(0, space / 2, 1, 1 - space);
}
}
[self.currentFilterHandler setCropRect:rect];
self.videoSize = [self videoSizeWithRatio:ratio];
}

六、前后置切换

通过调用 GPUImageVideoCamera 的 rotateCamera 方法来实现。

另外,由于前置摄像头不支持闪光灯,如果在前置的时候去切换闪光灯,只能修改我们封装的类型。所以在切换到后置的时候,需要去同步一下系统的闪光灯类型:

- (void)rotateCamera {
[self.camera rotateCamera];
// 切换摄像头,同步一下闪光灯
[self syncFlashState];
}

七、对焦

AVCaptureDevice 的 focusMode 用来设置聚焦模式,focusPointOfInterest 用来设置聚焦点;exposureMode 用来设置曝光模式,exposurePointOfInterest 用来设置曝光点。

前置摄像头只支持设置曝光,后置摄像头支持设置曝光和聚焦,所以在设置之前要先判断是否支持。

需要注意的是,相机默认输出的图像是横向的,图像向右偏转。而前置摄像头又是镜像,所以图像是向左偏转。我们从 UIView 获得的触摸点,要经过相应的转化,才是正确的坐标。关键代码如下:

- (void)setFocusPoint:(CGPoint)focusPoint {
_focusPoint = focusPoint;

AVCaptureDevice *device = self.camera.inputCamera;

// 坐标转换
CGPoint currentPoint = CGPointMake(focusPoint.y / self.outputView.bounds.size.height, 1 - focusPoint.x / self.outputView.bounds.size.width);
if (self.camera.cameraPosition == AVCaptureDevicePositionFront) {
currentPoint = CGPointMake(currentPoint.x, 1 - currentPoint.y);
}

[device lockForConfiguration:nil];

if ([device isFocusPointOfInterestSupported] &&
[device isFocusModeSupported:AVCaptureFocusModeAutoFocus]) {
[device setFocusPointOfInterest:currentPoint];
[device setFocusMode:AVCaptureFocusModeAutoFocus];
}
if ([device isExposurePointOfInterestSupported] &&
[device isExposureModeSupported:AVCaptureExposureModeAutoExpose]) {
[device setExposurePointOfInterest:currentPoint];
[device setExposureMode:AVCaptureExposureModeAutoExpose];
}

[device unlockForConfiguration];
}

八、改变焦距

改变焦距简单来说就是画面的放大缩小,通过设置 AVCaptureDevice 的 videoZoomFactor 属性实现。

值得注意的是,这个属性有最大值和最小值,设置之前需要做好判断,否则会直接崩溃。代码如下:

- (void)setVideoScale:(CGFloat)videoScale {
_videoScale = videoScale;

videoScale = [self availableVideoScaleWithScale:videoScale];

AVCaptureDevice *device = self.camera.inputCamera;
[device lockForConfiguration:nil];
device.videoZoomFactor = videoScale;
[device unlockForConfiguration];
}

- (CGFloat)availableVideoScaleWithScale:(CGFloat)scale {
AVCaptureDevice *device = self.camera.inputCamera;

CGFloat maxScale = kMaxVideoScale;
CGFloat minScale = kMinVideoScale;
if (@available(iOS 11.0, *)) {
maxScale = device.maxAvailableVideoZoomFactor;
}

scale = MAX(scale, minScale);
scale = MIN(scale, maxScale);

return scale;
}

九、滤镜

1、滤镜的使用

当我们想使用一个滤镜的时候,只需要把它加到滤镜链里去,通过 addTarget: 方法实现。来看一下这个方法的定义:

- (void)addTarget:(id<GPUImageInput>)newTarget;

可以看到,只要实现了 GPUImageInput 协议,就可以成为滤镜链的下一个结点。

2、美颜滤镜

目前美颜效果已经成为相机应用的标配,我们也来给自己的相机加上美颜的效果。

美颜效果本质上是对图片做模糊,想要达到比较好的效果,需要结合人脸识别,只对人脸的部分进行模糊处理。这里并不去探究美颜算法的实现,直接找开源的美颜滤镜来用。

目前找到的实现效果比较好的是 LFGPUImageBeautyFilter ,虽然它的效果肯定比不上现在市面上的美颜类 APP,但是作为学习级别的 Demo 已经足够了。

效果展示:

iaIjQjn.jpg!web

原图太大,请点击“阅读原文”查看效果

3、自定义滤镜

打开 GPUImageFilter 的头文件,可以看到有下面这个方法:

- (id)initWithVertexShaderFromString:(NSString *)vertexShaderString 
fragmentShaderFromString:(NSString *)fragmentShaderString;

很容易理解,通过一个顶点着色器和一个片段着色器来初始化,并且可以看到是字符串类型。

另外,GPUImageFilter 中还内置了简单的顶点着色器和片段着色器,顶点着色器代码如下:

NSString *const kGPUImageVertexShaderString = SHADER_STRING
(
attribute vec4 position;
attribute vec4 inputTextureCoordinate;

varying vec2 textureCoordinate;

void main()
{
gl_Position = position;
textureCoordinate = inputTextureCoordinate.xy;
}
);

这里用到了 SHADER_STRING 宏,看一下它的定义:

#define STRINGIZE(x) #x
#define STRINGIZE2(x) STRINGIZE(x)
#define SHADER_STRING(text) @ STRINGIZE2(text)

在 #define 中的 # 是「字符串化」的意思,返回 C 语言风格字符串,而 SHADER_STRING 在字符串前面加了一个 @ 符号,则 SHADER_STRING 的定义就是将括号中的内容转化为 OC 风格的字符串。

我们之前都是为着色器代码单独创建两个文件,而在 GPUImageFilter 中直接以字符串的形式,写死在代码中,两种方式本质上没什么区别。

当我们想自定义一个滤镜的时候,只需要继承 GPUImageFilter 来定义一个子类,然后用相同的方式来定义两个保存着色器代码的字符串,并且用这两个字符串来初始化子类就可以了。

作为示例,我把之前实现的 抖音滤镜 也添加到这个工程里,来看一下效果:

m2Q7ZfN.jpg!web

原图太大,请点击“阅读原文”查看效果

总结

通过上面的步骤,我们实现了一个具备基础功能的相机。之后会在这个相机的基础上,继续做一些有趣的尝试,欢迎持续关注~

源码

请到 GitHub (https://github.com/lmf12/SimpleCam) 上查看完整代码。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK