本文檔基於H.264的解碼,介紹讀寫Video Toolbox解碼回調函數參數CVImageBufferRef中的YUV或RGB數據的方法,並給出CVImageBufferRef生成灰度圖代碼、方便調試。同時,還介紹了Video Toolbox解碼回調中進行YUV處理時容易忽略的問題。文檔定位於iOS音視頻高級編程,致力於提供高參考價值的Core Video中文資料,最近也在StackOverflow上關注Core Video相關問題,學習並回饋社區。
目錄
|- 讀取CVImageBufferRef(CVPixelBufferRef)
|- 寫入CVImageBufferRef(CVPixelBufferRef)
|- CVPixelBufferPool內存池
|- CVPixelBuffer通過Core Graphics創建灰度圖
|- 坑
|-- 直接操作解碼回調的CVImageBuffer(CVPixelBuffer)存在的問題
|-- CVPixelBuffer上傳至GPU后圖像垂直鏡像問題
|- 參考與推薦閱讀
在實現全景視頻播放器及其關聯項目過程中,我編寫了以下Video Toolbox相關文檔(因開發任務等原因,部分文檔處於草稿狀態,之后會進行內容修訂):
CVPixelBufferRef是CVImageBufferRef的別名,兩者操作幾乎一致。
// CVPixelBuffer.h/*  
* CVPixelBufferRef  
* Based on the image buffer type.   
* The pixel buffer implements the memory storage for an image buffer.  
*/typedef CVImageBufferRef CVPixelBufferRef; 
        雖然語法上CVPixelBufferRef是CVImageBufferRef的別名,它們在文檔中的說明卻有區別:
Core Video image buffers provides a convenient interface for managing different types of image data. Pixel buffers and Core Video OpenGL buffers derive from the Core Video image buffer.
CVImageBufferRef:A reference to a Core Video image buffer. An image buffer is an abstract type representing Core Video buffers that hold images. In Core Video, pixel buffers, OpenGL buffers, and OpenGL textures all derive from the image buffer type.
CVPixelBufferRef :A reference to a Core Video pixel buffer object. The pixel buffer stores an image in main memory.
從上述可知,CVPixelBuffer『繼承了』CVImageBuffer,然而,由於Core Video暴露出來的是Objective-C接口,意味着若想用C語言實現『面向對象的繼承』,則CVPixelBuffer的數據成員定義位置與CVImageBuffer基本保持一致且令編譯器進行相同的偏移以確保字節對齊,猶如FFmpeg中AVFrame可強制轉換成AVPicture,以FFmpeg 3.0源碼為例。
typedef struct AVFrame {     
    uint8_t *data[AV_NUM_DATA_POINTERS];    
    int linesize[AV_NUM_DATA_POINTERS];     
    uint8_t **extended_data;    // 后續還有眾多字段
}
typedef struct AVPicture {    
    ///< pointers to the image data planes     
    uint8_t *data[AV_NUM_DATA_POINTERS];       
    ///< number of bytes per line       
    int linesize[AV_NUM_DATA_POINTERS];      
} AVPicture; 
        當然,從蘋果開源的某些框架上看,Core Video內部極有可能用Objective-C++實現,可能真正用了C++式繼承,在此不作過多猜測。
1、讀取CVImageBufferRef(CVPixelBufferRef)
在解碼回調中,傳遞過來的幀數據由CVImageBufferRef指向。如果需取出其中像素數據作進一步處理,得訪問其中真正存儲像素的內存。
VideoToolbox解碼后的圖像數據並不能直接給CPU訪問,需先用CVPixelBufferLockBaseAddress()鎖定地址才能從主存訪問,否則調用CVPixelBufferGetBaseAddressOfPlane等函數則返回NULL或無效值。值得注意的是,CVPixelBufferLockBaseAddress自身的調用並不消耗多少性能,一般情況,鎖定之后,往CVPixelBuffer拷貝內存才是相對耗時的操作,比如計算內存偏移。如果CVPixelBuffer的圖像需要顯示在屏幕上,建議用GPU實現圖像處理操作。下面展示讀寫左半圖像時的性能損耗(請忽略內存計算的粗暴代碼)。

讀取CVPixelBuffer圖像的性能消耗

