用Metal做計算(二)YUV格式相機幀渲染

隨著CV技術的不斷發展以及其在移動端的落地,越來越多的產品集成了特效相機相關功能,如拍攝小視頻時的實時濾鏡,人臉貼紙,各類瘦臉、瘦腰、大長腿等“邪術”黑科技......
從移動端技術角度去看,特效相機模塊的基本結構是:

  1. 從相機獲取圖像幀數據
  2. 將圖像 數據喂給特定的算法,獲取算法輸出
  3. 利用算法輸出對圖像幀進行修改(也可以是疊加其他圖像,如3D渲染出的虛擬模型)
  4. 將處理完的圖像幀渲染到手機屏幕上

本文僅對1,4(獲取相機圖像幀數據,直接將圖像幀渲染到手機屏幕)做討論。

iOS上,借助AVCaptureVideoPreviewLayer,可以很容易地把相機圖像幀展示在手機屏幕上。但特效相機功能開發中,我們基本不能使用AVCaptureVideoPreviewLayer,一是在某些場景下,我們需要修改圖像幀數據本;另外我們無法控制PreviewLayer的渲染時序,做不到相機圖像幀的渲染與CV算法執行之間的同步。
所以在本文中,我們從相機幀獲取數據,并使用MTKView渲染圖像幀。
另外,一些CV算法僅接受灰度圖數據作為輸入,有些則只接受RGBA數據,所以本文在配置 AVCaptureVideoDataOutput 時將輸出的數據格式設置為 kCVPixelFormatType_420YpCbCr8BiPlanarFullRange。

本文涉及到的示例代碼地址:camera_preview_with_metal

實現思路

  • 配置相機,輸出圖像幀數據(YUV格式),并以此創建兩個MTLTexture對象(一張紋理包含Y通道數據,即灰度圖數據,一張紋理包含UV通道數據)
  • 借助Metal的Compute Pipeline,用上一步獲取的兩張紋理,合成出RGBA格式的紋理
  • 借助Metal的Render Pipeline 將RGBA格式的紋理渲染的手機屏幕上
配置相機,獲取輸入紋理對象

配置相機部分的代碼如下,這里就不做過多的說明了

        session = AVCaptureSession()
        guard let device = AVCaptureDevice.default(for: .video) else {
            return
        }
        
        let deviceInput: AVCaptureDeviceInput
        do {
            deviceInput = try AVCaptureDeviceInput(device: device)
            guard session.canAddInput(deviceInput) else {
                return
            }
            session.addInput(deviceInput)
        } catch {
            return
        }
        
        let dataOutput = AVCaptureVideoDataOutput()
        dataOutput.videoSettings = [kCVPixelBufferPixelFormatTypeKey: kCVPixelFormatType_420YpCbCr8BiPlanarFullRange] as [String : Any]
        guard session.canAddOutput(dataOutput) else {
            return
        }
        dataOutput.setSampleBufferDelegate(self, queue: sampleBufferQueue)
        session.addOutput(dataOutput)
        session.sessionPreset = .hd1280x720

在相機數據的回調中,我們需要從CMSampleBuffer創建出兩個MTLTexture對象。 這里先對 420YpCbCr8BiPlanarFullRange 數據格式做一個簡單的介紹。
我們熟知的RGBA格式的圖像數據中,一個pixel占4個byte,每個Byte分別包含R、G、B顏色通道和A透明度通道信息,圖像幀總的大小為 countOfBytes = image.width * image.height * 4。
而420YpCbCr8BiPlanarFullRange,圖像幀的數據被分割為兩個plane,index為0的plane大小與圖像分辨率一致(即 countOfBytes = image.width * image.height),每個byte包含對應像素點的亮度信息。index為1的plane包含了圖像幀的色差信息,且其寬高分別為圖像寬高的一半(即 countOfBytes = image.width/2 * image.height/2),4個亮度像素公用一個色差像素。

下面是從CMSampleBuffer創建MTLTexture的代碼

        let imagePixel = CMSampleBufferGetImageBuffer(sampleBuffer)!
        let yWidth = CVPixelBufferGetWidthOfPlane(imagePixel, 0)
        let yHeight = CVPixelBufferGetHeightOfPlane(imagePixel, 0)
        
        let uvWidth = CVPixelBufferGetWidthOfPlane(imagePixel, 1)
        let uvHeight = CVPixelBufferGetHeightOfPlane(imagePixel, 1)
        
        CVPixelBufferLockBaseAddress(imagePixel, CVPixelBufferLockFlags(rawValue: 0))
        var yTexture: CVMetalTexture?
        var uvTexture: CVMetalTexture?
        CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, textureCache!, imagePixel, nil, .r8Unorm, yWidth, yHeight, 0, &yTexture)
        CVMetalTextureCacheCreateTextureFromImage(kCFAllocatorDefault, textureCache!, imagePixel, nil, .rg8Unorm, uvWidth, uvHeight, 1, &uvTexture)
        
        CVPixelBufferUnlockBaseAddress(imagePixel, CVPixelBufferLockFlags(rawValue: 0))
        guard yTexture != nil && uvTexture != nil else {
            return
        }
        
        // Get MTLTexture instance
        luminance = CVMetalTextureGetTexture(yTexture!)
        chroma = CVMetalTextureGetTexture(uvTexture!)

