H264码流结构一探究竟

2,107 阅读12分钟

更多博文,请看音视频系统学习的浪漫马车之总目录

实践项目:  介绍一个自己刚出炉的安卓音视频播放录制开源项目

视频理论基础:
视频基础知识扫盲
音视频开发基础知识之YUV颜色编码
解析H264视频编码原理——从孙艺珍的电影说起(一)
解析H264视频编码原理——从孙艺珍的电影说起(二)
H264码流结构一探究竟

Android平台MediaCodec系列:
Android硬编解码利器MediaCodec解析——从猪肉餐馆的故事讲起(一)
Android硬编解码工具MediaCodec解析——从猪肉餐馆的故事讲起(二)
Android硬编解码工具MediaCodec解析——从猪肉餐馆的故事讲起(三)

轻松入门OpenGL系列
一看就懂的OpenGL ES教程——图形渲染管线的那些事
一看就懂的OpenGL ES教程——再谈OpenGL工作机制
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(一)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(二)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(三)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(四)
一看就懂的OpenGL ES教程——这或许是你遇过最难画的三角形(五)

上两篇博文
解析H264视频编码原理——从孙艺珍的电影说起(一)
解析H264视频编码原理——从孙艺珍的电影说起(二)
已经比较详细地叙述了一个视频从原始的yuv数据流如何转化为一个H264码流,那么今天就来讲一讲整个过程的最终产物H264码流究竟是什么样子的。如果还没看过这两篇博文,建议先看一下,不然本文说的很多概念会不理解。

如果你愿意一层一层,一层地剥开我的心,你会发现,你会讶异,你是我,最压抑,最深处的秘密

这是杨宗纬一首很经典的歌,之所以引用它是因为今天的内容就犹如《洋葱》一般,需要一层层地剥开,剥开H264码流紧紧裹着的外衣。

什么是码流结构

解析H264视频编码原理——从孙艺珍的电影说起(二)最后层列出这样一张H264编码整体流程的图,最右端末尾(熵编码)输出的就是H264码流了,所谓的码流本质就是一串长长的二进制数据,就像一条很长的河流,缓缓的流淌,那么接收端收到这些码流之后,需要解析它才能读取里面的信息,所以码流一定是需要一定规律组织起来的,让接收端知道哪里是视频的开始,哪里是一帧图像的开始,甚至哪里是一个宏块的开始。这样的组织方式就是码流结构。

image.png

H264码流结构解析

宏块数据

解析视频编码原理——从孙艺珍的电影说起(一)已经说过,视频编码的编码最小单元是宏块,那我们可以从宏块入手,由小至大地近距离观看码流的模样。

我们已经知道编码器在编码宏块之后会将预测数据和相关参数以及残差数据装入码流,所以在码流的最小单元中,即宏块数据的存储如图所示: 1649566346(1).png

让我们“走近一点”看得更清楚些: 1649587194(1).png

预测数据部分(prediction)中,还包含预测模式(intra(帧内预测)、inter(帧间预测),)帧间预测包含参考帧(reference frame)和运动矢量(motion vector),以及量化系数(QP),残差数据(residual)。这些都是解析H264视频编码原理——从孙艺珍的电影说起(一) 中提到的编码用到的数据,也是解码必不可少的信息。

由于视频中的宏块非常多,所以宏块是这样子组织起来的:

image.png

其中MB(Macro block)表示一个宏块,skip表示的是跳过的宏块。

Slice

那么码流就只有宏块么?想象下,如果是把一连串宏块发送出去,那么是杂乱无章的,你无法分清楚那些宏块是属于哪一帧的。Slice,一般翻译为“片”,便应运而生。

Slice是什么呢?如果把宏块当做一箱货物的话,那么Slice可以当做集装箱,它制定了相互传输的格式,将宏快 有组织,有结构,有顺序的形成一系列的码流。

Slice 其实是为了并行编码设计的,一般来说是为了提高编码速度的,将一帧图像划分成几个 Slice,并且 Slice 之间相互独立、互不依赖、独立编码。所以帧内预测时候,不能跨Slice预测。

所以一帧图像包含一或者若干个slice,一个slice包含若干个宏块。

有了Slice,宏块就可以组织起来了。 1649588501(1).png