寫入CVPixelBuffer圖像的性能消耗
然而,用CVImageBuffer -> CIImage -> UIImage則無需顯式調用鎖定基地址函數。
// CVPixelBufferLockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly); // 可以不加
CIImage *ciImage = [CIImage imageWithCVPixelBuffer:imageBuffer];
CIContext *temporaryContext = [CIContext contextWithOptions:nil];CGImageRef videoImage = [temporaryContext                          
                         createCGImage:ciImage                          
                         fromRect:CGRectMake(0, 0,                                              
                                             CVPixelBufferGetWidth(imageBuffer),                                              
                                             CVPixelBufferGetHeight(imageBuffer))];
UIImage *image = [[UIImage alloc] initWithCGImage:videoImage];
UIImageView *imageView = [[UIImageView alloc] initWithImage:image];
CGImageRelease(videoImage);
// CVPixelBufferUnlockBaseAddress(imageBuffer, kCVPixelBufferLock_ReadOnly); 
        CVPixelBufferIsPlanar可得到像素的存儲方式是Planar或Chunky。若是Planar,則通過CVPixelBufferGetPlaneCount獲取YUV Plane數量。通常是兩個Plane,Y為一個Plane,UV由VTDecompressionSessionCreate創建解碼會話時通過destinationImageBufferAttributes指定需要的像素格式(可不同於視頻源像素格式)決定是否同屬一個Plane,每個Plane可當作表格按行列處理,像素是行順序填充的。下面以Planar Buffer存儲方式作說明。
CVPixelBufferGetPlaneCount得到像素緩沖區平面數量,然后由CVPixelBufferGetBaseAddressOfPlane(索引)得到相應的通道,一般是Y、U、V通道存儲地址,UV是否分開由解碼會話指定,如前面所述。而CVPixelBufferGetBaseAddress返回的對於Planar Buffer則是指向PlanarComponentInfo結構體的指針,相關定義如下:
/* 
Planar pixel buffers have the following descriptor at their base address.   
Clients should generally use CVPixelBufferGetBaseAddressOfPlane,  
CVPixelBufferGetBytesPerRowOfPlane, etc. instead of accessing it directly. 
*/
struct CVPlanarComponentInfo {  
    /* offset from main base address to base address of this plane, big-endian */   
    int32_t             offset;       
    /* bytes per row of this plane, big-endian */   
    uint32_t            rowBytes;  
};
typedef struct CVPlanarComponentInfo      CVPlanarComponentInfo;struct CVPlanarPixelBufferInfo {   
    CVPlanarComponentInfo  componentInfo[1]; 
};
typedef struct CVPlanarPixelBufferInfo         CVPlanarPixelBufferInfo;struct CVPlanarPixelBufferInfo_YCbCrPlanar {   
    CVPlanarComponentInfo  componentInfoY;   
    CVPlanarComponentInfo  componentInfoCb;   
    CVPlanarComponentInfo  componentInfoCr; 
};
typedef struct CVPlanarPixelBufferInfo_YCbCrPlanar   CVPlanarPixelBufferInfo_YCbCrPlanar;struct CVPlanarPixelBufferInfo_YCbCrBiPlanar {   
    CVPlanarComponentInfo  componentInfoY;   
    CVPlanarComponentInfo  componentInfoCbCr; 
};
typedef struct CVPlanarPixelBufferInfo_YCbCrBiPlanar   CVPlanarPixelBufferInfo_YCbCrBiPlanar; 
        根據CVPixelBufferGetPixelFormatType得到像素格式,以對應的方式讀取,比如YUV420SP跨距讀取所有的U到一個緩沖區。
2、寫入CVImageBufferRef(CVPixelBufferRef)
下面代碼展示了以向Y、UV Planar拷貝數據的過程:
NSDictionary *pixelAttributes = @{(id)kCVPixelBufferIOSurfacePropertiesKey : @{}}; 
CVPixelBufferRef pixelBuffer = NULL; 
CVReturn result = CVPixelBufferCreate(kCFAllocatorDefault,     
    width, height,     
    kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange,     
    (__bridge CFDictionaryRef)pixelAttributes)     
    &pixelBuffer);  
