20 KiB
05|码流结构:原来你是这样的H264
你好,我是李江。
上一节课我们一起讨论了视频编码的基本原理。今天,我们就接着来聊聊视频编码的码流结构,这在视频开发工作中是非常重要的。
视频编码标准其实有很多,比如上一节课讲到的H264、H265、AV1等,但原理大同小异,都是预测、变换、量化和熵编码等几个步骤。H264编码可以说是最常用的编码标准,比较经典,所以这节课我们就以H264为例来讲解码流结构。在掌握了这些之后,迁移学习其它编码标准的码流结构也就简单多了。
视频编码的码流结构其实就是指视频经过编码之后得到的二进制数据是怎么组织的,换句话说,就是编码后的码流我们怎么将一帧帧编码后的图像数据分离出来,以及在二进制码流数据中,哪一块数据是一帧图像,哪一块数据是另外一帧图像。
而我们在工程开发中,需要对编码后的数据进行一些解析,以便用于之后的打包。同时我们在打包时也需要判断当前一帧图像数据它的开头和结尾在哪。这些工作的前提就是我们要清楚如何分析编码码流,那么码流结构到底是怎样的,就是当下的学习重点了。
下面我们就以H264编码为基础,分析一下它的码流结构,并看看它在工程中是如何应用的。
H264的编码结构
这里有一些前置知识我们需要先了解一下。我们先一起来看几个重要的概念吧。它们之间有这样一条线索,你在接下来的学习中可以重点关注一下,对于你记忆它们也是非常有帮助的。
首先,清楚帧类型是图像的基础;其次,GOP是以其中的IDR帧作为分隔点的;最后的Slice是我们深入帧内部以后的一个重要概念。整个过程,由浅入深。
帧类型
帧类型相信你在平时的工作中可能已经接触过一部分了,比如说我们可能经常听到视频开发工作者说I帧、P帧之类的。其实在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帧,标准也是支持的。
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的码流结构
下面我们就以“剥洋葱”的方式来详细地讲解H264的码流结构。先从最外层的码流格式讲起,教你怎么判断视频编码数据的起始;然后再介绍里面的NALU(网络抽象层单元)数据,看看通过它是怎么区分不同的帧类型的;再详细聊聊NALU有几种类型,以及通过什么方式来区分NALU的类型。
码流格式
H264码流有两种格式:一种是Annexb格式;**一种是MP4格式。**两种格式的区别是:
-
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格式没有起始码,而是在图像编码数据的开始使用了4个字节作为长度标识,用来表示编码数据的长度,这样我们每次读取4个字节,计算出编码数据长度,然后取出编码数据,再继续读取4个字节得到长度,一直继续下去就可以取出所有的编码数据了。
这两种格式差别不大,接下来我们主要使用Annexb格式来讲解H264码流中的NALU。
下面,我们剥开“洋葱”的最外层,将起始码去掉,进入“洋葱”的内部,也就是编码数据。这个编码数据就是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。它总共占用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是怎样的。
下面我们再来看一个实际码流的例子,看看在实际编码出来的二进制数据中,各种NALU是怎么“放置”在数据中的。下图是我用二进制查看工具打开实际编码后的码流数据。我们可以看到在码流的开始部分是一个起始码,之后紧接着是一个SPS的NALU。在SPS后面是一个PPS的NALU。然后就是一个IDR Slice的NALU和一个非IDR Slice NALU。
现在,对于码流结构的认知你是不是很清晰了。你也可以去找个H264码流,用二进制查看工具打开它。通过今天学习的知识,你可以试着找出其中的起始码,看看能不能找到SPS、PPS、IDR和非IDR Slice。如果你都能找出来,那恭喜你说明你已经掌握了今天的主要知识点了。
常见工程问题
好了,在了解了基本的码流结构知识之后,我们来看看如何运用这节课学到的知识去解决工程上常见的一些问题。这里我列举了3个比较典型的问题,如果你有更多问题的话,可以到留言区我们一起讨论。
多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,不需要计算出实际值的话,只需要通过下面的方式计算就可以了。
这就是多Slice判断一帧的开始和结束的方法。
如何从SPS中获取图像的宽高?
在编码端编码一个视频的时候,我们是需要设置分辨率告诉编码器图像的实际宽高的。但是解码器是不需要设置分辨率的,那我们在解码端或者说接收端如何知道视频的分辨率大小呢?
其实,在编码器编码的时候会将分辨率信息编码到SPS中。在SPS中有几个字段用来表示分辨率的大小。我们可以解码出这几个字段并通过一定的规则计算得到分辨率的大小。这几个字段分别是:
这几个字段都是通过无符号指数哥伦布编码的,需要先解码出来。解码得到具体值之后,通过以下方法就可以得到分辨率了。注意,pic_height_in_map_units_minus1需要考虑帧编码和场编码的区别,其中场编码已经很少使用了,我们这里不再考虑。
通过上面的方法就可以计算得到图像的分辨率了。
如何计算得到QP值?
我们在视频编码原理那节课中讲过,量化过程是引入失真最主要的环节。而量化最主要的参数就是QP值,并且QP值的大小严重影响到编码画面的清晰度。因此QP值非常重要。那么我们如何从码流中计算得到QP值呢?
在PPS中有一个全局基础QP,字段是pic_init_qp_minus26。当前序列中所有依赖该PPS的Slice共用这个基础QP,且每一个Slice在这个基础QP的基础上做调整。在Slice Header中有一个slice_qp_delta字段来描述这个调整偏移值。更进一步,H264允许在宏块级别对QP做更进一步的精细化调节。这个字段在宏块数据里面,叫做mb_qp_delta。
如果需要得到Slice级别的QP则只需要考虑前两个QP相关字段。如果需要计算宏块QP,则需要三个都考虑。但是宏块QP需要解析整个Slice数据,计算量大。一般我们直接计算到Slice QP就可以了。计算方法如下:
小结
这节课我们主要讨论了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的大小,从而起到分隔的作用。
思考题
为什么有B帧的时候延时会高?
你可以把你的答案和思考写下来,分享到留言区,与我一起讨论。下节课再见。