视频编码的码流结构是指视频经过编码之后得到的二进制数据是怎么组织的,或者说,就是编码后的码流我们怎么将一帧帧编码后的图像数据分离出来,以及在二进制码流数据中,哪一块数据是一帧图像,哪一块数据是另外一帧图像。
而我们在工程开发中,需要对编码后的数据进行一些解析,以便用于之后的打包。同时我们在打包时也需要判断当前一帧图像数据它的开头和结尾在哪。
相关概念
在讲之前有一些必须的前置知识和概念需要先了解一下。首先,清楚帧类型是图像的基础;其次,GOP 是以其中的 IDR 帧作为分隔点的;最后的 Slice 是我们深入帧内部以后的一个重要概念。
帧类型
如果接触过视频编码,你可能听说过I帧,P帧,B帧,PPS帧,SPS帧这些概念。
上一篇讲过为了减少空间冗余和时间冗余,视频编码使用了帧内预测和帧间预测技术,这些都涉及到帧。所以了解帧的类型是很有必要的。
我们知道帧内预测不需要参考已编码帧,对已编码帧是没有依赖的,并可以自行完成编码和解码。而帧间预测是需要参考已编码帧的,并对已编码帧具有依赖性。帧间预测需要参考已经编码好了的帧内编码帧或者帧间编码帧。并且,帧间编码帧又可以分为只参考前面帧的前向编码帧,和既可以参考前面帧又可以参考后面帧的双向编码帧。为了做区分,在 H264 中,我们就将图像分为以下不同类型的帧。
帧类型 | 预测方式 | 参考帧 | 优缺点 | |
---|---|---|---|---|
I帧 | 帧内编码帧 | 只能进行帧内预测 | 无 | 自身能独立完成编码解码。压缩率低 |
P帧 | 前向编码帧 | 可以进行帧间预测和帧内预测 | 参考前面已编码的I帧或P帧 | 压缩率比I帧高。必须要参考帧才能正确编码 |
B帧 | 双向编码帧 | 可以进行帧间预测和帧内预测 | 参考前面和后面已编码的I帧或P帧 | 压缩率最高高,需要缓存帧,延时高,实时场景不适合 |
P帧序列示意图:
B帧序列示意图:
由于 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 帧,标准也是支持的。
GOP
在 H264 中,还有一个 GOP 的概念也经常会遇到,它是什么意思呢?从一个 IDR 帧开始到下一个 IDR 帧的前一帧为止,这里面包含的 IDR 帧、普通 I 帧、P 帧和 B 帧,我们称为一个 GOP(图像组)(这是 closed GOP,还有一种 opened GOP,比较少见,这里不讨论)。
我们可以看到 GOP 的大小是由 IDR 帧之间的间隔来确定的,而这个间隔我们有一个重要的概念来表示,叫做关键帧间隔。关键帧间隔越大,两个 IDR 相隔就会越远,GOP 也就越大;关键帧间隔越小,IDR 相隔也就越近,GOP 就越小。
GOP 越大,编码的 I 帧就会越少。相比而言,P 帧、B 帧的压缩率更高,因此整个视频的编码效率就会越高。但是 GOP 太大,也会导致 IDR 帧距离太大,点播场景时进行视频的 seek 操作就会不方便。并且,在 RTC 和直播场景中,可能会因为网络原因导致丢包而引起接收端的丢帧,大的 GOP 最终可能导致参考帧丢失而出现解码错误,从而引起长时间花屏和卡顿。这一块我们会在之后用单独的一节课来详细讲述。总之,GOP 不是越大越好,也不是越小越好,需要根据实际的场景来选择。前面我们讲的是视频图像序列的层次结构,那图像内的层次结构是怎样的呢?
Slice
这就不得不提到另一个概念了,Slice,也叫做“片”。Slice 其实是为了并行编码设计的。什么意思呢?就是说,我们可以将一帧图像划分成几个 Slice,并且 Slice 之间相互独立、互不依赖、独立编码。那么在机器性能比较高的情况下,我们就可以多线程并行对多个 Slice 进行编码,从而提升速度。但也因为一帧内的几个 Slice 是相互独立的,所以如果帧内预测的话,就不能跨 Slice 进行,因此编码性能会差一些。而在 H264 中编码的基本单元是宏块,所以一个 Slice 又包含整数个宏块。我们在前一节课中也讲了,宏块 MB 大小是 16 x 16。
在做帧内和帧间预测的时候,我们又可以将宏块继续划分成不同大小的子块,用来给复杂区域做精细化编码。总结来说,图像内的层次结构就是一帧图像可以划分成一个或多个 Slice,而一个 Slice 包含多个宏块,且一个宏块又可以划分成多个不同尺寸的子块。如下图所示:
H264 的码流结构
码流格式
H264 码流有两种格式:一种是 Annexb 格式;一种是 MP4 格式。两种格式的区别是:
Annexb 格式
Annexb 格式使用起始码来表示一个编码数据的开始。起始码本身不是图像编码的内容,只是用来分隔用的。起始码有两种,一种是 4 字节的“00 00 00 01”,一种是 3 字节的“00 00 01”。
这里需要注意一下,由于图像编码出来的数据中也有可能出现“00 00 00 01”和“00 00 01”的数据。那这种情况怎么办呢?为了防止出现这种情况,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”。
同样地在解码端,我们在去掉起始码之后,也需要将对应的字节串转换回来。
MP4 格式
MP4 格式没有起始码,而是在图像编码数据的开始使用了 4 个字节作为长度标识,用来表示编码数据的长度,这样我们每次读取 4 个字节,计算出编码数据长度,然后取出编码数据,再继续读取 4 个字节得到长度,一直继续下去就可以取出所有的编码数据了。
这两种格式差别不大,接下来我们主要使用 Annexb 格式来讲解 H264 码流中的 NALU。
NALU
前面我们讲了图像分成 I 帧、P 帧和 B 帧这三种类型的帧。其实除了图像数据,视频编码的时候还有一些编码参数数据,为了能够将一些通用的编码参数提取出来,不在图像编码数据中重复,H264 设计了两个重要的参数集:一个是 SPS(序列参数集);一个是 PPS(图像参数集)。其中,SPS 主要包含的是图像的宽、高、YUV 格式和位深等基本信息;PPS 则主要包含熵编码类型、基础 QP 和最大参考帧数量等基本编码信息。如果没有 SPS、PPS 里面的基础信息,之后的 I 帧、P 帧、B 帧就都没办法进行解码。因此 SPS 和 PPS 是至关重要的。
结合前面我们讲的内容,我们现在可以知道,H264 码流主要包含了 SPS、PPS、I 帧、P 帧和 B 帧。由于帧又可以划分成一个或多个 Slice。因此,帧在码流中实际上是以 Slice 的形式呈现的。所以,H264 的码流主要是由 SPS、PPS、I Slice、P Slice和B Slice 组成的。如下图所示:
我们知道了 H264 码流主要由 SPS、PPS 和三种 Slice 组成,那我们如何在码流中区分这几种数据呢?为了解决这个问题,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 组成。其结构如下:
NALU Header具体解析见nalu
这里不进行展开了。
小结
这篇文章主要讨论了 H264 的编码层次结构和码流结构。在一个视频图像序列中,我们将其划分成一个个 GOP。GOP 包含一个 IDR 帧到下一个 IDR 帧的前一帧中的所有帧。GOP 的大小选择需要根据实际应用场景来选择,一般 RTC 和直播场景可以稍微大一些,而点播场景一般小一些。
在 H264 中,每一帧图像又可以分为 I 帧、P 帧和 B 帧,而 I 帧又包含了普通 I 帧和 IDR 帧。帧可以划分为一个或者多个 Slice,并且最后帧都是以 Slice 的方式在码流中呈现。同时 H264 码流中除了 Slice 数据之外,还有 SPS 和 PPS 两个参数集,分别用来存放基础图像信息和基础编码参数。SPS 和 PPS 非常重要,如果丢失了,将无法进行解码。每一个 Slice 和 SPS、PPS 都是通过 NALU 来封装的,且 NALU 含有一个 1 字节的 NALU Header。我们可以通过 NALU Header 中的 NALU Type 来判断 NALU 的类型。同时,每一个 NALU 的分隔有两种方式:一种是 Annexb 格式,通过使用起始码分隔;一种是 MP4 格式,通过一个 4 字节的长度来表示 NALU 的大小,从而起到分隔的作用。