CVPixelBufferLockBaseAddress(pixelBuffer, 0);uint8_t *yDestPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 0);memcpy(yDestPlane, yPlane, width * height);uint8_t *uvDestPlane = CVPixelBufferGetBaseAddressOfPlane(pixelBuffer, 1);// numberOfElementsForChroma為UV寬高乘積memcpy(uvDestPlane, uvPlane, numberOfElementsForChroma); 
CVPixelBufferUnlockBaseAddress(pixelBuffer, 0);
if (result != kCVReturnSuccess) {     
    NSLog(@"Unable to create cvpixelbuffer %d", result); 
}  
CIImage *coreImage = [CIImage imageWithCVPixelBuffer:pixelBuffer]; 
CVPixelBufferRelease(pixelBuffer); 
        上述代碼通過- [CIImage imageWithCVPixelBuffer:]創建CIImage在iPad Air 2、iPhone 6p等真機上存在的問題:
1、當使用kCVPixelFormatType_420YpCbCr8PlanarFullRange時提示[CIImage initWithCVPixelBuffer:options:] failed because its pixel format f420 is not supported.,即不支持由YUV420P格式的CVPixelBuffer創建CIImage。
經測試,視頻源格式為yuvj420p(pc, bt709),在VTDecompressionSessionCreate不指定destinationImageBufferAttributes的kCVPixelBufferPixelFormatTypeKey值時,Video Toolbox解碼出來的CVImageBufferRef對應為f420。
當指定destinationImageBufferAttributes需要kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange時,解碼出來的ImageBuffer為420v,然后創建YUV時指定PixelFormat為f420會出現上述問題。原因是,以420v方式拷貝YUV數據,其存儲布局與f420不同,導致創建CIImage失敗。
2、決定CVPixelBufferCreate創建的格式是其參數pixelFormatType,而非參數pixelAttributes使用kCVPixelBufferPixelFormatTypeKey指定的像素格式。
下面介紹一些簡單的圖像處理辦法。

原始灰度圖
(一)水平鏡像
水平鏡像就是圖像繞圖像中間垂直線交換左右像素點位置,使用矩陣運行表示為:
[x, y, 1]   -1    0 0 -> [x', y', 1]            
            0     1 0             
            width 0 1 
        對於CPU而言,矩陣運行通常沒GPU快,因為GPU做2x2、3x3等矩陣運算是硬件加速實現的,很可能就是一條指令處理完,而CPU往往是逐個元素進行計算,因此,目前大家傾向於GPU做矩陣運行。示例CPU實現代碼如下。
for (int line = 0; line < 480; ++line) {    
    for (int col = 0; col < 960; ++col) {         
        dst_buffer[line * 960 + col] = src_buffer[line * 960 + (960 - col)];     
     } 
} 
        
水平鏡像
(二)垂直鏡像
垂直鏡像就是圖像繞圖像中間水平線交換上下像素點位置,使用矩陣運行表示為:
[x, y, 1]   1     0  0 -> [x', y', 1]            
            0    -1  0             
            0 height 1 
        示例CPU實現代碼如下。
for (int line = 0; line < 480; ++line) {    
    for (int col = 0; col < 960; ++col) {         
        dst_buffer[(480 - line) * 960 + col] = src_buffer[line * 960 + col];     
    } 
} 
        

垂直鏡像
3、CVPixelBufferPool內存池
自行創建CVPixelBufferPool且通過CVPixelBufferPool創建CVPixelBuffer,容易出現CVPixelBuffer被錯誤釋放或意外增加引用計數導致內存泄露,以ijkplayer為例演示CVPixelBubffer泄露的情況。

CVPixelBuffer泄露

CVPixelBuffer結束引用時引用計數不為0導致內存泄露
而自行創建CVPixelBuffer,則容易出現內存暴漲問題,如創建一個960x480的YUV420SP格式的CVPixelBuffer所占內存為700多M,如果是異步解碼且沒作內存大小限制,將導致應用崩潰。

