H264的码流结构
视频编码的码流结构其实就是指视频经过编码之后得到的二进制数据是怎么组织的,换句话说,就是编码后的码流我们怎么将一帧帧编码后的图像数据分离出来,以及在二进制码流数据中,哪一块数据是一帧图像,哪一块数据是另外一帧图像。
一、H264的编码结构
1、帧类型
在 H264 中,帧类型主要分为 3 大类,分别是 I 帧、P 帧和 B 帧。帧内预测不需要参考已编码帧,对已编码帧是没有依赖的,并可以自行完成编码和解码。而帧间预测是需要参考已编码帧的,并对已编码帧具有依赖性。帧间预测需要参考已经编码好了的帧内编码帧或者帧间编码帧。并且,帧间编码帧又可以分为只参考前面帧的前向编码帧,和既可以参考前面帧又可以参考后面帧的双向编码帧。
为了做区分,在 H264 中,我们就将图像分为以下不同类型的帧。

三种帧的示例图如下所示。例如,从左向右,第一个 B 帧参考第一个 I 帧和第一个 P 帧,第一个 P 帧只参考第一个 I 帧(箭头是从参考帧指向编码帧)。

由于 P 帧和 B 帧需要参考其它帧。如果编码或者解码的过程中有一个参考帧出现错误的话,那依赖它的 P 帧和 B 帧肯定也会出现错误,而这些有问题的 P 帧(B 帧虽然也可以用来作为参考帧,但是一般用的比较少,所以这里不讨论)又会继续作为之后 P 帧或 B 帧的参考帧。因此,错误会不断的传递。为了避免错误的不断传递,就有了一种特殊的 I 帧叫 IDR 帧,也叫立即刷新帧。
H264 编码标准中规定,IDR 帧之后的帧不能再参考 IDR 帧之前的帧。这样,如果某一帧编码错误,之后的帧参考了这个错误帧,则也会出错。此时编码一个 IDR 帧,由于它不参考其它帧,所以只要它自己编码是正确的就不会有问题。之前有错误的帧也不会再被用作参考帧,这样就截断了编码错误的传递,且之后的帧就可以正常编 / 解码了。
当然,有 IDR 这种特殊的 I 帧,也就有普通的 I 帧。普通的 I 帧就是指当前帧只使用帧内预测编码,但是后面的 P 帧和 B 帧还是可以参考普通 I 帧之前的帧。一般来说我们不太会使用这种普通 I 帧,大多数情况下还是直接使用 IDR 帧,尤其是在流媒体场景,比如 RTC 场景。只是说如果你非要用这种普通 I 帧,标准也是支持的。
2、GOP
从一个 IDR 帧开始到下一个 IDR 帧的前一帧为止,这里面包含的 IDR 帧、普通 I 帧、P 帧和 B 帧,我们称为一个 GOP(图像组)。
GOP 的大小是由 IDR 帧之间的间隔来确定的,而这个间隔我们有一个重要的概念来表示,叫做关键帧间隔。关键帧间隔越大,两个 IDR 相隔就会越远,GOP 也就越大;关键帧间隔越小,IDR 相隔也就越近,GOP 就越小。

GOP 越大,编码的 I 帧就会越少。相比而言,P 帧、B 帧的压缩率更高,因此整个视频的编码效率就会越高。但是 GOP 太大,也会导致 IDR 帧距离太大,点播场景时进行视频的 seek 操作就会不方便。
并且,在 RTC(实时通信,要求低延迟、双向交互、自适应网络、抗丢包能力等) 和直播场景中,可能会因为网络原因导致丢包而引起接收端的丢帧,大的 GOP 最终可能导致参考帧丢失而出现解码错误,从而引起长时间花屏和卡顿。这一块我们会在之后用单独的一节课来详细讲述。总之,GOP 不是越大越好,也不是越小越好,需要根据实际的场景来选择。一般 RTC 和直播场景可以稍微大一些,而点播场景一般小一些。
3、Slice以及宏块的子块
Slice,也叫做“片”。Slice 其实是为了并行编码设计的。可以将一帧图像划分成几个 Slice,并且 Slice 之间相互独立、互不依赖、独立编码。
在机器性能比较高的情况下,我们就可以多线程并行对多个 Slice 进行编码,从而提升速度。但也因为一帧内的几个 Slice 是相互独立的,所以如果帧内预测的话,就不能跨 Slice 进行,因此编码性能会差一些。
在 H264 中编码的基本单元是宏块,所以一个 Slice 又包含整数个宏块。在H264编码中,宏块 MB 大小是 16 x 16,而在做帧内和帧间预测的时候,我们又可以将宏块继续划分成不同大小的子块,用来给复杂区域做精细化编码。
总结来说,图像内的层次结构就是一帧图像可以划分成一个或多个 Slice,而一个 Slice 包含多个宏块,且一个宏块又可以划分成多个不同尺寸的子块。如下图所示:

二、H264的码流结构
1、码流格式
H264 码流有两种格式:一种是 Annexb 格式;一种是 MP4 格式。两种格式的区别是:
(1)Annexb格式
Annexb 格式使用起始码来表示一个编码数据的开始。起始码本身不是图像编码的内容,只是用来分隔用的。起始码有两种,一种是 4 字节的“00 00 00 01”,一种是 3 字节的“00 00 01”。
这里需要注意一下,由于图像编码出来的数据中也有可能出现“00 00 00 01”和“00 00 01”的数据。那这种情况怎么办呢?为了防止出现这种情况,H264 会将图像编码数据中的下面的几种字节串做如下处理:
①“00 00 00”修改为“00 00 03 00”;②“00 00 01”修改为“00 00 03 01”;③“00 00 02”修改为“00 00 03 02”;④“00 00 03”修改为“00 00 03 03”。
同样地在解码端,我们在去掉起始码之后,也需要将对应的字节串转换回来。

实际上,这是编码器在处理起始码伪装问题时的处理,规则是:如果在 RBSP 中检测到两个连续的 0x00 字节,且紧随其后的字节属于 {0x00, 0x01, 0x02, 0x03},则在这三个字节之间插入一个 0x03。
在解码时,同样只会处理一次,检测到两个连续的 0x00 字节,如果后面是0x03,则删除。
(2)MP4格式
MP4 格式没有起始码,而是在图像编码数据的开始使用了 4 个字节作为长度标识,用来表示编码数据的长度,这样我们每次读取 4 个字节,计算出编码数据长度,然后取出编码数据,再继续读取 4 个字节得到长度,一直继续下去就可以取出所有的编码数据了。
        
2、NALU
(1)SPS和PPS
除了图像数据,视频编码的时候还有一些编码参数数据,为了能够将一些通用的编码参数提取出来,不在图像编码数据中重复,H264 设计了两个重要的参数集:一个是 SPS(序列参数集);一个是 PPS(图像参数集)。
其中,SPS 主要包含的是图像的宽、高、YUV 格式和位深等基本信息;PPS 则主要包含熵编码类型、基础 QP 和最大参考帧数量等基本编码信息。如果没有 SPS、PPS 里面的基础信息,之后的 I 帧、P 帧、B 帧就都没办法进行解码。因此 SPS 和 PPS 是至关重要的。
(2)码流组成
H264 码流主要包含了 SPS、PPS、I 帧、P 帧和 B 帧。由于帧又可以划分成一个或多个 Slice。因此,帧在码流中实际上是以 Slice 的形式呈现的。所以,H264 的码流主要是由 SPS、PPS、I Slice、P Slice和B Slice 组成的。如下图所示:

(3)NALU单元
如何在码流中区分这几种数据呢?
为了解决这个问题,H264 设计了 NALU(网络抽象层单元)。SPS 是一个 NALU、PPS 是一个 NALU、每一个 Slice 也是一个 NALU。每一个 NALU 又都是由一个 1 字节的 NALU Header 和若干字节的 NALU Data 组成的。而对于每一个 Slice NALU,其 NALU Data 又是由 Slice Header 和 Slice Data 组成,并且 Slice Data 又是由一个个 MB Data 组成。其结构如下:

(4)NALU Header
重点是NALU Header。它总共占用 1 个字节,具体如下图所示。
        
F:forbidden_zero_bit,占 1bit,禁止位,H264 码流必须为 0;
NRI: nal_ref_idc,占 2bits,可以取 00~11,表示当前 NALU 的重要性。参考帧、SPS 和 PPS 对应的 NALU 必须要大于 0;
Type: nal_unit_type,占 5bits,表示 NALU 类型。其取值如下表所示。

有了 NALU Type 类型表格,那我们解析出 NALU Header 的 Type 字段,查询表格就可以得到哪个 NALU 是 SPS,哪个是 PPS,以及哪个是 IDR 帧了。
这里需要注意一下,NALU 类型只区分了 IDR Slice 和非 IDR Slice,至于非 IDR Slice 是普通 I Slice、P Slice 还是 B Slice,则需要继续解析 Slice Header 中的 Slice Type 字段得到。可以通过下面两个例子来看看常见的 NALU 里的 NALU Header 是怎样的。
第一个例子,可以看到这里SPS的NALU单元刚开始的数据是67、27,其实这里这两个数字是十六进制,写成二进制,也就是0110 0111和0010 0111。按照上面的一个字节的header解析来看,后面5个位是类型,表示SPS类型;中间2个bit表示重要性,第一个bit表示禁止位。

