iOS视频开发(二):视频H264硬编码

前言

系列文章:
《iOS视频开发(一):视频采集》
《iOS视频开发(二):视频H264硬编码》
《iOS视频开发(三):视频H264硬解码》
《iOS视频开发(四):通俗理解YUV数据》

上一篇《iOS视频开发(一):视频采集》我们已经介绍了如何采集iOS摄像头的视频数据,采集到的原始视频数据量是比较大的,这么大的数据量不利于进行储存或网络传输。于是我们需要对视频数据进行压缩,就像你要向别人传文件时觉得文件太大了,打个rar压缩包再发给对方的道理一样。视频数据的压缩也叫做编码,H264是一种视频编码格式,iOS 8.0及以上苹果开放了VideoToolbox框架来实现H264硬编码,开发者可以利用VideoToolbox框架很方便地实现视频的硬编码。下面我们将分以下几部分内容来讲解H264硬编码在iOS中的实现:
1、介绍视频编码的基本概念
2、VideoToolbox实现硬编码原理及流程
3、代码实现硬编码
4、总结及Demo


基本概念

视频数据为什么可以压缩呢,因为视频数据存在冗余。通俗地理解,例如一个视频中,前一秒画面跟当前的画面内容相似度很高,那么这两秒的数据我们是不是可以不用全部保存,只保留一个完整的画面,下一个画面看有哪些地方有变化了记录下来,拿视频去播放的时候我们就按这个完整的画面和其他有变化的地方把其他画面也恢复出来。记录画面不同然后保存下来这个过程就是数据编码,根据不同的地方恢复画面的过程就是数据解码。

H264是一种视频编码标准,具体牛逼在哪儿这里就不赘述了,点击去看百科吧。
在H264协议里定义了三种帧:

  • I帧:完整编码的帧,也叫关键帧
  • P帧:参考之前的I帧生成的只包含差异部分编码的帧
  • B帧:参考前后的帧编码的帧叫B帧

H264采用的核心算法是帧内压缩和帧间压缩,帧内压缩是生成I帧的算法,帧间压缩是生成B帧和P帧的算法。
H264原始码流是由一个接一个的NALU(Nal Unit)组成的,NALU = 开始码 + NAL类型 + 视频数据
开始码用于标示这是一个NALU 单元的开始,必须是"00 00 00 01" 或"00 00 01"
NALU类型如下:

类型 说明
0 未规定
1 非IDR图像中不采用数据划分的片段
2 非IDR图像中A类数据划分片段
3 非IDR图像中B类数据划分片段
4 非IDR图像中C类数据划分片段
5 IDR图像的片段
6 补充增强信息(SEI)
7 序列参数集(SPS)
8 图像参数集(PPS)
9 分割符
10 序列结束符
11 流结束符
12 填充数据
13 序列参数集扩展
14 带前缀的NAL单元
15 子序列参数集
16 – 18 保留
19 不采用数据划分的辅助编码图像片段
20 编码片段扩展
21 – 23 保留
24 – 31 未规定

一般我们只用到了1、5、7、8这4个类型就够了。类型为5表示这是一个I帧,I帧前面必须有SPS和PPS数据,也就是类型为7和8,类型为1表示这是一个P帧或B帧。

帧率:单位为fps(frame pre second),视频画面每秒有多少帧画面,数值越大画面越流畅
码率:单位为bps(bit pre second),视频每秒输出的数据量,数值越大画面越清晰
分辨率:视频画面像素密度,例如常见的720P、1080P等
关键帧间隔:每隔多久编码一个关键帧
软编码:使用CPU进行编码。性能较差
硬编码:不使用CPU进行编码,使用显卡GPU,专用的DSP、FPGA、ASIC芯片等硬件进行编码。性能较好

VideoToolbox实现H264硬编码