先是有了Slice Header,然后是有了Slice Data,Slice Header 中存放了这个 Slice 会用到的参数项,而 Data 中则存放了真正的图像信息,即具体的宏块数据。

Slice header主要是当前Slice包含的宏块的一些基本的数据,例如Slice的类型,Slice属于的那一帧的信息,以及当前Slice使用的图像序列参数以及量化参数等信息。

Slice的类型:(最后2种类型属于扩展类,就不细讲了)

  1. I Slice:仅包含I宏块
  2. P Slice:包含P宏块和I宏块
  3. B Slice:包含B宏块和I宏块
  4. SP Slice:包含B宏块和I宏块,用于使编码流之间容易交换
  5. SI Slice:包含SI宏块(一种特殊的编码宏块),用于使编码流之间容易交换

Slice包含的具体参数字段:

1651380208(1).png

这里讲下几个重要参数有:

first_mb_in_slice: 表示当前 Slice 的第一个宏块 MB 在当前编码图像帧中的序号。经常用于判断当前Slice是否是一帧的第一个Slice。如果 first_mb_in_slice 的值等于 0,就代表了当前 Slice 的第一个宏块是一帧的第一个宏块,也就是说当前 Slice 就是一帧的第一个 Slice。

slice type: Slice类型。

pic_parameter_set_id: 当前Slice所使用的PPS(图像参数集)的序号id,关于PPS后面会讲到。

SPS(序列参数集)和PPS(图像参数集)

当然Slice并不孤单,在同一个层次中,还有2个“兄弟”陪伴,分别是SPS(序列参数集)和PPS(图像参数集),这两虽然数据量不大可是来头可不小,没有他们,码流根本无法解码。

其中,SPS 主要包含的是图像的宽、高、YUV 格式和位深等基本信息;PPS 则主要包含熵编码类型、基础 QP 和最大参考帧数量等基本编码信息。

上面讲Slice的时候说过,有个参数pic_parameter_set_id,就是表示当前Slice所使用的PPS(图像参数集)的序号id,而一个PPS又会关联一个SPS,这样相当于一个Slice就关联了PPS和SPS。

可以打个比方,Slice的集装箱虽然可以运输到远方,但是集装箱内的物品怎么打开怎么使用却需要一份说明书(SPS(序列参数集)和PPS(图像参数集)),但是说明书并不需要每个集装箱一份,所以多个Slice会共用SPS和PPS。

所以Slice会和SPS和PPS形成这样一个结构: 1651381861(1).png

Slice里面存放着具体的视频编码数据,称为 Video Coding Layer (VCL),PPS和SPS存放编码相关信息供解码端解码。

综上所述,H264 的码流主要是由 SPS、PPS、I Slice、P Slice和B Slice 组成的。

NALU

上面说Slice就像集装箱,那么同一个层次的PPS和SPS也可以看做集装箱,为了收货方方便区分这些集装箱,所以必须给集装箱加个数据头部表明类型,所以NALU(Network Abstraction Layer Units)就应运而生。

通过NALU的头部,我们就能很方便区分集装箱里是一个Slice还是PPS还是SPS。所以一个Slice或者PPS或者SPS,统称为RBSP单元(Raw Byte Sequence Payload),都是一个NALU中。

所谓Network Abstraction Layer Units,即网络抽象层单元,就是码流在网络中传输的基本单元,每个NALU通过一个或者若干个网络数据包传输,每个NALU由一个字节的NALU header和NALU数据,即Slice和PPS和SPS,NALU header主要用于指定NALU类型和其优先级。

