1. 程式人生 > >Core Image 和視訊

Core Image 和視訊

在這篇文章中,我們將研究如何將 Core Image 應用到實時視訊上去。我們會看兩個例子:首先,我們把這個效果加到相機拍攝的影片上去。之後,我們會將這個影響作用於拍攝好的視訊檔案。它也可以做到離線渲染,它會把渲染結果返回給視訊,而不是直接顯示在螢幕上。兩個例子的完整原始碼,請點選這裡

總覽

當涉及到處理視訊的時候,效能就會變得非常重要。而且瞭解黑箱下的原理 —— 也就是 Core Image 是如何工作的 —— 也很重要,這樣我們才能達到足夠的效能。在 GPU 上面做盡可能多的工作,並且最大限度的減少 GPU 和 CPU 之間的資料傳送是非常重要的。之後的例子中,我們將看看這個細節。

想對 Core Image 有個初步認識的話,可以讀讀 Warren 的這篇文章

Core Image 介紹。我們將使用 Swift 的函式式 API 中介紹的基於 CIFilter 的 API 封裝。想要了解更多關於 AVFoundation 的知識,可以看看本期話題中 Adriaan 的文章,還有話題 #21 中的 iOS 上的相機捕捉

優化資源的 OpenGL ES

CPU 和 GPU 都可以執行 Core Image,我們將會在 下面 詳細介紹這兩個的細節。在這個例子中,我們要使用 GPU,我們做如下幾樣事情。

我們首先建立一個自定義的 UIView,它允許我們把 Core Image 的結果直接渲染成 OpenGL。我們可以新建一個 GLKView 並且用一個 EAGLContext

來初始化它。我們需要指定 OpenGL ES 2 作為渲染 API,在這兩個例子中,我們要自己觸發 drawing 事件 (而不是在 -drawRect: 中觸發),所以在初始化 GLKView 的時候,我們將 enableSetNeedsDisplay 設定為 false。之後我們有可用新影象的時候,我們需要主動去呼叫 -display

在這個視圖裡,我們保持一個對 CIContext 的引用,它提供一個橋樑來連線我們的 Core Image 物件和 OpenGL 上下文。我們建立一次就可以一直使用它。這個上下文允許 Core Image 在後臺做優化,比如快取和重用紋理之類的資源等。重要的是這個上下文我們一直在重複使用。

上下文中有一個方法,-drawImage:inRect:fromRect:,作用是繪製出來一個 CIImage。如果你想畫出來一個完整的影象,最容易的方法是使用影象的 extent。但是請注意,這可能是無限大的,所以一定要事先裁剪或者提供有限大小的矩形。一個警告:因為我們處理的是 Core Image,繪製的目標以畫素為單位,而不是點。由於大部分新的 iOS 裝置配備 Retina 螢幕,我們在繪製的時候需要考慮這一點。如果我們想填充整個檢視,最簡單的辦法是獲取檢視邊界,並且按照螢幕的 scale 來縮放圖片 (Retina 螢幕的 scale 是 2)。

從相機獲取畫素資料

對於 AVFoundation 如何工作的概述,請看 Adriaan 的文章 和 Matteo 的文章 iOS 上的相機捕捉。對於我們而言,我們想從鏡頭獲得 raw 格式的資料。我們可以通過建立一個 AVCaptureDeviceInput 物件來選定一個攝像頭。使用 AVCaptureSession,我們可以把它連線到一個 AVCaptureVideoDataOutput。這個 data output 物件有一個遵守 AVCaptureVideoDataOutputSampleBufferDelegate 協議的代理物件。這個代理每一幀將接收到一個訊息:

func captureOutput(captureOutput: AVCaptureOutput!,
                   didOutputSampleBuffer: CMSampleBuffer!,
                   fromConnection: AVCaptureConnection!) {

我們將用它來驅動我們的影象渲染。在我們的示例程式碼中,我們已經將配置,初始化以及代理物件都打包到了一個叫做 CaptureBufferSource 的簡單介面中去。我們可以使用前置或者後置攝像頭以及一個回撥來初始化它。對於每個樣本快取區,這個回撥都會被呼叫,並且引數是緩衝區和對應攝像頭的 transform:

source = CaptureBufferSource(position: AVCaptureDevicePosition.Front) {
   (buffer, transform) in
   ...
}

我們需要對相機返回的資料進行變換。無論你如何轉動 iPhone,相機的畫素資料的方向總是相同的。在我們的例子中,我們將 UI 鎖定在豎直方向,我們希望螢幕上顯示的影象符合照相機拍攝時的方向,為此我們需要後置攝像頭拍攝出的圖片旋轉 -π/2。前置攝像頭需要旋轉 -π/2 並且加一個映象效果。我們可以用一個 CGAffineTransform 來表達這種變換。請注意如果 UI 是不同的方向 (比如橫屏),我們的變換也將是不同的。還要注意,這種變換的代價其實是非常小的,因為它是在 Core Image 渲染管線中完成的。

接著,要把 CMSampleBuffer 轉換成 CIImage,我們首先需要將它轉換成一個 CVPixelBuffer。我們可以寫一個方便的初始化方法來為我們做這件事:

extension CIImage {
    convenience init(buffer: CMSampleBuffer) {
        self.init(CVPixelBuffer: CMSampleBufferGetImageBuffer(buffer))
    }
}

現在我們可以用三個步驟來處理我們的影象。首先,把我們的 CMSampleBuffer 轉換成 CIImage,並且應用一個形變,使影象旋轉到正確的方向。接下來,我們用一個 CIFilter 濾鏡來得到一個新的 CIImage 輸出。我們使用了 Florian 的文章 提到的建立濾鏡的方式。在這個例子中,我們使用色調調整濾鏡,並且傳入一個依賴於時間而變化的調整角度。最終,我們使用之前定義的 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)
}