CVPixelBufferCreate占用的內存
如果不想自行創建CVPixelBufferPool,也不想自己創建CVPixelBuffer,取巧的辦法是,使用解碼回調函數的CVPixelBuffer,則無需擔心內存消耗問題。在實踐過程中,圖像處理后立即編碼,這樣使用的場合不會導致解碼器自身的緩存隊列數據出現圖像紊亂。前提是,修改后的像素數據在原數據的寬高范圍內。當然,這也會出現些問題,具體在文檔后續部分進行討論
對於解碼->圖像處理->編碼流程,且處理后的圖像與原圖像大小不同,則創建編碼器時再創建CVPixelBufferPool,讓系統管理CVPixelBuffer也是可靠的做法。
另外,在圖像處理過程中,Video Toolbox無論指定FullRange還是VideoRange,由此通過Core Graphics創建RGB圖像是正確的,和QuickTime播放時畫面保持一致。然而,解碼出來的YUV420SP數據經過拷貝,接着進行圖像處理,存在部分區域顏色有誤。通過指定Video Toolbox輸出YUV420P,再進行圖像處理則無顏色異常問題。當然,使用的算法也改變相應的YUV420P算法,因為個人認為,這極有可能是我們團隊的YUV420SP拷貝及操作算法有誤。
4、CVPixelBuffer通過Core Graphics創建灰度圖
修改完YUV數據后,如果每次都需要GPU實現YUV轉換RGB,這比較麻煩,特別是轉碼等離線計算場合。下面,介紹一種實現CVPixelBuffer生成UIImage的辦法,只使用Y平面生成圖像,判斷圖像成像方面的處理結果是否符合預期。
// baseAddress為Y平面地址,傳遞yuv420(s)p完整數據地址,則忽略uv
UIImage* yuv420ToUIImage(void *baseAddress, size_t width, size_t height, size_t bytesPerRow) {    // Create a device-dependent gray color space     
    CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceGray();    // Create a bitmap graphics context with the sample buffer data     
    CGContextRef context = CGBitmapContextCreate(baseAddress,          
        width, height,          
        8,         
        bytesPerRow,          
        colorSpace,          
        kCGImageAlphaNone);    // Create a Quartz image from the pixel data in the bitmap graphics context     
    CGImageRef quartzImage = CGBitmapContextCreateImage(context);    // Free up the context and color space     
    CGContextRelease(context);    
    CGColorSpaceRelease(colorSpace);    // Create an image object from the Quartz image     
    UIImage *image = [UIImage imageWithCGImage:quartzImage];    return image; 
} 
        上述代碼可能會引起這樣的疑問:灰度圖為何不需要U和V通道的數據。確實,此問題我最近特意查閱了些資料。創建灰度圖時,有些人還將U、V通道在偏置前(值范圍[-128, 127])設置為0,或者偏置后(值范圍[0, 255])設置為128,然而,創建灰度圖時,他們的代碼並未使用UV數據。另外,看到一種說法是:
Y通道就是平時所說的灰度通道。
當然,以我有限的了解來看,個人不太認可這種說法。原因是,Y通道是YUV的一個分量,而灰度是復合量,即使數值接近,在概念上應該也是有區別的。數值接近的意思是,以BT. 601轉換矩陣為例進行證明:
Y = 0.299R + 0.587G + 0.114B 
GrayScale = (R + G + B) / 3 
        可見,Y值在數值接近灰度值。下面,對創建圖像的代碼段進行簡要分析。
一些開源項目,如SDWebImage,它使用CGColorSpaceCreateDeviceRGB函數,是因為它的數據源是RGB,而我們這里的YUV數據需要經過顏色轉換矩陣運算才能得到RGB,簡單起見,由CGColorSpaceCreateDeviceGray函數創建灰度圖可直接看到圖像發生的變化,缺點是,丟失了顏色信息。示意圖如下所示。

生成灰度圖
雖然,像素格式為YUV的視頻解碼后幾乎都可生成灰度圖。然而,並不是所有的圖像原始數據都能通過Core Graphics生成可視圖像,iOS支持的像素格式非常有限,如下所示。

生成灰度圖支持的像素格式
5、坑
操作CVImageBuffer(CVPixelBuffer)雖然看着沒什么難度,然而,還是有些大大小小的問題。如果對此不作描述,那么本文檔的標題真是太標題黨了。下面,給出我在開發過程中遇到並解決的情況。
5.1、直接操作解碼回調的CVImageBuffer(CVPixelBuffer)存在的問題
在解碼回調函數中進行YUV處理,無論是否同步解碼,或者解碼與創建紋理、刷新界面是否為同一線程。需要注意的是,解碼回調得到的CVPixelBuffer中的圖像是上一次解碼回調中處理過的圖像,而非視頻壓縮數據通過解碼得到的新的完整圖像。換句話說,在一個關鍵幀解碼成功后,其后續P幀以前一幀為基礎,繼續解碼並將結果疊加到新畫面,然后傳遞到解碼回調函數。簡單示意之。
Decode Thread: VTDecompressionSessionDecodeFrame -> VTDecoderCallback (進行圖像處理) -> 添加到待顯示隊列 
Rendering Thread: 讀取待顯示隊列、得到已處理的CVPixelBuffer -> CVOpenGLESTextureCacheCreateTextureFromImage 
        下面,詳細討論上述情況。進行YUV三個通道處理后,播放出來的畫面看着正常,相關資源占用信息如下所示。然而,經輸出Video Toolbox回調函數傳遞過來的CVPixelBuffer或說CVImageBuffer,發現是之前我們處理過的圖像,並在上一關鍵幀基礎上持續疊加P幀,把結果圖像作為下一幀視頻。