另一个例子是用二进制查看工具打开实际编码后的码流数据。可以看到在码流的开始部分是一个起始码(以Annexb格式为例),之后紧接着是一个 SPS 的 NALU。在 SPS 后面是一个 PPS 的 NALU。然后就是一个 IDR Slice 的 NALU 和一个非 IDR Slice NALU。

三、码流的应用
1、多 Slice 时如何判断哪几个 Slice 是同一帧的?
在 H264 码流中,帧是以 Slice 的方式呈现的,或者可以说在 H264 码流里是没有“帧“这种数据的,只有 Slice。但是有个问题是,一帧有几个 Slice 是不会告诉你的。也就是说码流中没有字段表示一帧包含几个 Slice。既然没有办法知道一帧有几个 Slice,那我们如何知道多 Slice 编码时一帧的开始和结束分别对应哪个 Slice 呢?
其实,Slice NALU 由 NALU Header 和 NALU Data 组成,其中 NALU Data 里面就是 Slice 数据,而 Slice 数据又是由 Slice Header 和 Slice Data 组成。在 Slice Header 开始的地方有一个 first_mb_in_slice 的字段,表示当前 Slice 的第一个宏块 MB 在当前编码图像中的序号。我们只要解析出这个宏块的序号出来,
如果 first_mb_in_slice 的值等于 0,就代表了当前 Slice 的第一个宏块是一帧的第一个宏块,也就是说当前 Slice 就是一帧的第一个 Slice。
如果 first_mb_in_slice 的值不等于 0,就代表了当前 Slice 不是一帧的第一个 Slice。并且,使用同样的方式一直往下找,直到找到下一个 first_mb_in_slice 为 0 的 Slice,就代表新的一帧的开始,那么其前一个 Slice 就是前一帧的最后一个 Slice 了。

其中,first_mb_in_slice 是以无符号指数哥伦布编码的,需要使用对应的解码方式才能解码出来。但是有一个小技巧,如果只是需要判断 first_mb_in_slice 是不是等于 0,不需要计算出实际值的话,只需要通过下面的方式计算就可以了。
   
2、如何从 SPS 中获取图像的宽高?
在编码端编码一个视频的时候,我们是需要设置分辨率告诉编码器图像的实际宽高的。但是解码器是不需要设置分辨率的,那我们在解码端或者说接收端如何知道视频的分辨率大小呢?
其实,在编码器编码的时候会将分辨率信息编码到 SPS 中。在 SPS 中有几个字段用来表示分辨率的大小。我们可以解码出这几个字段并通过一定的规则计算得到分辨率的大小。这几个字段分别是:

这几个字段都是通过无符号指数哥伦布编码的,需要先解码出来。解码得到具体值之后,通过以下方法就可以得到分辨率了。注意,pic_height_in_map_units_minus1 需要考虑帧编码和场编码的区别,其中场编码已经很少使用了,我们这里不再考虑。

3、如何计算得到QP值?
量化过程是引入失真最主要的环节。而量化最主要的参数就是 QP 值,并且 QP 值的大小严重影响到编码画面的清晰度。因此 QP 值非常重要。那么我们如何从码流中计算得到 QP 值呢?
在 PPS 中有一个全局基础 QP,字段是 pic_init_qp_minus26。当前序列中所有依赖该 PPS 的 Slice 共用这个基础 QP,且每一个 Slice 在这个基础 QP 的基础上做调整。(这里当前序列的含义是,当某一帧(Picture)或多个 Slice(可能是同一帧的多个部分)在它们的头部声明 PPS ID = 2,那么这些 Slice 都会 共用 这个 PPS 的设置。)在绝大多数编码器实现中:一帧图像(Picture)内的所有 Slice 都使用同一个 PPS。因为同一帧通常需要保持相同的编码配置(同样的 QP、去块滤波参数、CABAC/ CAVLC 方式等)。但标准并不强制:同一个序列中可以存在多个 PPS,甚至同一帧的不同 Slice 也可以引用不同的 PPS。
在 Slice Header 中有一个 slice_qp_delta 字段来描述这个调整偏移值。更进一步,H264 允许在宏块级别对 QP 做更进一步的精细化调节。这个字段在宏块数据里面,叫做 mb_qp_delta。

如果需要得到 Slice 级别的 QP 则只需要考虑前两个 QP 相关字段。如果需要计算宏块 QP,则需要三个都考虑。但是宏块 QP 需要解析整个 Slice 数据,计算量大。一般我们直接计算到 Slice QP 就可以了。计算方法如下:

四、总结