iOS8.0及以上我们可以通过VideoToolbox实现视频数据的硬编解码。VideoToolbox基本数据结构:

  • CVPixelBufferRef/CVImageBufferRef:存放编码前和解码后的图像数据,这俩货其实是同一个东西
  • CMTime:时间戳相关,时间以64-bit/32-bit的形式出现
  • CMBlockBufferRef:编码后输出的数据
  • CMFormatDescriptionRef/CMVideoFormatDescriptionRef:图像存储方式,编解码器等格式描述。这俩货也是同一个东西
  • CMSampleBufferRef:存放编解码前后的视频图像的容器数据
    CMSampleBuffer编解码前后数据结构
基本步骤

1、通过VTCompressionSessionCreate创建编码器
2、通过VTSessionSetProperty设置编码器属性
3、设置完属性调用VTCompressionSessionPrepareToEncodeFrames准备编码
4、输入采集到的视频数据,调用VTCompressionSessionEncodeFrame进行编码
5、获取到编码后的数据并进行处理
6、调用VTCompressionSessionCompleteFrames停止编码器
7、调用VTCompressionSessionInvalidate销毁编码器

1、创建编码器

VTCompressionSessionCreate用来创建视频编码会话,这个方法有10个参数,我们可以看一下苹果对这个API的注释

VTCompressionSessionCreate(
    CM_NULLABLE CFAllocatorRef                          allocator,
    int32_t                                             width,
    int32_t                                             height,
    CMVideoCodecType                                    codecType,
    CM_NULLABLE CFDictionaryRef                         encoderSpecification,
    CM_NULLABLE CFDictionaryRef                         sourceImageBufferAttributes,
    CM_NULLABLE CFAllocatorRef                          compressedDataAllocator,
    CM_NULLABLE VTCompressionOutputCallback             outputCallback,
    void * CM_NULLABLE                                  outputCallbackRefCon,
    CM_RETURNS_RETAINED_PARAMETER CM_NULLABLE VTCompressionSessionRef * CM_NONNULL compressionSessionOut)

allocator:内存分配器,填NULL为默认分配器
widthheight:视频帧像素的宽高,如果编码器不支持这个宽高的话可能会改变
codecType:编码类型,枚举
encoderSpecification:指定特定的编码器,填NULL的话由VideoToolBox自动选择
sourceImageBufferAttributes:源像素缓冲区的属性,如果这个参数有值的话,VideoToolBox会创建一个缓冲池,不需要缓冲池可以设置为NULL
compressedDataAllocator:压缩后数据的内存分配器,填NULL使用默认分配器
outputCallback:视频编码后输出数据回调函数
outputCallbackRefCon:回调函数中的自定义指针,我们通常传self,在回调函数中就可以拿到当前类的方法和属性了
compressionSessionOut:编码器句柄,传入编码器的指针

OSStatus status = VTCompressionSessionCreate(NULL, 180, 320, kCMVideoCodecType_H264, NULL, NULL, NULL, encodeOutputDataCallback, (__bridge void *)(self), &_compressionSessionRef);
2、设置编码器属性 & 准备编码

编码器创建完了,所有给编码器设置属性都是调用VTSessionSetProperty方法来实现。

kVTCompressionPropertyKey_AverageBitRate:设置编码的平均码率,单位是bps,这不是一个硬性指标,设置的码率会上下浮动。VideoToolBox框架只支持ABR模式。H264有4种码率控制方法:

  • CBR(Constant Bit Rate)是以恒定比特率方式进行编码,有Motion发生时,由于码率恒定,只能通过增大QP来减少码字大小,图像质量变差,当场景静止时,图像质量又变好,因此图像质量不稳定。这种算法优先考虑码率(带宽)。
  • VBR(Variable Bit Rate)动态比特率,其码率可以随着图像的复杂程度的不同而变化,因此其编码效率比较高,Motion发生时,马赛克很少。码率控制算法根据图像内容确定使用的比特率,图像内容比较简单则分配较少的码率(似乎码字更合适),图像内容复杂则分配较多的码字,这样既保证了质量,又兼顾带宽限制。这种算法优先考虑图像质量。
    *CVBR(Constrained VariableBit Rate),这样翻译成中文就比较难听了,它是VBR的一种改进方法。但是Constrained又体现在什么地方呢?这种算法对应的Maximum bitRate恒定或者Average BitRate恒定。这种方法的兼顾了以上两种方法的优点:在图像内容静止时,节省带宽,有Motion发生时,利用前期节省的带宽来尽可能的提高图像质量,达到同时兼顾带宽和图像质量的目的。
  • ABR (Average Bit Rate) 在一定的时间范围内达到设定的码率,但是局部码率峰值可以超过设定的码率,平均码率恒定。可以作为VBR和CBR的一种折中选择。

