前言
系列文章:
《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
:存放编解码前后的视频图像的容器数据
基本步骤
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为默认分配器
width
、height
:视频帧像素的宽高,如果编码器不支持这个宽高的话可能会改变
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_MaxKeyFrameInterval
、kVTCompressionPropertyKey_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