當你執行它時,你可能會因為如此低的 CPU 使用率感到吃驚。這其中的奧祕是 GPU 做了幾乎所有的工作。儘管我們建立了一個 CIImage,應用了一個濾鏡,並輸出一個 CIImage,最終輸出的結果是一個 promise:直到實際渲染才會去進行計算。一個 CIImage 物件可以是黑箱裡的很多東西,它可以是 GPU 算出來的畫素資料,也可以是如何建立畫素資料的一個說明 (比如使用一個濾鏡生成器),或者它也可以是直接從 OpenGL 紋理中創建出來的影象。

下面是演示視訊

從影片中獲取畫素資料

我們可以做的另一件事是通過 Core Image 把這個濾鏡加到一個視訊中。和實時拍攝不同,我們現在從影片的每一幀中生成畫素緩衝區,在這裡我們將採用略有不同的方法。對於相機,它會推送每一幀給我們,但是對於已有的影片,我們使用拉取的方式:通過 display link,我們可以向 AVFoundation 請求在某個特定時間的一幀。

display link 物件負責在每幀需要繪製的時候給我們傳送訊息,這個訊息是按照顯示器的重新整理頻率同步進行傳送的。這通常用來做 自定義動畫,但也可以用來播放和操作視訊。我們要做的第一件事就是建立一個 AVPlayer 和一個視訊輸出:

player = AVPlayer(URL: url)
videoOutput = AVPlayerItemVideoOutput(pixelBufferAttributes: pixelBufferDict)
player.currentItem.addOutput(videoOutput)

接下來,我們要建立 display link。方法很簡單,只要建立一個 CADisplayLink 物件,並將其新增到 run loop。

let displayLink = CADisplayLink(target: self, selector: "displayLinkDidRefresh:")
displayLink.addToRunLoop(NSRunLoop.mainRunLoop(), forMode: NSRunLoopCommonModes)

現在,唯一剩下的就是在 displayLinkDidRefresh: 呼叫的時候獲取視訊每一幀。首先,我們獲取當前的時間,並且將它轉換成當前播放專案裡的時間比。然後我們詢問 videoOutput,如果當前時間有一個可用的新的畫素快取區,我們把它複製一下並且呼叫回撥方法:

func displayLinkDidRefresh(link: CADisplayLink) {
    let itemTime = videoOutput.itemTimeForHostTime(CACurrentMediaTime())
    if videoOutput.hasNewPixelBufferForItemTime(itemTime) {
        let pixelBuffer = videoOutput.copyPixelBufferForItemTime(itemTime, itemTimeForDisplay: nil)
        consumer(pixelBuffer)
    }
}

我們從一個視訊輸出獲得的畫素緩衝是一個 CVPixelBuffer,我們可以把它直接轉換成 CIImage。正如上面的例子,我們會加上一個濾鏡。在這個例子裡,我們將組合多個濾鏡:我們使用一個萬花筒的效果,然後用漸變遮罩把原始影象和過濾影象相結合,這個操作是非常輕量級的。

創意地使用濾鏡

大家都知道流行的照片效果。雖然我們可以將這些應用到視訊,但 Core Image 還可以做得更多。

Core Image 裡所謂的濾鏡有不同的類別。其中一些是傳統的型別,輸入一張圖片並且輸出一張新的圖片。但有些需要兩個 (或者更多) 的輸入影象並且混合生成一張新的影象。另外甚至有完全不輸入圖片,而是基於引數的生成影象的濾鏡。

通過混合這些不同的型別,我們可以建立意想不到的效果。

混合圖片

在這個例子中,我們使用這些東西:

Combining filters

上面的例子可以將影象的一個圓形區域畫素化。

它也可以建立互動,我們可以使用觸控事件來改變所產生的圓的位置。

生成器和漸變濾鏡可以不需要輸入就能生成影象。它們很少自己單獨使用,但是作為蒙版的時候會非常強大,就像我們例子中的 CIBlendWithMask 那樣。