kVTCompressionPropertyKey_ProfileLevel:设置H264编码的画质,H264有4种Profile:BP、EP、MP、HP

BP(Baseline Profile):基本画质。支持I/P 帧,只支持无交错(Progressive)和CAVLC;主要应用:可视电话,会议电视,和无线通讯等实时视频通讯领域
EP(Extended profile):进阶画质。支持I/P/B/SP/SI 帧,只支持无交错(Progressive)和CAVLC;
MP(Main profile):主流画质。提供I/P/B 帧,支持无交错(Progressive)和交错(Interlaced),也支持CAVLC 和CABAC 的支持;主要应用:数字广播电视和数字视频存储
HP(High profile):高级画质。在main Profile 的基础上增加了8×8内部预测、自定义量化、 无损视频编码和更多的YUV 格式;应用于广电和存储领域

Level就多了,这里不一一列举,可参考h264 profile & level,iPhone上常用的方案如下:

  • 实时直播:
    低清Baseline Level 1.3
    标清Baseline Level 3
    半高清Baseline Level 3.1
    全高清Baseline Level 4.1
  • 存储媒体:
    低清 Main Level 1.3
    标清 Main Level 3
    半高清 Main Level 3.1
    全高清 Main Level 4.1
  • 高清存储:
    半高清 High Level 3.1
    全高清 High Level 4.1

kVTCompressionPropertyKey_RealTime:设置是否实时编码输出
kVTCompressionPropertyKey_AllowFrameReordering:配置是否产生B帧,High profile 支持 B 帧
kVTCompressionPropertyKey_MaxKeyFrameIntervalkVTCompressionPropertyKey_MaxKeyFrameIntervalDuration:配置I帧间隔

设置完编码器的属性后,调用VTCompressionSessionPrepareToEncodeFrames准备编码

// 设置码率 512kbps
OSStatus status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_AverageBitRate, (__bridge CFTypeRef)@(512 * 1024));
// 设置ProfileLevel为BP3.1
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_ProfileLevel, kVTProfileLevel_H264_Baseline_3_1);
// 设置实时编码输出(避免延迟)
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
// 配置是否产生B帧
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_AllowFrameReordering, self.videoEncodeParam.allowFrameReordering ? kCFBooleanTrue : kCFBooleanFalse);
// 配置最大I帧间隔  15帧 x 240秒 = 3600帧,也就是每隔3600帧编一个I帧
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_MaxKeyFrameInterval, (__bridge CFTypeRef)@(self.videoEncodeParam.frameRate * self.videoEncodeParam.maxKeyFrameInterval));
// 配置I帧持续时间,240秒编一个I帧
status = VTSessionSetProperty(_compressionSessionRef, kVTCompressionPropertyKey_MaxKeyFrameIntervalDuration, (__bridge CFTypeRef)@(self.videoEncodeParam.maxKeyFrameInterval));
// 编码器准备编码
status = VTCompressionSessionPrepareToEncodeFrames(_compressionSessionRef);
3、输入待编码的视频数据

向编码器输送待编码的视频数据,通过调用VTCompressionSessionEncodeFrame方法实现。

VTCompressionSessionEncodeFrame(
    CM_NONNULL VTCompressionSessionRef  session,
    CM_NONNULL CVImageBufferRef         imageBuffer,
    CMTime                              presentationTimeStamp,
    CMTime                              duration, // may be kCMTimeInvalid
    CM_NULLABLE CFDictionaryRef         frameProperties,
    void * CM_NULLABLE                  sourceFrameRefCon,
    VTEncodeInfoFlags * CM_NULLABLE     infoFlagsOut )

