iOS8 Core Image In Swift:视频实时滤镜

简介: iOS8 Core Image In Swift:自动改善图像以及内置滤镜的使用iOS8 Core Image In Swift:更复杂的滤镜iOS8 Core Image In Swift:人脸检测以及马赛克iOS8 Core Image In Swift:视频实时滤镜在Core Image之前,我们虽然也能在视频录制或照片拍摄中对图像进行实时处理,但远没有Core Image使用起来方便,我们稍后会通过一个Demo回顾一下以前的做法,在此之前的例子都可以在模拟器和真机中测试,而这个例子因为会用到摄像头,所以只能在真机上测试。

iOS8 Core Image In Swift:自动改善图像以及内置滤镜的使用

iOS8 Core Image In Swift:更复杂的滤镜

iOS8 Core Image In Swift:人脸检测以及马赛克

iOS8 Core Image In Swift:视频实时滤镜


在Core Image之前,我们虽然也能在视频录制或照片拍摄中对图像进行实时处理,但远没有Core Image使用起来方便,我们稍后会通过一个Demo回顾一下以前的做法,在此之前的例子都可以在模拟器和真机中测试,而这个例子因为会用到摄像头,所以只能在真机上测试。


视频采集

我们要进行实时滤镜的前提,就是对摄像头以及UI操作的完全控制,那么我们将不能使用系统提供的Controller,需要自己去绘制一切。
先建立一个Single View Application工程(我命名名RealTimeFilter),还是在Storyboard里关掉Auto Layout和Size Classes,然后放一个Button进去,Button的事件连到VC的 openCamera方法上,然后我们给VC加两个属性:

class ViewController: UIViewController , AVCaptureVideoDataOutputSampleBufferDelegate {

    var captureSession: AVCaptureSession!

    var previewLayer: CALayer!

......

一个previewLayer用来做预览窗口,还有一个 AVCaptureSession则是重点。
除此之外,我还对VC实现了AVCaptureVideoDataOutputSampleBufferDelegate协议,这个会在后面说。
要使用AV框架,必须先引入库:import AVFoundation
在viewDidLoad里实现如下:

override func viewDidLoad() {

    super.viewDidLoad()

    

    previewLayer = CALayer()

    previewLayer.bounds = CGRectMake(00self.view.frame.size.heightself.view.frame.size.width);

    previewLayer.position = CGPointMake(self.view.frame.size.width / 2.0self.view.frame.size.height / 2.0);

    previewLayer.setAffineTransform(CGAffineTransformMakeRotation(CGFloat(M_PI / 2.0)));

    

    self.view.layer.insertSublayer(previewLayer, atIndex: 0)

    

    setupCaptureSession()

}

这里先对previewLayer进行初始化,注意bounds的宽、高和设置的旋转,这是因为AVFoundation产出的图像是旋转了90度的,所以这里预先调整过来,然后把layer插到最下面,全屏显示,最后调用初始化captureSession的方法:

func setupCaptureSession() {

    captureSession = AVCaptureSession()

    captureSession.beginConfiguration()


    captureSession.sessionPreset = AVCaptureSessionPresetLow

    

    let captureDevice = AVCaptureDevice.defaultDeviceWithMediaType(AVMediaTypeVideo)

    

    let deviceInput = AVCaptureDeviceInput.deviceInputWithDevice(captureDevice, error: nilas AVCaptureDeviceInput

    if captureSession.canAddInput(deviceInput) {

        captureSession.addInput(deviceInput)

    }

    

    let dataOutput = AVCaptureVideoDataOutput()

    dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey : kCVPixelFormatType_420YpCbCr8BiPlanarFullRange]

    dataOutput.alwaysDiscardsLateVideoFrames = true

    

    if captureSession.canAddOutput(dataOutput) {

        captureSession.addOutput(dataOutput)

    }

    

    let queue = dispatch_queue_create("VideoQueue"DISPATCH_QUEUE_SERIAL)

    dataOutput.setSampleBufferDelegate(self, queue: queue)


    captureSession.commitConfiguration()

}

从这个方法开始,就算正式开始了。

  1. 首先实例化一个AVCaptureSession对象,AVFoundation基于会话的概念,会话(session)被用于控制输入到输出的过程
  2. beginConfiguration与commitConfiguration总是成对调用,当后者调用的时候,会批量配置session,且是线程安全的,更重要的是,可以在session运行中执行,总是使用这对方法是一个好的习惯
  3. 然后设置它的采集质量,除了AVCaptureSessionPresetLow以外还有很多其他选项,感兴趣可以自己看看。
  4. 获取采集设备,默认的摄像设备是后置摄像头。
  5. 把上一步获取到的设备作为输入设备添加到当前session中,先用canAddInput方法判断一下是个好习惯。
  6. 添加完输入设备后再添加输出设备到session中,我在这里添加的是AVCaptureVideoDataOutput,表示视频里的每一帧,除此之外,还有AVCaptureMovieFileOutput(完整的视频)、AVCaptureAudioDataOutput(音频)、AVCaptureStillImageOutput(静态图)等。关于videoSettings属性设置,可以先看看文档说明:

    后面有写到虽然videoSettings是指定一个字典,但是目前只支持kCVPixelBufferPixelFormatTypeKey,我们用它指定像素的输出格式,这个参数直接影响到生成图像的成功与否,由于我打算先做一个实时灰度的效果,所以这里使用kCVPixelFormatType_420YpCbCr8BiPlanarFullRange的输出格式,关于这个格式的详细说明,可以看最后面的参数资料3(YUV的维基)。
  7. 后面设置了alwaysDiscardsLateVideoFrames参数,表示丢弃延迟的帧;同样用canAddInput方法判断并添加到session中。
  8. 最后设置delegate回调(AVCaptureVideoDataOutputSampleBufferDelegate协议)和回调时所处的GCD队列,并提交修改的配置。

我们现在完成一个session的建立过程,但这个session还没有开始工作,就像我们访问数据库的时候,要先打开数据库---然后建立连接---访问数据---关闭连接---关闭数据库一样,我们在openCamera方法里启动session: 

@IBAction func openCamera(sender: UIButton) {

    sender.enabled = false

    captureSession.startRunning()

}

session启动之后,不出意外的话,回调就开始了,并且是实时回调(这也是为什么要把delegate回调放在一个GCD队列中的原因),我们处理

optional func captureOutput(captureOutput: AVCaptureOutput!, didOutputSampleBuffer sampleBuffer: CMSampleBuffer!, fromConnection connection: AVCaptureConnection!)

这个回调就可以了:


Core Image之前的方式

func captureOutput(captureOutput: AVCaptureOutput!,

                    didOutputSampleBuffer sampleBuffer: CMSampleBuffer!,

                    fromConnection connection: AVCaptureConnection!) {


    let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)


    CVPixelBufferLockBaseAddress(imageBuffer, 0)


    let width = CVPixelBufferGetWidthOfPlane(imageBuffer, 0)

    let height = CVPixelBufferGetHeightOfPlane(imageBuffer, 0)

    let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(imageBuffer, 0)

    let lumaBuffer = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0)

    

    let grayColorSpace = CGColorSpaceCreateDeviceGray()

    let context = CGBitmapContextCreate(lumaBuffer, width, height, 8, bytesPerRow, grayColorSpace, CGBitmapInfo.allZeros)

    let cgImage = CGBitmapContextCreateImage(context)

    

    dispatch_sync(dispatch_get_main_queue(), {

        self.previewLayer.contents = cgImage

    })

}

当数据缓冲区的内容更新的时候,AVFoundation就会马上调这个回调,所以我们可以在这里收集视频的每一帧,经过处理之后再渲染到layer上展示给用户。

  1. 首先这个回调给我们了一个CMSampleBufferRef类型的sampleBuffer,这是Core Media对象,我们可以通过CMSampleBufferGetImageBuffer方法把它转成Core Video对象。
  2. 然后我们把缓冲区的base地址给锁住了,锁住base地址是为了使缓冲区的内存地址变得可访问,否则在后面就取不到必需的数据,显示在layer上就只有黑屏,更详细的原因可以看这里:
    http://stackoverflow.com/questions/6468535/cvpixelbufferlockbaseaddress-why-capture-still-image-using-avfoundation
  3. 接下来从缓冲区取图像的信息,包括宽、高、每行的字节数等
  4. 因为视频的缓冲区是YUV格式的,我们要把它的luma部分提取出来
  5. 我们为了把缓冲区的图像渲染到layer上,需要用Core Graphics创建一个颜色空间和图形上下文,然后通过创建的颜色空间把缓冲区的图像渲染到上下文中
  6. cgImage就是从缓冲区创建的Core Graphics图像了(CGImage),最后我们在主线程把它赋值给layer的contents予以显示
现在在真机上编译、运行,应该能看到如下的实时灰度效果:

(这张图是通过手机截屏获取的,容易手抖,所以不是很清晰)

用Core Image处理

通过以上几步可以看到,代码不是很多,没有Core Image也能处理,但是比较费劲,难以理解、不好维护,如果想多增加一些效果(这仅仅是一个灰度效果),代码会变得非常臃肿,所以拓展性也不好。
事实上,我们想通过Core Image改造上面的代码也很简单,先从添加CIFilter和CIContext开始,这是Core Image的核心内容。
在VC上新增两个属性:

var filter: CIFilter!

lazy var context: CIContext = {

    let eaglContext = EAGLContext(API: EAGLRenderingAPI.OpenGLES2)

    let options = [kCIContextWorkingColorSpace : NSNull()]

    return CIContext(EAGLContext: eaglContext, options: options)

}()

申明一个CIFilter对象,不用实例化;懒加载一个CIContext,这个CIContext的实例通过 contextWithEAGLContext:方法构造,和我们之前所使用的不一样,虽然通过 contextWithOptions:方法也能构造一个GPU的CIContext,但前者的优势在于:渲染图像的过程始终在GPU上进行,并且永远不会复制回CPU存储器上,这就保证了更快的渲染速度和更好的性能。
实际上,通过contextWithOptions:创建的GPU的context,虽然渲染是在GPU上执行,但是其输出的image是不能显示的,
只有当其被复制回CPU存储器上时,才会被转成一个可被显示的image类型,比如UIImage。
我们先创建了一个EAGLContext,再通过EAGLContext创建一个CIContext,并且通过把working color space设为nil来关闭颜色管理功能,颜色管理功能会降低性能,而且只有当对颜色保真度要求很高的时候才需要颜色管理功能,在其他情况下,特别是实时处理中,颜色保真都不是特别重要(性能第一,视频帧延迟很高的app大家都不会喜欢的)。
然后我们把session的配置过程稍微修改一下,只修改一处代码即可:

kCVPixelFormatType_420YpCbCr8BiPlanarFullRange

替换为

kCVPixelFormatType_32BGRA

我们把上面那个难以理解的格式替换为BGRA像素格式,大多数情况下用此格式即可。

再把session的回调进行一些修改,变成我们熟悉的方式,就像这样:

func captureOutput(captureOutput: AVCaptureOutput!,

                    didOutputSampleBuffer sampleBuffer: CMSampleBuffer!,

                    fromConnection connection: AVCaptureConnection!) {

    let imageBuffer = CMSampleBufferGetImageBuffer(sampleBuffer)

                        

    // CVPixelBufferLockBaseAddress(imageBuffer, 0)

    // let width = CVPixelBufferGetWidthOfPlane(imageBuffer, 0)

    // let height = CVPixelBufferGetHeightOfPlane(imageBuffer, 0)

    // let bytesPerRow = CVPixelBufferGetBytesPerRowOfPlane(imageBuffer, 0)

    // let lumaBuffer = CVPixelBufferGetBaseAddressOfPlane(imageBuffer, 0)

    //

    // let grayColorSpace = CGColorSpaceCreateDeviceGray()

    // let context = CGBitmapContextCreate(lumaBuffer, width, height, 8, bytesPerRow, grayColorSpace, CGBitmapInfo.allZeros)

    // let cgImage = CGBitmapContextCreateImage(context)

    

    var outputImage = CIImage(CVPixelBuffer: imageBuffer)

    

    if filter != nil {

        filter.setValue(outputImage, forKey: kCIInputImageKey)

        outputImage = filter.outputImage

    }

    

    let cgImage = context.createCGImage(outputImage, fromRect: outputImage.extent())

    

    dispatch_sync(dispatch_get_main_queue(), {

        self.previewLayer.contents = cgImage

    })

}

这是一段拓展性、维护性都比较好的代码了:

  1. 先拿到缓冲区,看从缓冲区直接取到一张CIImage
  2. 如果指定了滤镜,就应用到图像上;反之则显示原图
  3. 通过context创建CGImage的实例
  4. 在主队列中显示到layer上
在此基础上,我们只用添加一些滤镜就可以了。
先在Storyboard上添加一个UIView,再以这个UIView作容器,往里面加四个button,从0到3设置button的tag,并把button们的事件全部连接到VC的 applyFilter方法上,UI看起来像这样:

把这个UIView(buttons的容器)连接到VC的 filterButtonsContainer上,再添加一个字符串数组,存储一些滤镜的名字,最终VC的所有属性如下:

class ViewController: UIViewController , AVCaptureVideoDataOutputSampleBufferDelegate {

    @IBOutlet var filterButtonsContainer: UIView!

    var captureSession: AVCaptureSession!

    var previewLayer: CALayer!

    var filter: CIFilter!

    lazy var context: CIContext = {

        let eaglContext = EAGLContext(API: EAGLRenderingAPI.OpenGLES2)

        let options = [kCIContextWorkingColorSpace : NSNull()]

        return CIContext(EAGLContext: eaglContext, options: options)

    }()

