在這篇文章中,我們將研究如何將 Core Image 應(yīng)用到實時視頻上去。我們會看兩個例子:首先,我們把這個效果加到相機(jī)拍攝的影片上去。之后,我們會將這個影響作用于拍攝好的視頻文件。它也可以做到離線渲染,它會把渲染結(jié)果返回給視頻,而不是直接顯示在屏幕上。兩個例子的完整源代碼,請點擊這里。
當(dāng)涉及到處理視頻的時候,性能就會變得非常重要。而且了解黑箱下的原理 —— 也就是 Core Image 是如何工作的 —— 也很重要,這樣我們才能達(dá)到足夠的性能。在 GPU 上面做盡可能多的工作,并且最大限度的減少 GPU 和 CPU 之間的數(shù)據(jù)傳送是非常重要的。之后的例子中,我們將看看這個細(xì)節(jié)。
想對 Core Image 有個初步認(rèn)識的話,可以讀讀 Warren 的這篇文章 Core Image 介紹。我們將使用 Swift 的函數(shù)式 API 中介紹的基于 CIFilter
的 API 封裝。想要了解更多關(guān)于 AVFoundation 的知識,可以看看本期話題中 Adriaan 的文章,還有話題 #21 中的 iOS 上的相機(jī)捕捉。
CPU 和 GPU 都可以運行 Core Image,我們將會在 下面 詳細(xì)介紹這兩個的細(xì)節(jié)。在這個例子中,我們要使用 GPU,我們做如下幾樣事情。
我們首先創(chuàng)建一個自定義的 UIView
,它允許我們把 Core Image 的結(jié)果直接渲染成 OpenGL。我們可以新建一個 GLKView
并且用一個 EAGLContext
來初始化它。我們需要指定 OpenGL ES 2 作為渲染 API,在這兩個例子中,我們要自己觸發(fā) drawing 事件 (而不是在 -drawRect:
中觸發(fā)),所以在初始化 GLKView 的時候,我們將 enableSetNeedsDisplay
設(shè)置為 false。之后我們有可用新圖像的時候,我們需要主動去調(diào)用 -display
。
在這個視圖里,我們保持一個對 CIContext
的引用,它提供一個橋梁來連接我們的 Core Image 對象和 OpenGL 上下文。我們創(chuàng)建一次就可以一直使用它。這個上下文允許 Core Image 在后臺做優(yōu)化,比如緩存和重用紋理之類的資源等。重要的是這個上下文我們一直在重復(fù)使用。
上下文中有一個方法,-drawImage:inRect:fromRect:
,作用是繪制出來一個 CIImage
。如果你想畫出來一個完整的圖像,最容易的方法是使用圖像的 extent
。但是請注意,這可能是無限大的,所以一定要事先裁剪或者提供有限大小的矩形。一個警告:因為我們處理的是 Core Image,繪制的目標(biāo)以像素為單位,而不是點。由于大部分新的 iOS 設(shè)備配備 Retina 屏幕,我們在繪制的時候需要考慮這一點。如果我們想填充整個視圖,最簡單的辦法是獲取視圖邊界,并且按照屏幕的 scale 來縮放圖片 (Retina 屏幕的 scale 是 2)。
完整的代碼示例在這里:CoreImageView.swift
對于 AVFoundation 如何工作的概述,請看 Adriaan 的文章 和 Matteo 的文章 iOS 上的相機(jī)捕捉。對于我們而言,我們想從鏡頭獲得 raw 格式的數(shù)據(jù)。我們可以通過創(chuàng)建一個 AVCaptureDeviceInput
對象來選定一個攝像頭。使用 AVCaptureSession
,我們可以把它連接到一個 AVCaptureVideoDataOutput
。這個 data output 對象有一個遵守 AVCaptureVideoDataOutputSampleBufferDelegate
協(xié)議的代理對象。這個代理每一幀將接收到一個消息:
func captureOutput(captureOutput: AVCaptureOutput!,
didOutputSampleBuffer: CMSampleBuffer!,
fromConnection: AVCaptureConnection!) {
我們將用它來驅(qū)動我們的圖像渲染。在我們的示例代碼中,我們已經(jīng)將配置,初始化以及代理對象都打包到了一個叫做 CaptureBufferSource
的簡單接口中去。我們可以使用前置或者后置攝像頭以及一個回調(diào)來初始化它。對于每個樣本緩存區(qū),這個回調(diào)都會被調(diào)用,并且參數(shù)是緩沖區(qū)和對應(yīng)攝像頭的 transform:
source = CaptureBufferSource(position: AVCaptureDevicePosition.Front) {
(buffer, transform) in
...
}
我們需要對相機(jī)返回的數(shù)據(jù)進(jìn)行變換。無論你如何轉(zhuǎn)動 iPhone,相機(jī)的像素數(shù)據(jù)的方向總是相同的。在我們的例子中,我們將 UI 鎖定在豎直方向,我們希望屏幕上顯示的圖像符合照相機(jī)拍攝時的方向,為此我們需要后置攝像頭拍攝出的圖片旋轉(zhuǎn) -π/2。前置攝像頭需要旋轉(zhuǎn) -π/2 并且加一個鏡像效果。我們可以用一個 CGAffineTransform
來表達(dá)這種變換。請注意如果 UI 是不同的方向 (比如橫屏),我們的變換也將是不同的。還要注意,這種變換的代價其實是非常小的,因為它是在 Core Image 渲染管線中完成的。
接著,要把 CMSampleBuffer
轉(zhuǎn)換成 CIImage
,我們首先需要將它轉(zhuǎn)換成一個 CVPixelBuffer
。我們可以寫一個方便的初始化方法來為我們做這件事:
extension CIImage {
convenience init(buffer: CMSampleBuffer) {
self.init(CVPixelBuffer: CMSampleBufferGetImageBuffer(buffer))
}
}
現(xiàn)在我們可以用三個步驟來處理我們的圖像。首先,把我們的 CMSampleBuffer
轉(zhuǎn)換成 CIImage
,并且應(yīng)用一個形變,使圖像旋轉(zhuǎn)到正確的方向。接下來,我們用一個 CIFilter
濾鏡來得到一個新的 CIImage
輸出。我們使用了 Florian 的文章 提到的創(chuàng)建濾鏡的方式。在這個例子中,我們使用色調(diào)調(diào)整濾鏡,并且傳入一個依賴于時間而變化的調(diào)整角度。最終,我們使用之前定義的 View,通過 CIContext
來渲染 CIImage
。這個流程非常簡單,看起來是這樣的:
source = CaptureBufferSource(position: AVCaptureDevicePosition.Front) {
[unowned self] (buffer, transform) in
let input = CIImage(buffer: buffer).imageByApplyingTransform(transform)
let filter = hueAdjust(self.angleForCurrentTime)
self.coreImageView?.image = filter(input)
}
當(dāng)你運行它時,你可能會因為如此低的 CPU 使用率感到吃驚。這其中的奧秘是 GPU 做了幾乎所有的工作。盡管我們創(chuàng)建了一個 CIImage
,應(yīng)用了一個濾鏡,并輸出一個 CIImage
,最終輸出的結(jié)果是一個 promise:直到實際渲染才會去進(jìn)行計算。一個 CIImage
對象可以是黑箱里的很多東西,它可以是 GPU 算出來的像素數(shù)據(jù),也可以是如何創(chuàng)建像素數(shù)據(jù)的一個說明 (比如使用一個濾鏡生成器),或者它也可以是直接從 OpenGL 紋理中創(chuàng)建出來的圖像。
下面是演示視頻
我們可以做的另一件事是通過 Core Image 把這個濾鏡加到一個視頻中。和實時拍攝不同,我們現(xiàn)在從影片的每一幀中生成像素緩沖區(qū),在這里我們將采用略有不同的方法。對于相機(jī),它會推送每一幀給我們,但是對于已有的影片,我們使用拉取的方式:通過 display link,我們可以向 AVFoundation 請求在某個特定時間的一幀。
display link 對象負(fù)責(zé)在每幀需要繪制的時候給我們發(fā)送消息,這個消息是按照顯示器的刷新頻率同步進(jìn)行發(fā)送的。這通常用來做 自定義動畫,但也可以用來播放和操作視頻。我們要做的第一件事就是創(chuàng)建一個 AVPlayer
和一個視頻輸出:
player = AVPlayer(URL: url)
videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferDict)
player.currentItem.addOutput(videoOutput)
接下來,我們要創(chuàng)建 display link。方法很簡單,只要創(chuàng)建一個 CADisplayLink
對象,并將其添加到 run loop。
let displayLink = CADisplayLink(target: self, selector: "displayLinkDidRefresh:")
displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)
現(xiàn)在,唯一剩下的就是在 displayLinkDidRefresh:
調(diào)用的時候獲取視頻每一幀。首先,我們獲取當(dāng)前的時間,并且將它轉(zhuǎn)換成當(dāng)前播放項目里的時間比。然后我們詢問 videoOutput
,如果當(dāng)前時間有一個可用的新的像素緩存區(qū),我們把它復(fù)制一下并且調(diào)用回調(diào)方法:
func displayLinkDidRefresh(link: CADisplayLink) {
let itemTime = videoOutput.itemTimeForHostTime(CACurrentMediaTime())
if videoOutput.hasNewPixelBufferForItemTime(itemTime) {
let pixelBuffer = videoOutput.copyPixelBufferForItemTime(itemTime, itemTimeForDisplay: nil)
consumer(pixelBuffer)
}
}
我們從一個視頻輸出獲得的像素緩沖是一個 CVPixelBuffer
,我們可以把它直接轉(zhuǎn)換成 CIImage
。正如上面的例子,我們會加上一個濾鏡。在這個例子里,我們將組合多個濾鏡:我們使用一個萬花筒的效果,然后用漸變遮罩把原始圖像和過濾圖像相結(jié)合,這個操作是非常輕量級的。
大家都知道流行的照片效果。雖然我們可以將這些應(yīng)用到視頻,但 Core Image 還可以做得更多。
Core Image 里所謂的濾鏡有不同的類別。其中一些是傳統(tǒng)的類型,輸入一張圖片并且輸出一張新的圖片。但有些需要兩個 (或者更多) 的輸入圖像并且混合生成一張新的圖像。另外甚至有完全不輸入圖片,而是基于參數(shù)的生成圖像的濾鏡。
通過混合這些不同的類型,我們可以創(chuàng)建意想不到的效果。
在這個例子中,我們使用這些東西:
http://wiki.jikexueyuan.com/project/objc/images/23-5.svg" alt="" />
上面的例子可以將圖像的一個圓形區(qū)域像素化。
它也可以創(chuàng)建交互,我們可以使用觸摸事件來改變所產(chǎn)生的圓的位置。
Core Image Filter Reference 按類別列出了所有可用的濾鏡。請注意,有一部分只能用在 OS X。
生成器和漸變?yōu)V鏡可以不需要輸入就能生成圖像。它們很少自己單獨使用,但是作為蒙版的時候會非常強(qiáng)大,就像我們例子中的 CIBlendWithMask
那樣。
混合操作和 CIBlendWithAlphaMask
還有 CIBlendWithMask
允許將兩個圖像合并成一個。
我們在話題 #3 的文章,繪制像素到屏幕上里,介紹了 iOS 和 OS X 的圖形棧。需要注意的是 CPU 和 GPU 的概念,以及兩者之間數(shù)據(jù)的移動方式。
在處理實時視頻的時候,我們面臨著性能的挑戰(zhàn)。
首先,我們需要能在每一幀的時間內(nèi)處理完所有的圖像數(shù)據(jù)。我們的樣本中采用 24 幀每秒的視頻,這意味著我們有 41 毫秒 (1/24 秒) 的時間來解碼,處理以及渲染每一幀中的百萬像素。
其次,我們需要能夠從 CPU 或者 GPU 上面得到這些數(shù)據(jù)。我們從視頻文件讀取的字節(jié)數(shù)最終會到達(dá) CPU 里。但是這個數(shù)據(jù)還需要移動到 GPU 上,以便在顯示器上可見。
一個非常致命的問題是,在渲染管線中,代碼可能會把圖像數(shù)據(jù)在 CPU 和 GPU 之間來回移動好幾次。確保像素數(shù)據(jù)僅在一個方向移動是很重要的,應(yīng)該保證數(shù)據(jù)只從 CPU 移動到 GPU,如果能讓數(shù)據(jù)完全只在 GPU 上那就更好。
如果我們想渲染 24 fps 的視頻,我們有 41 毫秒;如果我們渲染 60 fps 的視頻,我們只有 16 毫秒,如果我們不小心從 GPU 下載了一個像素緩沖到 CPU 里,然后再上傳回 GPU,對于一張全屏的 iPhone 6 圖像來說,我們在每個方向?qū)⒁苿?3.8 MB 的數(shù)據(jù),這將使幀率無法達(dá)標(biāo)。
當(dāng)我們使用 CVPixelBuffer
時,我們希望這樣的流程:
http://wiki.jikexueyuan.com/project/objc/images/23-6.svg" alt="" />
CVPixelBuffer
是基于 CPU 的 (見下文),我們用 CIImage
來包裝它。構(gòu)建濾鏡鏈不會移動任何數(shù)據(jù);它只是建立了一個流程。一旦我們繪制圖像,我們使用了基于 EAGL 上下文的 Core Image 上下文,而這個 EAGL 上下文也是 GLKView 進(jìn)行圖像顯示所使用的上下文。EAGL 上下文是基于 GPU 的。請注意,我們是如何只穿越 GPU-CPU 邊界一次的,這是至關(guān)重要的部分。
Core Image 的圖形上下文可以通過兩種方式創(chuàng)建:使用 EAGLContext
的 GPU 上下文,或者是基于 CPU 的上下文。
這個定義了 Core Image 工作的地方,也就是像素數(shù)據(jù)將被處理的地方。與工作區(qū)域無關(guān),基于 GPU 和基于 CPU 的圖形上下文都可以通過執(zhí)行 createCGImage(…)
,render(_, toBitmap, …)
和 render(_, toCVPixelBuffer, …)
,以及相關(guān)的命令來向 CPU 進(jìn)行渲染。
重要的是要理解如何在 CPU 和 GPU 之間移動像素數(shù)據(jù),或者是讓數(shù)據(jù)保持在 CPU 或者 GPU 里。將數(shù)據(jù)移過這個邊界是需要很大的代價的。
在我們的例子中,我們使用了幾個不同的緩沖區(qū)和圖像。這可能有點混亂。這樣做的原因很簡單,不同的框架對于這些“圖像”有不同的用途。下面有一個快速總覽,以顯示哪些是以基于 CPU 或者基于 GPU 的:
類 | 描述 |
---|---|
CIImage | 它們可以代表兩種東西:圖像數(shù)據(jù)或者生成圖像數(shù)據(jù)的流程。 |
CIFilter 的輸出非常輕量。它只是如何被創(chuàng)建的描述,并不包含任何實際的像素數(shù)據(jù)。 | |
如果輸出時圖像數(shù)據(jù)的話,它可能是純像素的 NSData ,一個 CGImage , 一個 CVPixelBuffer ,或者是一個 OpenGL 紋理 |
|
CVImageBuffer | 這是 CVPixelBuffer (CPU) 和 CVOpenGLESTexture (GPU) 的抽象父類. |
CVPixelBuffer | Core Video 像素緩沖 (Pixel Buffer) 是基于 CPU 的。 |
CMSampleBuffer | Core Media 采樣緩沖 (Sample Buffer) 是 CMBlockBuffer 或者 CVImageBuffer 的包裝,也包括了元數(shù)據(jù)。 |
CMBlockBuffer | Core Media 區(qū)塊緩沖 (Block Buffer) 是基于 GPU 的 |
需要注意的是 CIImage
有很多方便的方法,例如,從 JPEG 數(shù)據(jù)加載圖像或者直接加載一個 UIImage
對象。在后臺,這些將會使用一個基于 CGImage
的 CIImage
來進(jìn)行處理。
Core Image 是操縱實時視頻的一大利器。只要你適當(dāng)?shù)呐渲孟?,性能將會是?qiáng)勁的 —— 只要確保 CPU 和 GPU 之間沒有數(shù)據(jù)的轉(zhuǎn)移。創(chuàng)意地使用濾鏡,你可以實現(xiàn)一些非常炫酷的效果,神馬簡單色調(diào),褐色濾鏡都弱爆啦。所有的這些代碼都很容易抽象出來,深入了解下不同的對象的作用區(qū)域 (GPU 還是 CPU) 可以幫助你提高代碼的性能。