NALU header由1 bit的禁止位forbidden_zero_bit、2 bit重要性nal_ref_idc以及5 bits的nal_unit_type组成(图来源:# 码流结构:原来你是这样的H264):

1651404043(1).png

禁止位forbidden_zero_bit H264码流必须为 0,重要性nal_ref_idc指定了当前NALU的重要性,即越重要越不能让其在网络中丢失,参考帧、SPS 和 PPS 对应的 NALU 必须要大于 0。

而NALU type取值如图所示:

1651404324(1).png

所以综上所述整个结构如下图所示:

# 码流结构:原来你是这样的H264这张图太好了,忍不住拿来用了~

1651402500(1).png

起始码

现在还有一个问题,一串1、0的码流过来,如何区分一个个NALU?最常见的方法莫过于先指定某一串特殊的码流为标记去作为分割线,H264也是使用这种方式去作为分隔符。其中每个NALU之间通过startcode(起始码)进行分隔,起始码分成两种:0x000001(3Byte)或者0x00000001(4Byte)。如果NALU对应的Slice为一帧的开始就用0x00000001,否则就用0x000001。

解码端一旦识别到起始码,就知道这是一个NALU的结束和另一个NALU的开始。

由于图像编码出来的数据中也有可能出现“00 00 00 01”和“00 00 01”的数据。那这种情况怎么办呢?

为了使NALU主体不包括起始码,在编码时每遇到两个字节(连续)的0,就插入一字节0x03,以和起始码相区别。解码时,则将相应的0x03删除掉。

为了防止出现这种情况,H264 会将图像编码数据中的下面的几种字节串做如下处理:

(1)“00 00 00”修改为“00 00 03 00”;
(2)“00 00 01”修改为“00 00 03 01”;
(3)“00 00 02”修改为“00 00 03 02”;
(4)“00 00 03”修改为“00 00 03 03”。

在解码端,我们在去掉起始码之后,也需要将对应的字节串转换回来。

所以,在NAL层,实际上准确来说是这样的结构(图来源:# 码流结构:原来你是这样的H264):

1651414209(1).png

码流结构整体

最后,让我们站得更高一些,回望今天讲的内容:

从网络抽象层到宏块层按层次划分的整体图:

fbeb95c1b6fd44429933fe6a9041689.png

从帧的角度来看的整体图: 13249ad957fae8e11ee773c0148e4d7.png

可以看出都是由类似树的结构,一层层展开。

先等等,这个结构是不是有点熟悉?

放一张我们熟悉的图:

1649588719(1).png

是的,这种分层结构是一种在计算机领域非常经典的思维,通过分层,不同层负责不同的事务并且互不干涉其他层的事务,做到即条理清晰,又互相解耦。

码流实战

所谓纸上得来终觉浅,绝知此事要躬行。接下来根据上面的理论知识,我们来对一个具体的码流进行分析。

这是一份H264文件的二进制码流:

1654338868337.png

首先是起始码0x000001,表示第一个NALU的开始: 1654339018053.png

起始码之后应该就是NALU Header了,因为NALU header由1 bit的禁止位forbidden_zero_bit、2 bit重要性nal_ref_idc以及5 bits的nal_unit_type组成,即占了8位,所以看下码流后面的8位:

1654340489463.png

十六进制67,二进制为01100111,nal_unit_type是最后5位,即00111,即7。再看一眼type对应的NALU类型:

1651404324(1).png

7位参数序列集,即为sps,所以接下来的一个NALU为sps,一直到下一个起始码:

1654340977583.png

接下来的一个NALU类型为68:

1654341159571.png

依照上面的方法可以得出该NALU为pps:

1654341056391.png

同样的方式,可以得到下一个NALU为IDR SLICE(很漫长):

1654341328729.png

同样的方式,可以得到下一个NALU为非IDR SLICE(很漫长):

1654341680216.png

总结

今天从下到上整体地剖析了H264的码流,分别是预测信息和残差数据组成了宏块,多个宏块又组成了Slice数据部分,Slice数据部分和Slice头部又组成了Slice,Slice和PPS和SPS又组成了一个NALU主体,NALU主体又和NALU头部组成了一个完整的NALU,一个个NALU和一个个起始码最终组成一串完整的码流,慢慢地在网络中流淌,或者静静地躺在我们的硬盘中。

最后通过一个h264码流文件的简单具体分析,亲自体验了这种文件结构的构成。

通过这三篇博文,基本有了H264编码的理论基础,后面就可以开始令人振奋的实战之旅了~~

原创不易,如果觉得本文对自己有帮助,别忘了随手点赞和关注,这也是我创作的最大动力~

参考

视音频数据处理入门:H.264视频码流解析
码流结构:原来你是这样的H264
《深入理解视频编解码技术》
《H.264和MPEG-4视频压缩 新一代多媒体的视频编码技术》