CPU不超負荷的資源占用

CPU不超負荷的GPU占用

CPU不超負荷的Y通道圖

CPU不超負荷的解碼回調每幀圖像
可見,作為一個關鍵幀間隔為15的視頻序列,src_1.jpg與src_16.jpg因關鍵幀得到一次立即刷新,隨后的圖像都在YUV處理的基礎上持續疊加。
5.2、CVPixelBuffer上傳至GPU后圖像垂直鏡像問題
對於CMVideoFormatDescription及指定輸出的CVPixelBuffer信息如下的解碼過程,在自行創建CVPixelBuffer后,將解碼回調函數的CVPixelBuffer數據拷貝到新CVPixelBuffer,通常會遇到圖像顛倒了,確切地說,圖像出現垂直鏡像問題。不過,使用前面生成灰度圖函數得到的圖像都是正的,不存在顛倒,只有上傳到GPU里才存在此現象。原因是,計算機的圖像存儲時有自己的坐標,這個坐標與OpenGL ES的紋理坐標的Y軸正好相反,故圖像在GPU中是顛倒的。
CMVideoFormatDescription {     
    CVFieldCount = 1;     
    CVImageBufferChromaLocationBottomField = Left;     
    CVImageBufferChromaLocationTopField = Left;     
    FullRangeVideo = 0;     
    SampleDescriptionExtensionAtoms =     {         
        avcC = <01640033 ffe10014 67640033 ac1b4583 c0f68400 000fa000 03a98010 01000468 e923cbfd f8f800>;     
    }; 
} 
destinationImageBufferAttributes = {     
    OpenGLESCompatibility = 1;     
    PixelFormatType = 2033463856; 
} 
        現在,嘗試使用Core Video接口處理此問題。首先,判斷源及目標圖像是否翻轉。
bool isFlipped = CVImageBufferIsFlipped(pixelBuffer);
if (isFlipped) {    
    NSLog(@"pixelBuffer is %s", isFlipped ? "flipped" : "not flipped"); } 
isFlipped = CVImageBufferIsFlipped(imageBuffer);
if (isFlipped) {    
    NSLog(@"imageBuffer is %s", isFlipped ? "flipped" : "not flipped"); 
} 
        發現圖像都是翻轉的,執行結果所下。
pixelBuffer is flipped 
imageBuffer is flipped 
        顯然,還需要更多信息去判斷。再獲取兩個緩沖區的ShouldNotPropagate屬性,發現都沒有值。但是,回調函數的像素緩沖區有ShouldPropagate屬性,而我們自行創建的緩沖區則無此屬性,如下所示。
CVFieldCount = 1; 
CVImageBufferChromaLocationBottomField = Left; 
CVImageBufferChromaLocationTopField = Left; 
CVImageBufferColorPrimaries = "SMPTE_C"; 
CVImageBufferTransferFunction = "ITU_R_709_2"; 
CVImageBufferYCbCrMatrix = "ITU_R_601_4"; 
ColorInfoGuessedBy = VideoToolbox; 
        那么,根據H.264文檔,CVFieldCount只是說明CVPixelBuffer只有一個訪問單元(Access Unit),而BottomField和TopField兩個域表達了圖像緩沖區兩個色度的位置,與圖像倒轉無關。其余參數,如YCbCrMatrix只是源視頻需要的YUV轉RGB矩陣。
所以,根據我對Core Video的了解,目前使用Core Video接口無法處理此情況,只能在GPU中通過鏡像紋理坐標或者使用前面介紹的垂直鏡像方式解決。
參考與推薦閱讀
文/熊皮皮(簡書作者)
原文鏈接:http://www.jianshu.com/p/dac9857b34d0
