iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像

本文档基于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相关文档(因开发任务等原因,部分文档处于草稿状态,之后会进行内容修订):

  • iOS VideoToolbox硬编H.265(HEVC)H.264(AVC):1 概述
  • 【草稿】iOS VideoToolbox硬编H.265(HEVC)H.264(AVC):2 H264数据写入文件
  • iOS VideoToolbox硬编H.265(HEVC)H.264(AVC):4 同步编码
  • iOS 音视频高级编程:AVAssetReaderTrackOutput改变CMFormatDescription导致Video Toolbox解码失败与不解码GPU直接显示H.264帧
  • iOS 音视频高级编程:AVAsset、CoreVideo、VideoToolbox、FFmpeg与CMTime
  • Video Toolbox Multi-pass Encoding
  • 获取VideoToolbox解码直播等H.264流的颜色转换矩阵

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实现图像处理操作。下面展示读写左半图像时的性能损耗(请忽略内存计算的粗暴代码)。

iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第1张图片
读取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指定的像素格式。

下面介绍一些简单的图像处理办法。


iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第2张图片
原始灰度图

(一)水平镜像
水平镜像就是图像绕图像中间垂直线交换左右像素点位置,使用矩阵运行表示为:

[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)];
    }
}
iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第3张图片
水平镜像

(二)垂直镜像
垂直镜像就是图像绕图像中间水平线交换上下像素点位置,使用矩阵运行表示为:

[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];
    }
}
iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第4张图片
垂直镜像

3、CVPixelBufferPool内存池

自行创建CVPixelBufferPool且通过CVPixelBufferPool创建CVPixelBuffer,容易出现CVPixelBuffer被错误释放或意外增加引用计数导致内存泄露,以ijkplayer为例演示CVPixelBubffer泄露的情况。

iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第5张图片
CVPixelBuffer泄露
iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第6张图片
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函数创建灰度图可直接看到图像发生的变化,缺点是,丢失了颜色信息。示意图如下所示。

iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第7张图片
生成灰度图

虽然,像素格式为YUV的视频解码后几乎都可生成灰度图。然而,并不是所有的图像原始数据都能通过Core Graphics生成可视图像,iOS支持的像素格式非常有限,如下所示。

iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第8张图片
生成灰度图支持的像素格式

5、坑

操作CVImageBuffer(CVPixelBuffer)虽然看着没什么难度,然而,还是有些大大小小的问题。如果对此不作描述,那么本文档的标题真是太标题党了。下面,给出我在开发过程中遇到并解决的情况。

5.1、直接操作解码回调的CVImageBuffer(CVPixelBuffer)存在的问题

在解码回调函数中进行YUV处理,无论是否同步解码,或者解码与创建纹理、刷新界面是否为同一线程。需要注意的是,解码回调得到的CVPixelBuffer中的图像是上一次解码回调中处理过的图像,而非视频压缩数据通过解码得到的新的完整图像。换句话说,在一个关键帧解码成功后,其后续P帧以前一帧为基础,继续解码并将结果叠加到新画面,然后传递到解码回调函数。简单示意之。

Decode Thread: VTDecompressionSessionDecodeFrame -> VTDecoderCallback (进行图像处理) -> 添加到待显示队列
Rendering Thread: 读取待显示队列、得到已处理的CVPixelBuffer -> CVOpenGLESTextureCacheCreateTextureFromImage

下面,详细讨论上述情况。进行YUV三个通道处理后,播放出来的画面看着正常,相关资源占用信息如下所示。然而,经输出Video Toolbox回调函数传递过来的CVPixelBuffer或说CVImageBuffer,发现是之前我们处理过的图像,并在上一关键帧基础上持续叠加P帧,把结果图像作为下一帧视频。

iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第9张图片
CPU不超负荷的资源占用
iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第10张图片
CPU不超负荷的GPU占用
iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第11张图片
CPU不超负荷的Y通道图
iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像_第12张图片
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中通过镜像纹理坐标或者使用前面介绍的垂直镜像方式解决。

参考与推荐阅读

  • Create CVPixelBuffer from YUV with IOSurface backed

你可能感兴趣的:(iOS Video Toolbox:读写解码回调函数CVImageBufferRef的YUV图像)