隨著CV技術的不斷發展以及其在移動端的落地,越來越多的產品集成了特效相機相關功能,如拍攝小視頻時的實時濾鏡,人臉貼紙,各類瘦臉、瘦腰、大長腿等“邪術”黑科技......
從移動端技術角度去看,特效相機模塊的基本結構是:
- 從相機獲取圖像幀數據
- 將圖像 數據喂給特定的算法,獲取算法輸出
- 利用算法輸出對圖像幀進行修改(也可以是疊加其他圖像,如3D渲染出的虛擬模型)
- 將處理完的圖像幀渲染到手機屏幕上
本文僅對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再次進行計算,實現各類濾鏡效果。