gitbook/攻克视频技术/docs/461658.md
2022-09-03 22:05:03 +08:00

198 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 05码流结构原来你是这样的H264
你好,我是李江。
上一节课我们一起讨论了视频编码的基本原理。今天,我们就接着来聊聊视频编码的码流结构,这在视频开发工作中是非常重要的。
视频编码标准其实有很多比如上一节课讲到的H264、H265、AV1等但原理大同小异都是预测、变换、量化和熵编码等几个步骤。H264编码可以说是最常用的编码标准比较经典所以这节课我们就以H264为例来讲解码流结构。在掌握了这些之后迁移学习其它编码标准的码流结构也就简单多了。
视频编码的码流结构其实就是指视频经过编码之后得到的二进制数据是怎么组织的,换句话说,就是编码后的码流我们怎么将一帧帧编码后的图像数据分离出来,以及在二进制码流数据中,哪一块数据是一帧图像,哪一块数据是另外一帧图像。
而我们在工程开发中,需要对编码后的数据进行一些解析,以便用于之后的打包。同时我们在打包时也需要判断当前一帧图像数据它的开头和结尾在哪。这些工作的前提就是我们要清楚如何分析编码码流,那么码流结构到底是怎样的,就是当下的学习重点了。
下面我们就以H264编码为基础分析一下它的码流结构并看看它在工程中是如何应用的。
## H264的编码结构
这里有一些前置知识我们需要先了解一下。我们先一起来看几个重要的概念吧。它们之间有这样一条线索,你在接下来的学习中可以重点关注一下,对于你记忆它们也是非常有帮助的。
首先清楚帧类型是图像的基础其次GOP是以其中的IDR帧作为分隔点的最后的Slice是我们深入帧内部以后的一个重要概念。整个过程由浅入深。
### 帧类型
帧类型相信你在平时的工作中可能已经接触过一部分了比如说我们可能经常听到视频开发工作者说I帧、P帧之类的。其实在H264中帧类型主要分为3大类分别是I帧、P帧和B帧。那么它们之间有什么区别呢接下来我们就来详细聊聊。
在[视频编码原理](https://time.geekbang.org/column/article/459554)那节课里面,我们讲过为了减少空间冗余和时间冗余,视频编码使用了帧内预测和帧间预测技术,这些都涉及到帧。所以了解帧的类型是很有必要的。
我们知道帧内预测不需要参考已编码帧,对已编码帧是没有依赖的,并可以自行完成编码和解码。而帧间预测是需要参考已编码帧的,并对已编码帧具有依赖性。帧间预测需要参考已经编码好了的帧内编码帧或者帧间编码帧。并且,帧间编码帧又可以分为只参考前面帧的前向编码帧,和既可以参考前面帧又可以参考后面帧的双向编码帧。
为了做区分在H264中我们就将图像分为以下不同类型的帧。
![](https://static001.geekbang.org/resource/image/6b/8d/6b908464d87e30bf977893ababf7e78d.jpg?wh=1280x392)
三种帧的示例图如下所示。例如从左向右第一个B帧参考第一个I帧和第一个P帧第一个P帧只参考第一个I帧箭头是从参考帧指向编码帧
![图片](https://static001.geekbang.org/resource/image/ab/4c/ab75b04921d925f567a92796c992e54c.jpeg?wh=1920x376)
由于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就越小。
![图片](https://static001.geekbang.org/resource/image/3d/df/3deac858fff85f0bf3d9c930e6776cdf.jpeg?wh=1920x416)
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包含多个宏块****且****一个宏块又可以划分成多个不同尺寸的子块**。如下图所示:
![](https://static001.geekbang.org/resource/image/63/16/63f316bf5d1410cdb38334ba7bc9f316.jpg?wh=1280x720)
好了上面都是从概念上来讨论视频编码中的视频序列和图像的层次结构。那有了这些知识之后接下来我们更进一步从H264码流的角度来看看这些层次结构具体在二进制码流中是怎样的。
## H264的码流结构
下面我们就以“剥洋葱”的方式来详细地讲解H264的码流结构。先从最外层的码流格式讲起教你怎么判断视频编码数据的起始然后再介绍里面的NALU网络抽象层单元数据看看通过它是怎么区分不同的帧类型的再详细聊聊NALU有几种类型以及通过什么方式来区分NALU的类型。
### 码流格式
**H264码流有两种格式******一种是Annexb格式******一种是MP4格式。**两种格式的区别是:
1. 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”。
同样地在解码端,我们在去掉起始码之后,也需要将对应的字节串转换回来。
![图片](https://static001.geekbang.org/resource/image/8f/b2/8f7b9f988aa439d8ae584eb4561ed1b2.jpeg?wh=1920x311)
2. MP4格式没有起始码而是**在图像编码数据的开始使用了4个字节作为长度标识**用来表示编码数据的长度这样我们每次读取4个字节计算出编码数据长度然后取出编码数据再继续读取4个字节得到长度一直继续下去就可以取出所有的编码数据了。
![图片](https://static001.geekbang.org/resource/image/94/6d/945b7e9c21e313c1cfcd8ecbe967576d.jpeg?wh=1920x250)
这两种格式差别不大接下来我们主要使用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组成的**。如下图所示:
![图片](https://static001.geekbang.org/resource/image/14/d4/145b789d7fd80e415c8c5d7d6e6fe1d4.jpeg?wh=1920x290)
我们知道了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组成。其结构如下
![](https://static001.geekbang.org/resource/image/df/c0/df6fdacccd55c66d8495cc7c113489c0.jpg?wh=1280x720)
在这里我们重点介绍一下NALU Header。它总共占用1个字节具体如下图所示。
![图片](https://static001.geekbang.org/resource/image/76/26/761aa34e76ed9b449a75c6b3bd226126.jpeg?wh=1586x522)
其中,
* Fforbidden\_zero\_bit占1bit禁止位H264码流必须为0
* NRI nal\_ref\_idc占2bits可以取0011表示当前NALU的重要性。参考帧、SPS和PPS对应的NALU必须要大于0
* Type nal\_unit\_type占5bits表示NALU类型。其取值如下表所示。
![](https://static001.geekbang.org/resource/image/86/a7/86c7dbba135911a984224d8b886c2fa7.png?wh=1880x267)
有了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是怎样的。
![图片](https://static001.geekbang.org/resource/image/3d/a8/3d017ba4005cd602881611772e5203a8.png?wh=1920x608)
下面我们再来看一个实际码流的例子看看在实际编码出来的二进制数据中各种NALU是怎么“放置”在数据中的。下图是我用二进制查看工具打开实际编码后的码流数据。我们可以看到在码流的开始部分是一个起始码之后紧接着是一个SPS的NALU。在SPS后面是一个PPS的NALU。然后就是一个IDR Slice的NALU和一个非IDR Slice NALU。
![图片](https://static001.geekbang.org/resource/image/77/bb/775e22095450797346e3434f5abdfcbb.png?wh=1220x1256)
现在对于码流结构的认知你是不是很清晰了。你也可以去找个H264码流用二进制查看工具打开它。通过今天学习的知识你可以试着找出其中的起始码看看能不能找到SPS、PPS、IDR和非IDR Slice。如果你都能找出来那恭喜你说明你已经掌握了今天的主要知识点了。
## 常见工程问题
好了在了解了基本的码流结构知识之后我们来看看如何运用这节课学到的知识去解决工程上常见的一些问题。这里我列举了3个比较典型的问题如果你有更多问题的话可以到留言区我们一起讨论。
### 多Slice时如何判断哪几个Slice是同一帧的
我们前面讲过在H264码流中帧是以Slice的方式呈现的或者可以说在H264码流里是没有“帧“这种数据的只有Slice。但是有个问题是一帧有几个Slice是不会告诉你的。也就是说码流中没有字段表示一帧包含几个Slice。既然没有办法知道一帧有几个Slice那我们如何知道多Slice编码时一帧的开始和结束分别对应哪个Slice呢
![图片](https://static001.geekbang.org/resource/image/ae/64/ae932f9ae0f2de95e1943d98c5a35b64.jpeg?wh=1920x418)
其实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了。
![图片](https://static001.geekbang.org/resource/image/0d/86/0dbc23dc75c0811a0d83b06916c5aa86.jpeg?wh=1598x1252 "图片来源于H264标准文档")
其中first\_mb\_in\_slice是以无符号指数哥伦布编码的需要使用对应的解码方式才能解码出来。但是有一个小技巧如果只是需要判断first\_mb\_in\_slice是不是等于0不需要计算出实际值的话只需要通过下面的方式计算就可以了。
![图片](https://static001.geekbang.org/resource/image/2c/69/2c6acyy46cbdf0829da609e281b93c69.png?wh=1470x226)
这就是多Slice判断一帧的开始和结束的方法。
### 如何从SPS中获取图像的宽高
在编码端编码一个视频的时候,我们是需要设置分辨率告诉编码器图像的实际宽高的。但是解码器是不需要设置分辨率的,那我们在解码端或者说接收端如何知道视频的分辨率大小呢?
其实在编码器编码的时候会将分辨率信息编码到SPS中。**在SPS中有几个字段用来表示分辨率的大小。**我们可以**解码出这几个字段并通过一定的规则计算得到分辨率的大小**。这几个字段分别是:
![](https://static001.geekbang.org/resource/image/60/3d/603e4da34be2ab8bfc5d24eba83b693d.jpg?wh=1280x574)
这几个字段都是通过无符号指数哥伦布编码的需要先解码出来。解码得到具体值之后通过以下方法就可以得到分辨率了。注意pic\_height\_in\_map\_units\_minus1需要考虑帧编码和场编码的区别其中场编码已经很少使用了我们这里不再考虑。
![](https://static001.geekbang.org/resource/image/4c/be/4c9yy45d48234db0da222024653833be.jpg?wh=1280x720)
通过上面的方法就可以计算得到图像的分辨率了。
### 如何计算得到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。
![](https://static001.geekbang.org/resource/image/94/e2/9491235aba78da8693e77befabc0bbe2.jpg?wh=1280x388)
如果需要得到Slice级别的QP则只需要考虑前两个QP相关字段。如果需要计算宏块QP则需要三个都考虑。但是宏块QP需要解析整个Slice数据计算量大。一般我们直接计算到Slice QP就可以了。计算方法如下
![图片](https://static001.geekbang.org/resource/image/d5/3a/d5b6d75fd0567bca065ddf6631bce83a.png?wh=1328x344)
## 小结
这节课我们主要讨论了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的大小**,从而起到分隔的作用。
为了帮助你记忆,我们通过下图来总结一下。
![](https://static001.geekbang.org/resource/image/2e/f9/2edfa9c579e43efd2a76e94fa33f83f9.png?wh=1846x1434)
## 思考题
为什么有B帧的时候延时会高
你可以把你的答案和思考写下来,分享到留言区,与我一起讨论。下节课再见。