    lazy var filterNames: [String] = {

        return ["CIColorInvert","CIPhotoEffectMono","CIPhotoEffectInstant","CIPhotoEffectTransfer"]

    }()

......

在viewDidLoad方法中先隐藏滤镜按钮们的容器:  

......

filterButtonsContainer.hidden = true

​......

修改 openCamera方法,最终实现如下:

@IBAction func openCamera(sender: UIButton) {

    sender.enabled = false

    captureSession.startRunning()

    self.filterButtonsContainer.hidden = false

}

最后 applyFilter方法的实现:

@IBAction func applyFilter(sender: UIButton) {

    var filterName = filterNames[sender.tag]

    filter = CIFilter(name: filterName)

}

至此,我们就大功告成了,赶紧在真机上编译、运行看看吧:



保存到图库

接下来我们添加拍照功能。
首先我们在VC上添加一个名为“拍照”的button,连接到VC的 takePicture方法上,在实现方法之前,有几步改造工作要先做完。
首先就是图像元数据的问题,一张图像可能包含定位信息、图像格式、方向等元数据,而方向是我们最关心的部分,在上面的viewDidLoad方法中,我是通过将previewLayer进行旋转使我们看到正确的图像,但是如果直接将图像保存在图库或文件中,我们会得到一个方向不正确的图像,为了最终获取方向正确的图像,我把previewLayer的旋转去掉:

......

previewLayer = CALayer()

// previewLayer.bounds = CGRectMake(0, 0, self.view.frame.size.height, self.view.frame.size.width);

// previewLayer.position = CGPointMake(self.view.frame.size.width / 2.0, self.view.frame.size.height / 2.0);

// previewLayer.setAffineTransform(CGAffineTransformMakeRotation(CGFloat(M_PI / 2.0)));

previewLayer.anchorPoint = CGPointZero

previewLayer.bounds = view.bounds

......

设置layer的anchorPoint是为了把bounds的顶点从中心变为左上角,这正是UIView的顶点。

现在你运行的话看到的将是方向不正确的图像。

然后我们把方向统一放到captureSession的回调中处理,修改之前写的实现:

......

var outputImage = CIImage(CVPixelBuffer: imageBuffer)

                    

let orientation = UIDevice.currentDevice().orientation

var t: CGAffineTransform!

if orientation == UIDeviceOrientation.Portrait {

    t = CGAffineTransformMakeRotation(CGFloat(-M_PI / 2.0))

else if orientation == UIDeviceOrientation.PortraitUpsideDown {

    t = CGAffineTransformMakeRotation(CGFloat(M_PI / 2.0))

else if (orientation == UIDeviceOrientation.LandscapeRight) {

    t = CGAffineTransformMakeRotation(CGFloat(M_PI))

else {

    t = CGAffineTransformMakeRotation(0)

}

outputImage = outputImage.imageByApplyingTransform(t)


if filter != nil {

    filter.setValue(outputImage, forKey: kCIInputImageKey)

    outputImage = filter.outputImage

}

......

在获取outputImage之后并在使用滤镜之前调整outputImage的方向,这样一下,四个方向都处理了。

运行之后看到的效果和之前就一样了。

方向处理完后我们还要用一个实例变量保存这个outputImage,因为这里面含有图像的元数据,我们不会丢弃它:

给VC添加一个CIImage的属性: 

var ciImage: CIImage!

在captureSession的回调里保存CIImage:

......

if filter != nil {

    filter.setValue(outputImage, forKey: kCIInputImageKey)

    outputImage = filter.outputImage

}


let cgImage = context.createCGImage(outputImage, fromRect: outputImage.extent())

ciImage = outputImage

......

滤镜处理完后,就将这个CIImage存起来,它可能被应用过滤镜,也可能是干干净净的原图。

最后是takePicture的方法实现:

@IBAction func takePicture(sender: UIButton) {

    sender.enabled = false

    captureSession.stopRunning()


    var cgImage = context.createCGImage(ciImage, fromRect: ciImage.extent())

    ALAssetsLibrary().writeImageToSavedPhotosAlbum(cgImage, metadata: ciImage.properties())

        { (url: NSURL!, error :NSError!) -> Void in

            if error == nil {

                println("保存成功")

                println(url)

            } else {

                let alert = UIAlertView(title: "错误"

      message: error.localizedDescription

     delegate: nil

    cancelButtonTitle: "确定")

                alert.show()

            }

            self.captureSession.startRunning()

            sender.enabled = true

    }

} 

先将按钮禁用,session停止运行,再用实例变量ciImage绘制一张CGImage,最后连同元数据一同存进图库中。

这里需要导入AssetsLibrary库:import AssetsLibrary。writeImageToSavedPhotosAlbum方法的回调
block用到了尾随闭包语法。

在真机上编译、运行看看吧。

注:由于我是用layer来做预览容器的,它没有autoresizingMask这样的属性,你会发现横屏的时候就显示不正常了,在iOS 8gh,你可以通过重写VC的以下方法来兼容横屏:

override func viewWillTransitionToSize(size: CGSize, withTransitionCoordinator 

coordinator: UIViewControllerTransitionCoordinator) {

    previewLayer.bounds.size = size

}



录制视频


前期配置

这篇文章并不会详解AVFoundation框架,但为了完成Core Image的功能,我们多多少少会说一些。
我们在VC上添加一个名为“开始录制”的按钮,把按钮本身连接到VC的 recordsButton属性上,并把它的事件连接到 record方法上,UI看起来像这样:

为了愉快地进行下去,我先把为VC新增的所有属性列出来:

......

// Video Records

@IBOutlet var recordsButton: UIButton!

var assetWriter: AVAssetWriter?

var assetWriterPixelBufferInput: AVAssetWriterInputPixelBufferAdaptor?

var isWriting = false

var currentSampleTime: CMTime?

var currentVideoDimensions: CMVideoDimensions?

......

这些就是为了实现视频录制会用到的所有属性,我们简单说一下:
  • recordsButton,为了方便的获取录制按钮的实例而增加的属性
  • assetWriter,这是一个AVAssetWriter对象的实例,这个类的工作方式很像AVCaptureSession,也是为了控制输入输出的流程而存在的
  • assetWriterPixelBufferInput,一个AVAssetWriterInputPixelBufferAdaptor对象,这个属性的作用如同它的名字,它允许我们不断地增加像素缓冲区到assetWriter对象里
  • isWriting,如果我们当前正在录制视频,则会用这个实例变量记录下来
  • currentSampleTime,这是一个时间戳,在AVFoundation框架里,每一块添加的数据(视频或音频等)除了data部分外,还需要一个当前的时间,每一帧的时间都不同,这就形成了每一帧的持续时间(时间间隔)
  • currentVideoDimensions,这个属性描述了视频尺寸,虽然这个属性并不重要,但是我更加懒得把尺寸写死,它的单位是像素
接下来我们先完成两个工具方法: movieURLcheckForAndDeleteFile

func movieURL() -> NSURL {

    var tempDir = NSTemporaryDirectory()

    let urlString = tempDir.stringByAppendingPathComponent("tmpMov.mov")

    return NSURL(fileURLWithPath: urlString)

}

这个方法做的事情很简单,只是构建一个临时目录里的文件URL。

func checkForAndDeleteFile() {

    let fm = NSFileManager.defaultManager()

    var url = movieURL()

    let exist = fm.fileExistsAtPath(movieURL().path!)

    

    var error: NSError?

    if exist {

        fm.removeItemAtURL(movieURL(), error: &error)

        println("删除之前的临时文件")

        if let errorDescription = error?.localizedDescription {

            println(errorDescription)

        }

    }

}

这个方法检查了文件是否已存在,如果已存在就删除旧文件,之所以要增加这个方法是因为AVAssetWriter不能在已有的文件URL上写文件,如果文件已存在就会报错。还有一点需要注意: 我在iOS 7上判断文件是否存在时用的是URL的absoluteString方法,结果导致AVAssetWriter没报错,但是后面的缓冲区出错了,排查了很久,把absoluteString换成path就好了。。
二个工具方法完成后,我们就开始写最主要的方法,即 createWriter方法:

func createWriter() {

    self.checkForAndDeleteFile()

    

    var error: NSError?

    assetWriter = AVAssetWriter(URL: movieURL(), fileType: AVFileTypeQuickTimeMovie, error: &error)

    if let errorDescription = error?.localizedDescription {

        println("创建writer失败")

        println(errorDescription)

        return

    }


    let outputSettings = [

        AVVideoCodecKey : AVVideoCodecH264,

        AVVideoWidthKey : Int(currentVideoDimensions!.width),

        AVVideoHeightKey : Int(currentVideoDimensions!.height)

    ]

    let assetWriterVideoInput = AVAssetWriterInput(mediaType: AVMediaTypeVideo, outputSettings: outputSettings)


    assetWriterVideoInput.expectsMediaDataInRealTime = true

    assetWriterVideoInput.transform = CGAffineTransformMakeRotation(CGFloat(M_PI / 2.0))


    let sourcePixelBufferAttributesDictionary = [

        kCVPixelBufferPixelFormatTypeKey : kCVPixelFormatType_32BGRA,

        kCVPixelBufferWidthKey : Int(currentVideoDimensions!.width),

        kCVPixelBufferHeightKey : Int(currentVideoDimensions!.height),

        kCVPixelFormatOpenGLESCompatibility : kCFBooleanTrue

    ]

    assetWriterPixelBufferInput = AVAssetWriterInputPixelBufferAdaptor(assetWriterInput: assetWriterVideoInput,

                                            sourcePixelBufferAttributes: sourcePixelBufferAttributesDictionary)

    

    if assetWriter!.canAddInput(assetWriterVideoInput) {

        assetWriter!.addInput(assetWriterVideoInput)

    } else {

        println("不能添加视频writerinput \(assetWriterVideoInput)")

    }

} 

这个方法主要是配置项很多。
  • 首先检查了文件是否存在,如果存在的话就删除旧的临时文件,不然AVAssetWriter会因无法写入文件而报错
  • 实例化一个AVAssetWriter对象,把需要写的文件URL和文件类型传递给它,再给它一个存储错误信息的指针,方便在出错的时候排查
  • 创建一个outputSettings的字典应用到AVAssetWriterInput对象上,这个对象之前没有提到,但也是相当重要的一个对象,它表示了一个输入设备,比如视频、音频的输入等,不同的设备拥有不同的参数和配置,并不复杂,我们这里就不考虑音频输入了。在这个视频的配置里,我们配置了视频的编码,以及用获取到的当前视频设备尺寸(单位像素)初始化了宽、高
  • 设置expectsMediaDataInRealTime为true,这是从摄像头捕获的源中进行实时编码的必要参数
  • 设置了视频的transform,主要也是为了解决方向问题
  • 创建另外一个属性字典去实例化一个AVAssetWriterInputPixelBufferAdaptor对象,我们在视频采集的过程中,会不断地通过这个缓冲区往AVAssetWriter对象里添加内容,实例化的参数中还有AVAssetWriterInput对象,属性字典标识了缓冲区的大小与格式。
  • 最后判断一下能否添加这个输入设备,虽然大多数情况下判断一定为真,而且为假的情况我们也没办法考虑了,但预先判断还是一个好的编码习惯

处理每一帧

上面这些基本性的配置工作完成后,在正式开始录制视频之前,我们还有最后一步要处理,那就是处理视频的每一帧。其实在之前我们就已经尝试过处理每一帧了,因为我们做过拍照的实时滤镜功能,现在我们只需要修改AVCaptureSession的回调就行了。由于之前在captureOutput:didOutputSampleBuffer:这个回调方法中,我们是先对图像的方向进行处理,然后再对其应用滤镜,而录制视频的时候我们不需要对方向进行处理,因为在配置AVAssetWriterInput对象的时候我们已经处理过了,所以我们先将应用滤镜和方向调整的代码互换一下,变成先应用滤镜,再处理方向,然后在他们中间插入处理录制视频的代码:

......

if self.filter != nil {

    self.filter.setValue(outputImage, forKey: kCIInputImageKey)

    outputImage = self.filter.outputImage

}


// 处理录制视频

let formatDescription = CMSampleBufferGetFormatDescription(sampleBuffer)

self.currentVideoDimensions = CMVideoFormatDescriptionGetDimensions(formatDescription)

self.currentSampleTime = CMSampleBufferGetOutputPresentationTimeStamp(sampleBuffer)

if self.isWriting {

    if self.assetWriterPixelBufferInput?.assetWriterInput.readyForMoreMediaData == true {

        var newPixelBuffer: Unmanaged<CVPixelBuffer>? = nil

        CVPixelBufferPoolCreatePixelBuffer(nilself.assetWriterPixelBufferInput?.pixelBufferPool, &newPixelBuffer)

        

        self.context.render(outputImage,

                            toCVPixelBuffer: newPixelBuffer?.takeUnretainedValue(),

                            bounds: outputImage.extent(),

                            colorSpace: nil)

        

        let success = self.assetWriterPixelBufferInput?.appendPixelBuffer(newPixelBuffer?.takeUnretainedValue(),

                                                                          withPresentationTime: self.currentSampleTime!)

        

        newPixelBuffer?.autorelease()

        

        if success == false {

            println("Pixel Buffer没有append成功")

        }

    }

}


let orientation = UIDevice.currentDevice().orientation

var t: CGAffineTransform!

......

在对图像应用完滤镜之后,我们做了这些事情:
  1. 获取尺寸和时间,这两个值在后面会用到。强调一下,时间这个参数是很重要的,当你有一系列的帧的时候,assetWriter必须知道何时显示他们,我们除了通过CMSampleBufferGetOutputPresentationTimeStamp函数获取之外,也可以手动创建一个时间,比如把每个缓冲区的时间设置为比上一个缓冲区时间多1/30秒,这就相当于创建一个每秒30帧的视频,但是这不能保证视频时序的真实情况,因为某些滤镜(或者其他操作)可能会耗时过长
  2. 当前是否需要录制视频,录制视频其实就是写文件的一个过程
  3. 判断assetWriter是否已经准备好输入数据了
  4. 一切都准备好后,我们就先配置一个缓冲区。用CVPixelBufferPoolCreatePixelBuffer函数能创建基于池的缓冲区,它的好处是在创建缓冲区的时候会把之前对assetWriterPixelBufferInput对象的配置项应用到新的缓冲区上,这样就避免了你重新对新的缓冲区进行配置。有一点需要注意,如果我们的assetWriter还未开始工作,那么当我们调用assetWriterPixelBufferInput的pixelBufferPool时候会得到一个空指针,缓冲区当然也就创建不了了
  5. 我们把缓冲区准备好后,就利用context把图像渲染到里面
  6. 把缓冲区写入到临时文件中,同时得到是否写入成功的返回值
  7. 由于在Swift里CVPixelBufferPoolCreatePixelBuffer函数需要的是一个手动管理引用计数的对象(Unmanaged对象),所以需要自己把它处理一下
  8. 如果第6步失败的话就输出一下
之前的代码还是保留,因为我们还是需要将每一帧绘制到屏幕上。
由于这个方法用到了很多对象,而且比较占用内存,所以我在进入这个方法的时候还手动增加了自动释放池:

autoreleasepool {

    // ....

} 


保存视频到图库

我们之前就加入了recordsButton,并把它连接到了record方法上,现在来实现它:

@IBAction func record() {

    if isWriting {

        self.isWriting = false

        assetWriterPixelBufferInput = nil

        recordsButton.enabled = false

        assetWriter?.finishWritingWithCompletionHandler({[unowned self] () -> Void in

            println("录制完成")

            self.recordsButton.setTitle("处理中...", forState: UIControlState.Normal)

            self.saveMovieToCameraRoll()

        })

    } else {

        createWriter()

        recordsButton.setTitle("停止录制...", forState: UIControlState.Normal)

        assetWriter?.startWriting()

        assetWriter?.startSessionAtSourceTime(currentSampleTime!)

        isWriting = true

    }

}

首先是不是在录制,如果是的话就停止录制、保存视频,并清理资源。
如果还没有开始录制,就创建AVAssetWriter并配置好,然后调用startWriting方法使 assetWriter开始工作,不然在回调里取pixelBufferPool的时候取不到,除此之外,还要调用 startSessionAtSourceTime方法,调用后者是为了在回调中拿到最新的时间,即currentSampleTime。如果不调用这两个方法,在appendPixelBuffer的时候就会有问题,就算最后能保存,也只能得到一个空的视频文件。
当视频录制的过程开始后,就只有调用 finishWriting方法才能停止,我们通过 saveMovieToCameraRoll方法把视频写入到图库中,不然这视频也就没机会展示了:

func saveMovieToCameraRoll() {

    ALAssetsLibrary().writeVideoAtPathToSavedPhotosAlbum(movieURL(), completionBlock: { (url: NSURL!, error: NSError?) -> Void in

        if let errorDescription = error?.localizedDescription {

            println("写入视频错误:\(errorDescription)")

        } else {

            self.checkForAndDeleteFile()

            println("写入视频成功")

        }

        self.recordsButton.enabled = true

        self.recordsButton.setTitle("开始录制", forState: UIControlState.Normal)

    })

} 

之前在拍照并保存的时候,我们使用了 尾随闭包语法,这里使用的是完整语法的闭包。

保存成功后就可以删除临时文件了。

编译、运行吧:




局部滤镜

上面的滤镜都是对整张图像应用滤镜,我们也可以只对部分区域应用滤镜,例如把滤镜应用到视频中的面部上。不同于 上一篇,AVFoundation框架内置了检测人脸的功能,所以我们不需要使用CIDetector。

标记人脸

我们先简单的用一个Layer把人脸的区域标记出来,给VC增加一个属性:

// 标记人脸

var faceLayer: CALayer?

修改setupCaptureSession方法,在captureSession调用commitConfiguration方法之前加入以下代码:

......

// 为了检测人脸

let metadataOutput = AVCaptureMetadataOutput()

metadataOutput.setMetadataObjectsDelegate(self, queue: dispatch_get_main_queue())


if captureSession.canAddOutput(metadataOutput) {

    captureSession.addOutput(metadataOutput)

    println(metadataOutput.availableMetadataObjectTypes)

    metadataOutput.metadataObjectTypes = [AVMetadataObjectTypeFace]

}

......

这里加入了一个元数据的output对象,添加到captureSession后我们就能在回调中得到图像的元数据,包括检测到的人脸。给metadataObjectTypes属性赋值是为了申明要检测的类型,这句要在增加到captureSession之后调用。因为我们要在回调中直接操作Layer的显示,所以我把回调放在主队列中。
实现AVCaptureMetadataOutput的回调方法:

// MARK: - AVCaptureMetadataOutputObjectsDelegate

func captureOutput(captureOutput: AVCaptureOutput!, didOutputMetadataObjects metadataObjects: [AnyObject]!, fromConnection connection: AVCaptureConnection!) {

    // println(metadataObjects)

    if metadataObjects.count > 0 {

        //识别到的第一张脸

        var faceObject = metadataObjects.first as AVMetadataFaceObject

        

        if faceLayer == nil {

            faceLayer = CALayer()

            faceLayer?.borderColor = UIColor.redColor().CGColor

            faceLayer?.borderWidth = 1

            view.layer.addSublayer(faceLayer)

        }

        let faceBounds = faceObject.bounds

        let viewSize = view.bounds.size


        faceLayer?.position = CGPoint(x: viewSize.width * (1 - faceBounds.origin.y - faceBounds.size.height / 2),

                                      y: viewSize.height * (faceBounds.origin.x + faceBounds.size.width / 2))

        

        faceLayer?.bounds.size = CGSize(width: faceBounds.size.width * viewSize.height,

                                        height: faceBounds.size.height * viewSize.width)

        print(faceBounds.origin)

        print("###")

        print(faceLayer!.position)

        print("###")

        print(faceLayer!.bounds)

    }

} 

简单说明下上述代码的作用:
  1. 参数中的metadataObjects数组就是AVFoundation框架给我们的关于图像的所有元数据,由于我只设置了需要人脸检测,所以简单判断是否为空后,取出其中的数据即可。在这里我只对第一张脸进行了处理
  2. 接下来初始化Layer,并设置边框
  3. 取到的faceObject对象虽然包含了bounds属性,但并不能直接使用,因为从AVFoundation视频中取到的bounds,是一个0~1之间的数,是相对于图像的百分比,所以我们在设置position时,做了两步:把x、y颠倒,修正方向等问题,我只是简单地适配了Portrait方向,此处能达到目的即可。再和view的宽、高相乘,其实是和Layer的父Layer的宽、高相乘。
  4. 设置size也如上
做的事情比较简单,只是单纯地初始化一个Layer,然后不停地修改它的postion和size就行了。
编译、运行后应该能看到如下效果:




使用滤镜

上面用Layer只是简单的先显示一下人脸的区域,我们没有调整图像输出时的CIImage,所以并不能被录制到视频或被保存图片到图库中。
接下来我们就修改之前的代码,使其能同时支持整体滤镜和部分滤镜。
首先把VC中记录的属性改一下:  

......

// 标记人脸

// var faceLayer: CALayer?

var faceObject: AVMetadataFaceObject?

......

我们就不用Layer作人脸范围的标记了,而是直接把滤镜应用到输出的CIImage上,为此,我们需要在AVCaptureMetadataOutput对象的delegate回调方法中记录识别到的脸部元数据:

// MARK: - AVCaptureMetadataOutputObjectsDelegate

func captureOutput(captureOutput: AVCaptureOutput!, didOutputMetadataObjects metadataObjects: [AnyObject]!, fromConnection connection: AVCaptureConnection!) {

    // println(metadataObjects)

    if metadataObjects.count > 0 {

        //识别到的第一张脸

        faceObject = metadataObjects.first asAVMetadataFaceObject

        

        /*

        if faceLayer == nil {

            faceLayer = CALayer()

            faceLayer?.borderColor = UIColor.redColor().CGColor

            faceLayer?.borderWidth = 1

            view.layer.addSublayer(faceLayer)

        }

        let faceBounds = faceObject.bounds

        let viewSize = view.bounds.size


        faceLayer?.position = CGPoint(x: viewSize.width * (1 - faceBounds.origin.y - faceBounds.size.height / 2),

                                      y: viewSize.height * (faceBounds.origin.x + faceBounds.size.width / 2))

        

        faceLayer?.bounds.size = CGSize(width: faceBounds.size.height * viewSize.width,

                                        height: faceBounds.size.width * viewSize.height)

        print(faceBounds.origin)

        print("###")

        print(faceLayer!.position)

        print("###")

        print(faceLayer!.bounds)

        */

    }

} 

之前的Layer相关代码都注释掉,只简单地把识别到的第一张脸记录在VC的属性中。
然后修改AVCaptureSession的delegate回调,在录制视频的代码之前,全局滤镜的代码之后,添加脸部处理代码:

......

if self.filter != nil {    // 之前做的全局滤镜 

    self.filter.setValue(outputImage, forKey: kCIInputImageKey)

    outputImage = self.filter.outputImage

}

if self.faceObject != nil {    // 脸部处理

    outputImage = self.makeFaceWithCIImage(outputImage, faceObject: self.faceObject!)

}

...... 

我们写了个makeFaceWithImage的方法来专门为脸部应用滤镜,应用的效果是 上一篇中提到的马赛克效果。
makeFaceWithCIImage的方法实现:

func makeFaceWithCIImage(inputImage: CIImage, faceObject: AVMetadataFaceObject) -> CIImage {

    var filter = CIFilter(name: "CIPixellate")

    filter.setValue(inputImage, forKey: kCIInputImageKey)

    // 1.

    filter.setValue(max(inputImage.extent().size.width, inputImage.extent().size.height/ 60, forKey: kCIInputScaleKey)

    

    let fullPixellatedImage = filter.outputImage

    var maskImage: CIImage!

    let faceBounds = faceObject.bounds

    

    // 2.

    let centerX = inputImage.extent().size.width * (faceBounds.origin.x + faceBounds.size.width / 2)

    let centerY = inputImage.extent().size.height * (1 - faceBounds.origin.y - faceBounds.size.height / 2)

    let radius = faceBounds.size.width * inputImage.extent().size.width / 2

    let radialGradient = CIFilter(name: "CIRadialGradient",

        withInputParameters: [

            "inputRadius0" : radius,

            "inputRadius1" : radius + 1,

            "inputColor0" : CIColor(red: 0, green: 1, blue: 0, alpha: 1),

            "inputColor1" : CIColor(red: 0, green: 0, blue: 0, alpha: 0),

            kCIInputCenterKey : CIVector(x: centerX, y: centerY)

        ])


    let radialGradientOutputImage = radialGradient.outputImage.imageByCroppingToRect(inputImage.extent())

    if maskImage == nil {

        maskImage = radialGradientOutputImage

    } else {

        println(radialGradientOutputImage)

        maskImage = CIFilter(name: "CISourceOverCompositing",

            withInputParameters: [

                kCIInputImageKey : radialGradientOutputImage,

                kCIInputBackgroundImageKey : maskImage

            ]).outputImage

    }

    

    let blendFilter = CIFilter(name: "CIBlendWithMask")

    blendFilter.setValue(fullPixellatedImage, forKey: kCIInputImageKey)

    blendFilter.setValue(inputImage, forKey: kCIInputBackgroundImageKey)

    blendFilter.setValue(maskImage, forKey: kCIInputMaskImageKey)

    

    return blendFilter.outputImage

} 

这上面的代码基本是复制 上一篇里的代码,改的地方只有两处:
  1. 把马赛克的效果变大,kCIInputScaleKey默认值为0.5,你可以把这行代码注释掉后看效果
  2. 计算脸部的中心点和半径,计算方法和之前didOutputMetadataObjects这个delegate回调中的计算方法一样,复制过来就行了
如果你看到我的上一篇《 iOS8 Core Image In Swift:人脸检测以及马赛克》的话,这里面的实现方式应该就很清楚了。
到此,对脸部的滤镜也处理好了,编译、运行,可以得到这样的结果:




GitHub下载地址

我在GitHub上会保持更新。


参考资料:

1. http://weblog.invasivecode.com/post/18445861158/a-very-cool-custom-video-camera-with

2. https://developer.apple.com/library/mac/documentation/graphicsimaging/conceptual/CoreImaging/ci_intro/ci_intro.html

3. http://en.wikipedia.org/wiki/YUV


相关实践学习
部署Stable Diffusion玩转AI绘画(GPU云服务器)
本实验通过在ECS上从零开始部署Stable Diffusion来进行AI绘画创作,开启AIGC盲盒。
目录
相关文章
|
3月前
|
Swift iOS开发
iOS Swift使用Alamofire请求本地服务器报错-1002
iOS Swift使用Alamofire请求本地服务器报错-1002
89 1
|
4月前
|
Unix 调度 Swift
苹果iOS新手开发之Swift 中获取时间戳有哪些方式?
在Swift中获取时间戳有四种常见方式:1) 使用`Date`对象获取秒级或毫秒级时间戳;2) 通过`CFAbsoluteTimeGetCurrent`获取Core Foundation的秒数,需转换为Unix时间戳;3) 使用`DispatchTime.now()`获取纳秒级精度的调度时间点;4) `ProcessInfo`提供设备启动后的秒数,不表示绝对时间。不同方法适用于不同的精度和场景需求。
131 3
|
18小时前
|
安全 数据处理 Swift
深入探索iOS开发中的Swift语言特性
本文旨在为开发者提供对Swift语言在iOS平台开发的深度理解,涵盖从基础语法到高级特性的全面分析。通过具体案例和代码示例,揭示Swift如何简化编程过程、提高代码效率,并促进iOS应用的创新。文章不仅适合初学者作为入门指南,也适合有经验的开发者深化对Swift语言的认识。
15 9
|
14天前
|
安全 API Swift
探索iOS开发中的Swift语言之美
【10月更文挑战第23天】在数字时代的浪潮中,iOS开发如同一艘航船,而Swift语言则是推动这艘船前进的风帆。本文将带你领略Swift的独特魅力,从语法到设计哲学,再到实际应用案例,我们将一步步深入这个现代编程语言的世界。你将发现,Swift不仅仅是一种编程语言,它是苹果生态系统中的一个创新工具,它让iOS开发变得更加高效、安全和有趣。让我们一起启航,探索Swift的奥秘,感受编程的乐趣。
|
1月前
|
安全 Swift iOS开发
探索iOS开发中的Swift语言之美
在数字时代的浪潮中,移动应用已成为日常生活的延伸。本文将深入探讨iOS平台上的Swift编程语言,揭示其背后的设计哲学、语法特性以及如何利用Swift进行高效开发。我们将通过实际代码示例,展示Swift语言的强大功能和优雅简洁的编程风格,引导读者理解并运用Swift解决实际问题。
|
2月前
|
安全 Swift iOS开发
探索iOS开发之旅:Swift语言的魅力与挑战
【9月更文挑战第21天】在这篇文章中,我们将一起潜入iOS开发的海洋,探索Swift这门现代编程语言的独特之处。从简洁的语法到强大的功能,Swift旨在让开发者能够以更高效、更安全的方式构建应用程序。通过实际代码示例,我们会深入了解Swift如何简化复杂任务,并讨论它面临的挑战和未来的发展方向。无论你是初学者还是有经验的开发者,这篇文章都将为你提供新的视角和知识。
44 4
|
2月前
|
安全 编译器 Swift
探索iOS开发之旅:Swift编程语言的魅力与挑战
【9月更文挑战第5天】在iOS应用开发的广阔天地中,Swift作为苹果官方推荐的编程语言,以其简洁、高效和安全的特点,成为了开发者的新宠。本文将带领你领略Swift语言的独特魅力,同时探讨在实际开发过程中可能遇到的挑战,以及如何克服这些挑战,成为一名优秀的iOS开发者。
|
2月前
|
设计模式 前端开发 Swift
探索iOS开发:Swift与Objective-C的较量
在这篇文章中,我们将深入探讨iOS开发的两大编程语言——Swift与Objective-C。我们将分析这两种语言的特性、优势和局限性,并讨论它们在现代iOS开发中的应用。无论你是初学者还是有经验的开发者,这篇文章都将为你提供有价值的见解和建议。
55 3
|
3月前
|
测试技术 Swift iOS开发
探索iOS自动化测试:使用Swift编写UI测试
【8月更文挑战第31天】在软件开发的海洋中,自动化测试是保证船只不偏离航线的灯塔。本文将带领读者启航,深入探索iOS应用的自动化UI测试。我们将通过Swift语言,点亮代码的灯塔,照亮测试的道路。文章不仅会展示如何搭建测试环境,还会提供实用的代码示例,让理论知识在实践中生根发芽。无论你是新手还是有经验的开发者,这篇文章都将是你技能提升之旅的宝贵指南。
|
3月前
|
移动开发 安全 Swift
探索iOS开发:从零开始的Swift之旅
【8月更文挑战第31天】本文将带你开启一段Swift编程语言的奇幻旅程,通过简单易懂的方式介绍Swift的基本概念和编程实践。我们将一起构建一个简单的iOS应用,体验从代码到界面的转变。无论你是编程新手还是希望扩展技能的开发者,这篇文章都会为你提供宝贵的知识和启发。