session:创建编码器时的句柄
imageBuffer:YUV数据,iOS通过摄像头采集出来的视频流数据类型是CMSampleBufferRef,我们要从里面拿到CVImageBufferRef来进行编码。通过CMSampleBufferGetImageBuffer方法可以从sampleBuffer中获得imageBuffer。
presentationTimeStamp:这一帧的时间戳,单位是毫秒
duration:这一帧的持续时间,如果没有持续时间,填kCMTimeInvalid
frameProperties:指定这一帧的属性,这里我们可以用来指定产生I帧
encodeParams:自定义指针
infoFlagsOut:用于接收编码操作的信息,不需要就置为NULL

// 获取CVImageBufferRef
CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
// 设置是否为I帧
NSDictionary *frameProperties = @{(__bridge NSString *)kVTEncodeFrameOptionKey_ForceKeyFrame: @(forceKeyFrame)};;
// 输入待编码数据
OSStatus status = VTCompressionSessionEncodeFrame(_compressionSessionRef, imageBuffer, kCMTimeInvalid, kCMTimeInvalid, (__bridge CFDictionaryRef)frameProperties, NULL, NULL);
4、获取编码后的数据并进行处理

编码后的数据通过VTCompressionSessionCreate方法设置的回调函数返回。编码后的数据以及这一帧的基本信息都在CMSampleBufferRef中。如果这一帧是个关键帧,那么我们需要获取SPS和PPS数据,然后给这些数据加个开始码返回出去。

VEVideoEncoder *encoder = (__bridge VEVideoEncoder *)outputCallbackRefCon;
// 开始码
const char header[] = "\x00\x00\x00\x01";
size_t headerLen = (sizeof header) - 1;
NSData *headerData = [NSData dataWithBytes:header length:headerLen];

// 判断是否是关键帧
bool isKeyFrame = !CFDictionaryContainsKey((CFDictionaryRef)CFArrayGetValueAtIndex(CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true), 0), (const void *)kCMSampleAttachmentKey_NotSync);

if (isKeyFrame)
{
    NSLog(@"VEVideoEncoder::编码了一个关键帧");
    CMFormatDescriptionRef formatDescriptionRef = CMSampleBufferGetFormatDescription(sampleBuffer);
    
    // 关键帧需要加上SPS、PPS信息
    size_t sParameterSetSize, sParameterSetCount;
    const uint8_t *sParameterSet;
    OSStatus spsStatus = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDescriptionRef, 0, &sParameterSet, &sParameterSetSize, &sParameterSetCount, 0);
    
    size_t pParameterSetSize, pParameterSetCount;
    const uint8_t *pParameterSet;
    OSStatus ppsStatus = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(formatDescriptionRef, 1, &pParameterSet, &pParameterSetSize, &pParameterSetCount, 0);
    
    if (noErr == spsStatus && noErr == ppsStatus)
    {
        // sps数据加上开始码组成NALU
        NSData *sps = [NSData dataWithBytes:sParameterSet length:sParameterSetSize];
        NSMutableData *spsData = [NSMutableData data];
        [spsData appendData:headerData];
        [spsData appendData:sps];
        // 通过代理回调给上层
        if ([encoder.delegate respondsToSelector:@selector(videoEncodeOutputDataCallback:isKeyFrame:)])
        {
            [encoder.delegate videoEncodeOutputDataCallback:spsData isKeyFrame:isKeyFrame];
        }
        // pps数据加上开始码组成NALU
        NSData *pps = [NSData dataWithBytes:pParameterSet length:pParameterSetSize];
        NSMutableData *ppsData = [NSMutableData data];
        [ppsData appendData:headerData];
        [ppsData appendData:pps];
        
        if ([encoder.delegate respondsToSelector:@selector(videoEncodeOutputDataCallback:isKeyFrame:)])
        {
            [encoder.delegate videoEncodeOutputDataCallback:ppsData isKeyFrame:isKeyFrame];
        }
    }
}
// 获取帧数据
CMBlockBufferRef blockBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t length, totalLength;
char *dataPointer;
status = CMBlockBufferGetDataPointer(blockBuffer, 0, &length, &totalLength, &dataPointer);
if (noErr != status)
{
    NSLog(@"VEVideoEncoder::CMBlockBufferGetDataPointer Error : %d!", (int)status);
    return;
}

