一、概念
首先了解编码器、容器、采样率等。其余的如图像、视频分辨率;画面更新fps、压缩(视频、音频、帧压缩等)
编解码
编解码器(codec)指的是-一个能够对一个信号或者-一个数据流进行变换的设备或者程序。这里指的变换既包括将信号或者数据流进行编码(通常是为了传输、存储或者加密)或者提取得到一个编码流的操作,也包括为了观察或者处理从这个编码流中恢复适合观察或操作的形式的操作。编解码器经常用在视频会议和流媒体等应用中。
容器
很多多媒体数据流需要同时包含音频数据和视频数据,这时通常会加入一些用于音频和视频数据同步的元数据,例如字幕。这 三种数据流可能会被不同的程序,进程或者硬件处理,但是当它们传输或者存储的时候,这三种数据通常是被封装在一起的。通常这种封装是通过视频文件格式来实现的,例如常见的*.mpg, *.avi, *.mov, * .mp4, *.rm, *.ogg or *.tta.这些格式中有些只能使用某些编解器,而更多可以以容器的方式使用各种编解码器。
采样率
采样率(也称为采样速度或者采样频率)定义了每秒从连续信号中提取并组成离散信号的采样个数,它用赫兹(Hz)来表示。采样频率的倒数叫作采样周期或采样时间,它是采样之间的时间间隔。注意不要将采样率与比特率(bitrate, 亦称一位速率II )相混淆。
二、视频文件
视频文件和播放
视频文件可被感知的有两个方面:视频
和音频
,一个完整的视频文件中,可能包含着不同场景的多个子视频,不同的多个子音频。
我们将这些视频码流
、音频码流
进行封装格式数据,形成我们常见的MP4、MKV、AVI文件,就可以在网络上进行传输了。
当我们拿到一个视频文件时,我们可以通过支持该格式的视频播放软件进行播放,播放主要包括如下步骤:
解封装
: 分别得到压缩后的音频数据和视频数据。常见的音频压缩数据包括:AAC、MP3等等,而视频则包括H.264、MPEG等等。解码
:调用解码器对音视频分别解码,得到相应的采样数据:音频采样数据(PCM),视频像素数据(YUV)。音视频同步
播放
:通过显示器、音响进行播放。
三、编码
1. 为什么要编码?
在文件编码这块主要由两个目的,其一是形成统一的数据形式,以便于存储和传输
,第二是为了删除冗余数据
。
试想一下,一个1080P 30帧,32bit色彩 时长为1秒的视频文件,如果按每一帧画面进行存储的话,数据大小将会达到:
32bit * 30 * 1080 * 1920 ≈ 237MB的空间,除非有特殊的需求,这种方式存储、传输视频显然是不可接受的。
如果我们采取编码算法,例如MPEG4、H.264等等算法对视频文件进行去冗余,压缩后,那么实际上得到的文件大小会大大降低。
2. 数据冗余
前面说到,编码的主要目的是为了压缩,各种编码方式都是为了让视频体积小,核心的思想就是:去除冗余信息,冗余信息主要包括:
1. 空间冗余:图像内部相邻像素之间存在较强的相关性造成的冗余。
例如这样一张视频截图,在背景色全部是黑色的情况下,我们实际上没有必要按照视频大小(1124*772)存储黑色,我们可以将存储黑色的像素点抽离出来记录,只存储其他像素点的颜色即可。
2. 时间冗余:视频图像序列中不同帧
之间的相关性造成的冗余
简单地说就是帧A和帧B是前后帧的关系,并且两个帧之间画面变化相对较小,那么帧B就完全没有必要存储一个完整的画面帧,记录变化即可。
3. 视觉冗余
人眼难以感知到或者说不敏感的部分图像数据可以压缩存储。
例如,对于图像的编码和解码处理时,由于压缩或量比截断引入了噪声而使图像发生了一些变化,如果这些变化不能为视觉所感知,则仍认为图像足够好。
事实上人类视觉系统一般的分辨能力约为26灰度等级,而一般图像量化采用28灰度等级,这类冗余我们称为视觉冗余。
4. 信息熵冗余
也称编码冗余,人们用于表达某一信息所需要的比特位数
总比理论上表示该信息所需要的最小比特数
来的大,这之间的差距就成为信息熵冗余。
熵编码: 哈夫曼算法:表示 111122222可以表示成:4152,九个bit的数据用4个bit即可表示。
四、音视编码详解
音视频编码过程
常见音频编码器
- 常见的音频编码器包括OPUS、AAC、 Ogg、 Speex、 iLBC、
AMR、G.711等 - 其中, AAC在直播系统中应用的比较广泛; OPUS是较新的音编
码器, WebRTC默认使用OPUS ;固话一般用的G.711系列 - 网上评测结果: OPUS> AAC > Ogg
什么是音频重采样
将音频三元组(采样率,采样大小和通道数)的值转成另外一-组值;
例如:将44100/16/2转成48000/16/2
为什么重采样
- 从设备采集的音频数据与编码器要求的数据不一致
- 扬声器要求的音频数据与要播放的音频数据不一致
- 更方便运算
五、H264解析
H264编码规则
- 在相邻几幅图像画面中,- 般有差别的像素只有109%6以内的点亮度差值变化不超过296,而色度差值的变化只有196以内所以对于一 段变化不大图像画面, 我们可以先编码出一个完整的图像帧A
- 随后的B帧就不编码全部图像,只写入与A帧的差别,这样8帧的大小就只有完整帧的1/ 10或更小! B帧之后的C帧如果变化不大,我们可以继续以参考B的方式编码C帧,这样循环下去。
- 这段图像我们称为一个序列:列就是有相同特点的一段数据当某个图像 与之前的图像变化很大,无法参考前 面的帧来生成,那我们就结束上一个序列,开始下一段序列也就是对这个图像生成一 个完整帧A1, 随后的图像就参考A1生成,只写入与AI的差别内容
帧
帧内预测是根据帧内已经编码的样本,为当前的样本计算出一个预测值,用当前样本值减去预测值得到一个残差值,目的就是为了减少传输的数据量。
- NALU 以 0000 0001划分开
- yuv420p一个pix占用字节数1.5Byte
rgb 8bit位深,3通道(不含透明度),一个pix占用3Bytes
- h264编码(pix:640*480 yuv420p fps=15 500kbps)常见压缩比1%
常见电影_fps>=60; 视频直播_fps>=15
- b帧多的缺点,占用cpu;解码耗时;不宜直播
实时:i+p;转码:大量b帧,为减小存储
-
IDR帧,特殊的I帧,解码立即刷新帧,由于每个GOP间明显的差别
- 特点:解码端遇到IDR会将缓存清空,重新解码
- 每个GOP中第一帧就是IDR
-
h264默认编码(不是编码顺序,可以看作gop中帧顺序)
IBBBPBBBPBBBI
B帧间无相互关系,B帧参考始终是之前的I和之后的P帧 -
SPS PPS 在每个IDR帧前都会成对出现这两种帧(参数术语)
- SPS 参数序列集(帧内参数,约束gop的参数)
SPS_ID 帧数 参考帧数量(可参考一帧,亦可参考多帧) 解码图像尺寸 编码模式 - PPS 图像参数集(约束GOP中每帧的参数)
ID 熵编码 帧编码 片组数目(帧编码) 初始量化参数 区块滤波系数
- SPS 参数序列集(帧内参数,约束gop的参数)
-
帧内压缩理论:
- 1.相邻像素差别不大,有宏块预测的基础
- 2.人眼对亮度的敏感超过色度
- 3.yuv分开存储,利于压缩
-
宏块预测有9种模式
-
残差值 = 原始图像 - 预测出的图像
压缩时,预测图象压缩+残差值压缩(补偿残差);主要用在帧内压缩
运动补偿:在解码时将残差值的影响考虑在内 -
运动估计:根据宏块匹配的手段找到运动矢量的过程称运动估计
-
宏块查找:目的是找到宏块的运动轨迹(运动矢量)
-
宏块查找算法:
三步搜索 二位对数搜索 四步搜索 钻石搜索 -
压缩编码的主要手段是:运动矢量+补偿压缩(残差值补偿),图像还原的原理也是依据这两点
-
花屏,马赛克原因:GOP中丢帧(主要丢失的是P,B帧,运动矢量,残差值也丢失)
-
花屏的兄弟-卡顿:当GOP丢帧时,就丢弃掉GOP内所有帧,直到下一个GOP的IDR帧到来;这种情况的刷新周期就取决于IDR帧间隔(I帧间隔) 卡顿和花屏不能兼得,互斥关系
-
无损压缩示意图
常见封装编码示意图
H264编码顺序
与帧相似程度极高达到95%6以上编码成B像是程度7096编码成P帧。如何编码不需要程序员来实现,已经由x264这个工具帮我们做了
图片
六、编码准备
视频帧就行编码。大致的流程分为三步:
- 准备编码器,即创建session:VTCompressionSessionCreate,并设置编码器属性;
- 开始编码:VTCompressionSessionEncodeFrame
- 编码完成的回调里处理数据:添加起始码**“\x00\x00\x00\x01”,添加sps pps**等。
- 结束编码,清除数据,释放资源。
准备编码器
- 创建session : VTCompressionSessionCreate
- 设置属性:VTSessionSetProperty 是否实时编码输出、是否产生B帧、设置关键帧、设置期望帧率、设置码率、最大码率值等等
- 准备开始编码:VTCompressionSessionPrepareToEncodeFrames
-(void)initVideoToolBox
{
// cEncodeQueue是一个串行队列
dispatch_sync(cEncodeQueue, ^{
frameID = 0;
int width = 480,height = 640;
//创建编码session
OSStatus status = VTCompressionSessionCreate(NULL, width, height, kCMVideoCodecType_H264, NULL, NULL, NULL, didCompressH264, (__bridge void *)(self), &cEncodeingSession);
NSLog(@"H264:VTCompressionSessionCreate:%d",(int)status);
if (status != 0) {
NSLog(@"H264:Unable to create a H264 session");
return ;
}
//设置实时编码输出(避免延迟)
VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_RealTime, kCFBooleanTrue);
VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_ProfileLevel,kVTProfileLevel_H264_Baseline_AutoLevel);
//是否产生B帧(因为B帧在解码时并不是必要的,是可以抛弃B帧的)
VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_AllowFrameReordering, kCFBooleanFalse);
//设置关键帧(GOPsize)间隔,GOP太小的话图像会模糊
int frameInterval = 10;
CFNumberRef frameIntervalRaf = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &frameInterval);
VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_MaxKeyFrameInterval, frameIntervalRaf);
//设置期望帧率,不是实际帧率
int fps = 10;
CFNumberRef fpsRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberIntType, &fps);
VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_ExpectedFrameRate, fpsRef);
//码率的理解:码率大了话就会非常清晰,但同时文件也会比较大。码率小的话,图像有时会模糊,但也勉强能看
//码率计算公式,参考印象笔记
//设置码率、上限、单位是bps
int bitRate = width * height * 3 * 4 * 8;
CFNumberRef bitRateRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &bitRate);
VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_AverageBitRate, bitRateRef);
//设置码率,均值,单位是byte
int bigRateLimit = width * height * 3 * 4;
CFNumberRef bitRateLimitRef = CFNumberCreate(kCFAllocatorDefault, kCFNumberSInt32Type, &bigRateLimit);
VTSessionSetProperty(cEncodeingSession, kVTCompressionPropertyKey_DataRateLimits, bitRateLimitRef);
//准备开始编码
VTCompressionSessionPrepareToEncodeFrames(cEncodeingSession);
});
}
复制代码
VTCompressionSessionCreate创建编码对象参数详解:
- allocator:NULL 分配器,设置NULL为默认分配
- width:width
- height:height
- codecType:编码类型,如kCMVideoCodecType_H264
- encoderSpecification:NULL encoderSpecification: 编码规范。设置NULL由videoToolbox自己选择
- sourceImageBufferAttributes:NULL sourceImageBufferAttributes: 源像素缓冲区属性.设置NULL不让videToolbox创建,而自己创建
- compressedDataAllocator:压缩数据分配器.设置NULL,默认的分配
- outputCallback:编码回调 , 当VTCompressionSessionEncodeFrame被调用压缩一次后会被异步调用.这里设置的函数名是 didCompressH264
- outputCallbackRefCon:回调客户定义的参考值,此处把self传过去,因为我们需要在C函数中调用self的方法,而C函数无法直接调self
- compressionSessionOut: 编码会话变量
开始编码
- 拿到未编码的视频帧: CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
- 设置帧时间:CMTime presentationTimeStamp = CMTimeMake(frameID++, 1000);
- 开始编码:调用 VTCompressionSessionEncodeFrame进行编码
- (void)captureOutput:(AVCaptureOutput *)captureOutput didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection
{
//开始视频录制,获取到摄像头的视频帧,传入encode 方法中
dispatch_sync(cEncodeQueue, ^{
[self encode:sampleBuffer];
});
}
复制代码
- (void) encode:(CMSampleBufferRef )sampleBuffer
{
//拿到每一帧未编码数据
CVImageBufferRef imageBuffer = (CVImageBufferRef)CMSampleBufferGetImageBuffer(sampleBuffer);
//设置帧时间
CMTime presentationTimeStamp = CMTimeMake(frameID++, 1000);
//开始编码
OSStatus statusCode = VTCompressionSessionEncodeFrame(cEncodeingSession, imageBuffer, presentationTimeStamp, kCMTimeInvalid, NULL, NULL, &flags);
if (statusCode != noErr) {
//编码失败
NSLog(@"H.264:VTCompressionSessionEncodeFrame faild with %d",(int)statusCode);
//释放资源
VTCompressionSessionInvalidate(cEncodeingSession);
CFRelease(cEncodeingSession);
cEncodeingSession = NULL;
return;
}
}
复制代码
VTCompressionSessionEncodeFrame编码函数参数详解:
- session :编码会话变量
- imageBuffer:未编码的数据
- presentationTimeStamp:获取到的这个sample buffer数据的展示时间戳。每一个传给这个session的时间戳都要大于前一个展示时间戳
- duration:对于获取到sample buffer数据,这个帧的展示时间.如果没有时间信息,可设置kCMTimeInvalid.
- frameProperties:包含这个帧的属性.帧的改变会影响后边的编码帧.
- sourceFrameRefcon:回调函数会引用你设置的这个帧的参考值.
- infoFlagsOut:指向一个VTEncodeInfoFlags来接受一个编码操作.如果使用异步运行,kVTEncodeInfo_Asynchronous被设置;同步运行,kVTEncodeInfo_FrameDropped被设置;设置NULL为不想接受这个信息.
编码完成后数据处理
- 判断是否是关键帧:是的话,CMVideoFormatDescriptionGetH264ParameterSetAtIndex获取sps和pps信息,并转换为二进制写入文件或者进行上传
- 组装NALU数据: 获取编码后的h264流数据:CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer),通过 首地址 、单个长度、 总长度通过dataPointer指针偏移做遍历 OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer); 读取数据时有个大小端模式:网络传输一般都是大端模式
/*
1.H264硬编码完成后,回调VTCompressionOutputCallback
2.将硬编码成功的CMSampleBuffer转换成H264码流,通过网络传播
3.解析出参数集SPS & PPS,加上开始码组装成 NALU。提现出视频数据,将长度码转换为开始码,组成NALU,将NALU发送出去。
*/
void didCompressH264(void *outputCallbackRefCon, void *sourceFrameRefCon, OSStatus status, VTEncodeInfoFlags infoFlags, CMSampleBufferRef sampleBuffer)
{
NSLog(@"didCompressH264 called with status %d infoFlags %d",(int)status,(int)infoFlags);
//状态错误
if (status != 0) {
return;
}
//没准备好
if (!CMSampleBufferDataIsReady(sampleBuffer)) {
NSLog(@"didCompressH264 data is not ready");
return;
}
ViewController *encoder = (__bridge ViewController *)outputCallbackRefCon;
//判断当前帧是否为关键帧
CFArrayRef array = CMSampleBufferGetSampleAttachmentsArray(sampleBuffer, true);
CFDictionaryRef dic = CFArrayGetValueAtIndex(array, 0);
bool keyFrame = !CFDictionaryContainsKey(dic, kCMSampleAttachmentKey_NotSync);
//判断当前帧是否为关键帧
//获取sps & pps 数据 只获取1次,保存在h264文件开头的第一帧中
//sps(sample per second 采样次数/s),是衡量模数转换(ADC)时采样速率的单位
//pps()
if (keyFrame) {
//图像存储方式,编码器等格式描述
CMFormatDescriptionRef format = CMSampleBufferGetFormatDescription(sampleBuffer);
//sps
size_t sparameterSetSize,sparameterSetCount;
const uint8_t *sparameterSet;
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 0, &sparameterSet, &sparameterSetSize, &sparameterSetCount, 0);
if (statusCode == noErr) {
//获取pps
size_t pparameterSetSize,pparameterSetCount;
const uint8_t *pparameterSet;
//从第一个关键帧获取sps & pps
OSStatus statusCode = CMVideoFormatDescriptionGetH264ParameterSetAtIndex(format, 1, &pparameterSet, &pparameterSetSize, &pparameterSetCount, 0);
//获取H264参数集合中的SPS和PPS
if (statusCode == noErr)
{
NSData *sps = [NSData dataWithBytes:sparameterSet length:sparameterSetSize];
NSData *pps = [NSData dataWithBytes:pparameterSet length:pparameterSetSize];
if(encoder)
{
[encoder gotSpsPps:sps pps:pps];
}
}
}
}
CMBlockBufferRef dataBuffer = CMSampleBufferGetDataBuffer(sampleBuffer);
size_t length,totalLength;
char *dataPointer;
OSStatus statusCodeRet = CMBlockBufferGetDataPointer(dataBuffer, 0, &length, &totalLength, &dataPointer);
if (statusCodeRet == noErr) {
size_t bufferOffset = 0;
static const int AVCCHeaderLength = 4;//返回的nalu数据前4个字节不是001的startcode,而是大端模式的帧长度length
//循环获取nalu数据
while (bufferOffset < totalLength - AVCCHeaderLength) {
uint32_t NALUnitLength = 0;
//读取 一单元长度的 nalu
memcpy(&NALUnitLength, dataPointer + bufferOffset, AVCCHeaderLength);
//从大端模式转换为系统端模式
NALUnitLength = CFSwapInt32BigToHost(NALUnitLength);
//获取nalu数据
NSData *data = [[NSData alloc]initWithBytes:(dataPointer + bufferOffset + AVCCHeaderLength) length:NALUnitLength];
//将nalu数据写入到文件
[encoder gotEncodedData:data isKeyFrame:keyFrame];
//move to the next NAL unit in the block buffer
//读取下一个nalu 一次回调可能包含多个nalu数据
bufferOffset += AVCCHeaderLength + NALUnitLength;
}
}
}
//第一帧写入 sps & pps
- (void)gotSpsPps:(NSData*)sps pps:(NSData*)pps
{
const char bytes[] = "\x00\x00\x00\x01";
size_t length = (sizeof bytes) - 1; // 最后一位是\0结束符
NSData *ByteHeader = [NSData dataWithBytes:bytes length:length];
[fileHandele writeData:ByteHeader];
[fileHandele writeData:sps];
[fileHandele writeData:ByteHeader];
[fileHandele writeData:pps];
}
- (void)gotEncodedData:(NSData*)data isKeyFrame:(BOOL)isKeyFrame
{
if (fileHandele != NULL) {
//添加4个字节的H264 协议 start code 分割符
//一般来说编码器编出的首帧数据为PPS & SPS
//H264编码时,在每个NAL前添加起始码 0x00000001,解码器在码流中检测起始码,当前NAL结束。
const char bytes[] ="\x00\x00\x00\x01";
//长度
size_t length = (sizeof bytes) - 1;
//头字节
NSData *ByteHeader = [NSData dataWithBytes:bytes length:length];
//写入头字节
[fileHandele writeData:ByteHeader];
//写入H264数据
[fileHandele writeData:data];
}
}
复制代码
结束编码
-(void)endVideoToolBox
{
VTCompressionSessionCompleteFrames(cEncodeingSession, kCMTimeInvalid);
VTCompressionSessionInvalidate(cEncodeingSession);
CFRelease(cEncodeingSession);
cEncodeingSession = NULL;
}
七、结尾
本篇就介绍了音视频的编码原理,及部分解析。更多H264以及H265编码,以及ffmpeg 实现。可以前往这里直达学习;里面内容解析了从入门到精通的详细教学。以及FFmpeg的实战笔录。