本文介绍了如何使用 GPUImage 来实现一个简单的相机。具体功能包括拍照、录制视频、多段视频合成、实时美颜、自定义滤镜实现等。html
AVFoundation 是苹果提供的用于处理基于时间的媒体数据的一个框架。咱们想要实现一个相机,须要从手机摄像头采集数据,离不开这个框架的支持。GPUImage 对 AVFoundation 作了一些封装,使咱们的采集工做变得十分简单。ios
另外,GPUImage 的核心魅力还在于,它封装了一个链路结构的图像数据处理流程,简称滤镜链。滤镜链的结构使得多层滤镜的叠加功能变得很容易实现。git
在下面介绍的功能中,有一些和 GPUImage 自己的关系并不大,咱们是直接调用 AVFoundation 的 API 来实现的。可是,这些功能也是一个相机应用必不可少的一部分。因此,咱们也会简单讲一下每一个功能的实现方式和注意事项。github
在 GPUImage 中,对图像数据的处理都是经过创建滤镜链来实现的。算法
这里就涉及到了一个类 GPUImageOutput
和一个协议 GPUImageInput
。对于继承了 GPUImageOutput
的类,能够理解为具有输出图像数据的能力;对于实现了 GPUImageInput
协议的类,能够理解为具有接收图像数据输入的能力。spring
顾名思义,滤镜链做为一个链路,具备起点和终点。根据前面的描述,滤镜链的起点应该只继承了 GPUImageOutput
类,滤镜链的终点应该只实现了 GPUImageInput
协议,而对于中间的结点应该同时继承了 GPUImageOutput
类并实现了 GPUImageInput
协议,这样才具有承上启下的做用。缓存
在 GPUImage 中,只继承了 GPUImageOutput
,而没有实现 GPUImageInput
协议的类有六个,也就是说有六种类型的输入源:session
一、GPUImagePicture框架
GPUImagePicture
经过图片来初始化,本质上是先将图片转化为 CGImageRef
,而后将 CGImageRef
转化为纹理。dom
二、GPUImageRawDataInput
GPUImageRawDataInput
经过二进制数据初始化,而后将二进制数据转化为纹理,在初始化的时候须要指明数据的格式(GPUPixelFormat
)。
三、GPUImageTextureInput
GPUImageTextureInput
经过已经存在的纹理来初始化。既然纹理已经存在,在初始化的时候就不会从新去生成,只是将纹理的索引保存下来。
四、GPUImageUIElement
GPUImageUIElement
能够经过 UIView
或者 CALayer
来初始化,最后都是调用 CALayer
的 renderInContext:
方法,将当前显示的内容绘制到 CoreGraphics 的上下文中,从而获取图像数据。而后将数据转化为纹理。简单来讲就是截屏,截取当前控件的内容。
这个类能够用来实如今视频上添加文字水印的功能。由于在 OpenGL 中不能直接进行文本的绘制,因此若是咱们想把一个 UILabel
的内容添加到滤镜链里面去,使用 GPUImageUIElement
来实现是很合适的。
五、GPUImageMovie
GPUImageMovie
经过本地的视频来初始化。首先经过 AVAssetReader
来逐帧读取视频,而后将帧数据转化为纹理,具体的流程大概是:AVAssetReaderOutput
-> CMSampleBufferRef
-> CVImageBufferRef
-> CVOpenGLESTextureRef
-> Texture
。
六、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
的类有四个:
一、GPUImageMovieWriter
GPUImageMovieWriter
封装了 AVAssetWriter
,能够逐帧从帧缓存的渲染结果中读取数据,最后经过 AVAssetWriter
将视频文件保存到指定的路径。
二、GPUImageRawDataOutput
GPUImageRawDataOutput
经过 rawBytesForImage
属性,能够获取到当前输入纹理的二进制数据。
假设咱们的滤镜链在输入源和终点之间,链接了三个滤镜,而咱们须要拿到第二个滤镜渲染后的数据,用来作人脸识别。那咱们能够在第二个滤镜后面再添加一个 GPUImageRawDataOutput
做为输出,则能够拿到对应的二进制数据,且不会影响原来的渲染流程。
三、GPUImageTextureOutput
这个类的实现十分简单,提供协议方法 newFrameReadyFromTextureOutput:
,在每一帧渲染结束后,将自身返回,经过 texture
属性就能够拿到输入纹理的索引。
四、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
承载,显示的是整个滤镜链做用的结果。而咱们的拍照接口,能够传入这个链路上的任意一个滤镜,甚至能够在后面多加一个滤镜,而后拍照接口会返回对应滤镜的渲染结果。即咱们的拍照结果不必定要和咱们的预览一致。
示意图以下:
录制视频首先要建立一个 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);
}
}];
}
复制代码
在 GPUImage
中并无提供多段录制的功能,须要咱们本身去实现。
首先,咱们要重复单段视频的录制过程,这样咱们就有了多段视频的文件路径。而后主要实现两个功能,一个是 AVPlayer
的多段视频循环播放;另外一个是经过 AVComposition
来合并多段视频,并用 AVAssetExportSession
来导出新的视频。
整个过程逻辑并不复杂,出于篇幅的考虑,代码就不贴了,请到项目中查看。
在拍照或者录视频结束后,经过 PhotoKit
保存到相册里。
- (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);
}
}];
}
复制代码
- (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
属性来控制,其实只有三种,分别是:
可是市面上的相机应用,通常还有一种常亮类型,这种类型在夜间的时候会比较适用。这个功能须要经过 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);
} else if (ratio == SCCameraRatio4v3) {
self.camera.captureSessionPreset = AVCaptureSessionPreset640x480;
} else if (ratio == SCCameraRatio16v9) {
self.camera.captureSessionPreset = AVCaptureSessionPreset1280x720;
} else if (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;
}
复制代码
当咱们想使用一个滤镜的时候,只须要把它加到滤镜链里去,经过 addTarget:
方法实现。来看一下这个方法的定义:
- (void)addTarget:(id<GPUImageInput>)newTarget;
复制代码
能够看到,只要实现了 GPUImageInput
协议,就能够成为滤镜链的下一个结点。
目前美颜效果已经成为相机应用的标配,咱们也来给本身的相机加上美颜的效果。
美颜效果本质上是对图片作模糊,想要达到比较好的效果,须要结合人脸识别,只对人脸的部分进行模糊处理。这里并不去探究美颜算法的实现,直接找开源的美颜滤镜来用。
目前找到的实现效果比较好的是 LFGPUImageBeautyFilter ,虽然它的效果确定比不上如今市面上的美颜类 APP,可是做为学习级别的 Demo 已经足够了。
效果展现:
打开 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
来定义一个子类,而后用相同的方式来定义两个保存着色器代码的字符串,而且用这两个字符串来初始化子类就能够了。
做为示例,我把以前实现的 抖音滤镜 也添加到这个工程里,来看一下效果:
经过上面的步骤,咱们实现了一个具有基础功能的相机。以后会在这个相机的基础上,继续作一些有趣的尝试,欢迎持续关注~
请到 GitHub 上查看完整代码。
获取更佳的阅读体验,请访问原文地址【Lyman's Blog】使用 GPUImage 实现一个简单相机