需要特別說明的是,我們在獲取MTLTexture時,并不是通過手動創建 MTLTexture對象,并調用它的

replace(region:mipmapLevel:withBytes:bytesPerRow:)

來實現的,而是使用了下面的方法。

CVReturn CVMetalTextureCacheCreateTextureFromImage(CFAllocatorRef allocator, CVMetalTextureCacheRef textureCache, CVImageBufferRef sourceImage, CFDictionaryRef textureAttributes, MTLPixelFormat pixelFormat, size_t width, size_t height, size_t planeIndex, CVMetalTextureRef  _Nullable *textureOut);

這樣做的好處是可以降低CPU的使用率,感興趣的同學可以對比下兩個方案的性能。

利用Metal的ComputePipeline實現YUV到RGB的轉換

我們需要在初始化階段,創建一個MTLComputePipelineState對象,代碼如下:

        let yuv2rgbFunc = library.makeFunction(name: "yuvToRGB")!
        yuv2rgbComputePipeline = try! device.makeComputePipelineState(function: yuv2rgbFunc)
        CVMetalTextureCacheCreate(kCFAllocatorDefault, nil, device, nil, &textureCache)

其中,yuvToRGB方法的代碼如下:

kernel void yuvToRGB(texture2d<float, access::read> yTexture[[texture(0)]],
         texture2d<float, access::read> uvTexture[[texture(1)]],
         texture2d<float, access::write> outTexture[[texture(2)]],
         constant float3x3 *convertMatrix [[buffer(0)]],
         uint2 gid [[thread_position_in_grid]]) {

    float4 ySample = yTexture.read(gid);
    float4 uvSample = uvTexture.read(gid/2);
    
    float3 yuv;
    yuv.x = ySample.r;
    yuv.yz = uvSample.rg - float2(0.5);
    
    float3x3 matrix = *convertMatrix;
    float3 rgb = matrix * yuv;
    outTexture.write(float4(rgb, yuv.x), gid);
}

shader方法包含5個參數,前兩個為輸入的紋理,第三個為輸出紋理,第四個是用于做 YUV到RGB轉換的3x3矩陣。轉換矩陣的定義:

        convertMatrix = float3x3(float3(1.164, 1.164, 1.164),
                                 float3(0, -0.231, 2.112),
                                 float3(1.793, -0.533, 0))

最后一個是grid id。

需要注意的是

float4 uvSample = uvTexture.read(gid/2);

如前文所說,4個y通道數據對應一個uv通道數據,所以去uv數據時讀的是gid/2的坐標點。

然后是將計算相關的指令編碼到CommandBuffer當中:

       guard let computeEncoder = commandBuffer.makeComputeCommandEncoder() else {
            return
        }
        
        // compute pass
        computeEncoder.setComputePipelineState(yuv2rgbComputePipeline)
        computeEncoder.setTexture(luminance, index: 0)
        computeEncoder.setTexture(chroma, index: 1)
        computeEncoder.setTexture(rgbTexture, index: 2)
        computeEncoder.setBytes(&convertMatrix, length: MemoryLayout<float3x3>.size, index: 0)
        
        let width = rgbTexture.width
        let height = rgbTexture.height
        
        let groupSize = 32
        let groupCountW = (width + groupSize) / groupSize - 1
        let groupCountH = (height + groupSize) / groupSize - 1
        computeEncoder.dispatchThreadgroups(MTLSize(width: groupCountW, height: groupCountH, depth: 1),
                                            threadsPerThreadgroup: MTLSize(width: groupSize, height: groupSize, depth: 1))
        computeEncoder.endEncoding()

到這里,我們獲得了RGBA格式的圖像數據,并保存在rgbTexture對象當中,接下來可以用Metal的RenderPipeline繪制一個全屏幕的四邊形,并將rgbTexture附著在四邊形上,部分代碼如下(RenderPipeline初始化相關的代碼可參考:camera_preview_with_metal

        guard let renderPassDesc = view.currentRenderPassDescriptor else {
            return
        }
        
        guard let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDesc) else {
            return
        }

        renderEncoder.setRenderPipelineState(renderPipelineState)
        renderEncoder.setVertexBuffer(vertexBuffer, offset: 0, index: 0)
        renderEncoder.setFragmentTexture(rgbTexture, index: 0)
        
        renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: 4)
        renderEncoder.endEncoding()

總結

以上就是獲取相機YUV格式的數據,轉換為RGB格式,并渲染到屏幕上所需的全部代碼了,我們可以在此基礎上做很多擴展,比如在渲染到手機屏幕之前對rgbTexture再次進行計算,實現各類濾鏡效果。

?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。

推薦閱讀更多精彩內容