size_t bufferOffset = 0;
static const int avcHeaderLength = 4;
while (bufferOffset < totalLength - avcHeaderLength)
{
    // 读取 NAL 单元长度
    uint32_t nalUnitLength = 0;
    memcpy(&nalUnitLength, dataPointer + bufferOffset, avcHeaderLength);
    
    // 大端转小端
    nalUnitLength = CFSwapInt32BigToHost(nalUnitLength);
    
    NSData *frameData = [[NSData alloc] initWithBytes:(dataPointer + bufferOffset + avcHeaderLength) length:nalUnitLength];
    
    NSMutableData *outputFrameData = [NSMutableData data];
    [outputFrameData appendData:headerData];
    [outputFrameData appendData:frameData];
    
    bufferOffset += avcHeaderLength + nalUnitLength;
    
    if ([encoder.delegate respondsToSelector:@selector(videoEncodeOutputDataCallback:isKeyFrame:)])
    {
        [encoder.delegate videoEncodeOutputDataCallback:outputFrameData isKeyFrame:isKeyFrame];
    }
}
5、停止编码
OSStatus status = VTCompressionSessionCompleteFrames(_compressionSessionRef, kCMTimeInvalid);
6、释放编码器
VTCompressionSessionInvalidate(_compressionSessionRef);
CFRelease(_compressionSessionRef);
_compressionSessionRef = NULL;

踩坑及总结

在弄视频编解码的时候,发现720P的分辨率,码率1Mbps,在画面晃动的时候马赛克很严重,码率设置的再低一点更严重。一开始我以为是编码器的某些属性漏了设置了,或者是参数设置错了。查阅了很多资料都找不到原因。后来怀疑是ABR模式当画面从静止到晃动码率一下子上不去,导致马赛克,这个假设似乎成立,结果去打印编码出来的码率,画面晃动的时候码率是有上去的,说明这个思路还是不对。后来,我发现,摄像头采集的数据是720P,也就是1280x720的分辨率,我给编码器设置编码宽高的时候也是按1280x720的宽高设给编码器的,但实际上我解码、播放是展示的画面尺寸(像素)只有320x180,于是我尝试了一下把编码的宽高设置为320x180,马赛克问题解决了!在编解码这块我还是个小白,如有大神知道原理还请不吝赐教。

下一篇讲使用VideoToolBox实现硬解码,也补充一下YUV数据的相关知识。
Demo地址:https://github.com/GenoChen/MediaService

推荐阅读:WebRTC Native 源码导读(九):iOS 视频硬编码实现分析

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容

  • 硬件编码相关知识(H264,H265) 阅读人群:研究硬件编码器应用于iOS开发中,从0研究关于硬件编解码,码流中...
    小东邪啊阅读 12,662评论 0 18
  • ### YUV颜色空间 视频是由一帧一帧的数据连接而成,而一帧视频数据其实就是一张图片。 yuv是一种图片储存格式...
    天使君阅读 3,228评论 0 4
  • 视频 视频实质:纯粹的视频(不包括音频)实质上就是一组帧图片,经过视频编码成为视频(video)文件再把音频(au...
    勇敢的_心_阅读 2,875评论 1 30
  • 关键词小矮人,水面上,游船,太热,牛逼,嘴巴歪了 小矮人 小爱人是一个个可爱又活...
    李宇宙rourou阅读 596评论 4 3
  • 咽下所有的苦,吞下所有的痛。成长就是学会默默忍受。不要总是太过善良,悲天悯人。担心对方会不会不开心,其实难过的只有...
    阳光底下的凤尾竹阅读 248评论 0 0