混合操作和 CIBlendWithAlphaMask 還有 CIBlendWithMask 允許將兩個影象合併成一個。

CPU vs. GPU

我們在話題 #3 的文章,繪製畫素到螢幕上裡,介紹了 iOS 和 OS X 的圖形棧。需要注意的是 CPU 和 GPU 的概念,以及兩者之間資料的移動方式。

在處理實時視訊的時候,我們面臨著效能的挑戰。

首先,我們需要能在每一幀的時間內處理完所有的影象資料。我們的樣本中採用 24 幀每秒的視訊,這意味著我們有 41 毫秒 (1/24 秒) 的時間來解碼,處理以及渲染每一幀中的百萬畫素。

其次,我們需要能夠從 CPU 或者 GPU 上面得到這些資料。我們從視訊檔案讀取的位元組數最終會到達 CPU 裡。但是這個資料還需要移動到 GPU 上,以便在顯示器上可見。

避免轉移

一個非常致命的問題是,在渲染管線中,程式碼可能會把影象資料在 CPU 和 GPU 之間來回移動好幾次。確保畫素資料僅在一個方向移動是很重要的,應該保證資料只從 CPU 移動到 GPU,如果能讓資料完全只在 GPU 上那就更好。

如果我們想渲染 24 fps 的視訊,我們有 41 毫秒;如果我們渲染 60 fps 的視訊,我們只有 16 毫秒,如果我們不小心從 GPU 下載了一個畫素緩衝到 CPU 裡,然後再上傳回 GPU,對於一張全屏的 iPhone 6 影象來說,我們在每個方向將要移動 3.8 MB 的資料,這將使幀率無法達標。

當我們使用 CVPixelBuffer 時,我們希望這樣的流程:

Flow of image data

CVPixelBuffer 是基於 CPU 的 (見下文),我們用 CIImage 來包裝它。構建濾鏡鏈不會移動任何資料;它只是建立了一個流程。一旦我們繪製圖像,我們使用了基於 EAGL 上下文的 Core Image 上下文,而這個 EAGL 上下文也是 GLKView 進行影象顯示所使用的上下文。EAGL 上下文是基於 GPU 的。請注意,我們是如何只穿越 GPU-CPU 邊界一次的,這是至關重要的部分。

工作和目標

Core Image 的圖形上下文可以通過兩種方式建立:使用 EAGLContext 的 GPU 上下文,或者是基於 CPU 的上下文。

這個定義了 Core Image 工作的地方,也就是畫素資料將被處理的地方。與工作區域無關,基於 GPU 和基於 CPU 的圖形上下文都可以通過執行 createCGImage(…)render(_, toBitmap, …)render(_, toCVPixelBuffer, …),以及相關的命令來向 CPU 進行渲染。

重要的是要理解如何在 CPU 和 GPU 之間移動畫素資料,或者是讓資料保持在 CPU 或者 GPU 裡。將資料移過這個邊界是需要很大的代價的。

緩衝區和影象

在我們的例子中,我們使用了幾個不同的緩衝區影象。這可能有點混亂。這樣做的原因很簡單,不同的框架對於這些“影象”有不同的用途。下面有一個快速總覽,以顯示哪些是以基於 CPU 或者基於 GPU 的:

描述
CIImage 它們可以代表兩種東西:影象資料或者生成影象資料的流程。
CIFilter 的輸出非常輕量。它只是如何被建立的描述,並不包含任何實際的畫素資料。
如果輸出時影象資料的話,它可能是純畫素的 NSData,一個 CGImage, 一個 CVPixelBuffer,或者是一個 OpenGL 紋理
CVImageBuffer 這是 CVPixelBuffer (CPU) 和 CVOpenGLESTexture (GPU) 的抽象父類.
CVPixelBuffer Core Video 畫素緩衝 (Pixel Buffer) 是基於 CPU 的。
CMSampleBuffer Core Media 取樣緩衝 (Sample Buffer) 是 CMBlockBuffer 或者 CVImageBuffer 的包裝,也包括了元資料。
CMBlockBuffer Core Media 區塊緩衝 (Block Buffer) 是基於 GPU 的

需要注意的是 CIImage 有很多方便的方法,例如,從 JPEG 資料載入影象或者直接載入一個 UIImage 物件。在後臺,這些將會使用一個基於 CGImageCIImage 來進行處理。

結論

Core Image 是操縱實時視訊的一大利器。只要你適當的配置下,效能將會是強勁的 —— 只要確保 CPU 和 GPU 之間沒有資料的轉移。創意地使用濾鏡,你可以實現一些非常炫酷的效果,神馬簡單色調,褐色濾鏡都弱爆啦。所有的這些程式碼都很容易抽象出來,深入瞭解下不同的物件的作用區域 (GPU 還是 CPU) 可以幫助你提高程式碼的效能。