MP4
MP4结构简介
MP4文件是由多个box组成,一般来说常见的大box有三种:
- ftyp( file type,文件类型)box:标明自己是 MP4 并提供额外的兼容性信息
- moov(metadata container, 电影)box:包含以其他框的嵌套结构组织的元数据
- mdat(media data container,媒体数据)box:包含音频/视频有效载荷。
其中,moov是最重要的部分,包含的信息如下
- mvhd:movie header,文件的总体信息,如时长,创建时间等
- trak:track or stream container,存放视频/音频流的容器
- tkhd:track header,track的总体信息,如时长,宽高等
- mdia: track media information container ,不解释
- mdhd:media header,定义TimeScale , track需要通过TimeScale换算成真实时间
- hdlr: handler,表明本track类型,指明是video/audio/还是hint
- minf : media information container
- stbl: sample table box包含样本序号/时间/文件位置映射的信息
- stsd: sample descriptions
- stts: decoding time to sample, DTS sample序号的映射表
- ctts: composition time to sample CTS(创作时间)DTS对应sample序号的映射表
- stsc: sample-to-chunk, sample和chunk的映射表
- stsz/stz2: sample size, 每个sample的大小
- stss: sync sample table, 关键帧列表
- stco/co64: chunk offset, 每个chunk的文件偏移
找到MOOV BOX
第一步就是要找到moov box,以便于获取文件的相关信息,但是一般来说能看到的视频header有两种:
(可使用在线网页HexEd.it — 基于浏览器的十六进制编辑器 ,也可以下载010 editor进行二进制信息分析,这里使用的是010 editor)
第一种比较容易,因为moov box直接在最前面,但是第二种就稍微麻烦了,因为mdat在最前面,所以必须跳过数据包部分,处理这个问题有一个分支,一个是小于4GB的文件,一个是大于4GB的文件,小于4GB的文件,数据字节量会在mdat前面,占用4个字节(),大于4GB的文件,数据字节量会在mdat后面,占用8个字节,8个字节足以存储绝大多数视频的数据部分所占用的字节数了。
Python 代码如下:
if data.find(b"mdat") > 0: # 如果数据块中包含mdat字符串 index = data.find(b"mdat") # 找到mdat数据块的起始位置 # 读取mdat前的4个字节,如果结果是b'\x00\x00\x00\x01',则mdat数据块的大小需要用64位表示,需要读取mdat后面的8个字节 if data[index - 4 : index] == b"\x00\x00\x00\x01": video_size = struct.unpack(">Q", data[index + 4 : index + 12])[0] else: # 否则mdat数据块的大小用32位表示,需要读取mdat前面的4个字节 video_size = struct.unpack(">I", data[index - 4 : index])[0]
我们使用requests请求视频,通过流的形式进行请求
r = requests.get(url, stream=True) # 使用stream模式获取视频数据
获取时长和宽高
之后我们就要解析moov了。首先解析出视频的总时间、宽、高,我们首先找到mvhd,mvhd所包含的信息如下:
字段 | 类型 | 描述 |
version | 8 bit | 取0或1,一般取o |
flags | 24 bit | 标识 |
creation time | 64/32 bit | 创建时间,当version=0时取 32bit |
modification _time | 64/32 bit | 修改时间,当version=0时取 32bit |
timescale | 32 bit | 时间基 |
duration | 64/32 bit | 文件时长,当version=0 时取 32bit |
rate | 32 bit | 播放速率,默认取 0x00010000,即1.0 |
volume | 16 bit | 音量,默认取0x0100,即1.0 |
reserved | 16 bit | 0 |
reserved | 2x 32 bit | 0 |
matrix | 9 x 32 bit | 可忽略 |
pre_defined | 6x 32 bit | 0 |
next_track_ID | 32 bit | 下一个紧邻的 track box id |
我们找到Time Scale和Duration,知道这个文件是以1000毫秒为时基,有243544个时长,处理代码如下:
if data.find(b"mvhd") > 0: # 如果数据块中包含mvhd字符串 index = data.find(b"mvhd") + 4 # 找到mvhd数据块的起始位置,向后偏移4字节 # 从第13位到第17位读取时基的值,用大端序转换为整数 time_scale = struct.unpack(">I", data[index + 13 : index + 13 + 4])[0] # 从第17位到第21位读取时长的值,用大端序转换为整数 duration = struct.unpack(">I", data[index + 13 + 4 : index + 13 + 4 + 4])[0] seconds = duration / time_scale # 计算视频的秒数
再获取视频的长宽,视频的宽高一般来说是在第一个trak的tkhd中,第二个trak一般来说是音频的信息,tkhd所包含的信息如下:
字段 | 类型 | 描述 |
version | 8 bit | 取0或1,一般取0 |
flags | 24 bit | 标识 |
creation_time | 64/32 bit | 创建时间,当version=0时取32bit |
modification _time | 64/32 bit | 修改时间,当version=0时取32bit |
trackID | 32 bit | 本track id |
reserved | 32 bit | 0 |
duration | 64/32 bit | 本track时长,当version=0时取32bit |
reserved | 2x 32 bit | 0 |
layer | 16 bit | 视频轨道的叠加顺序,数字越小越靠近观看者,比如1比2靠上,0比1靠上; |
alternate_group | 16 bit | 当前track的分组lD, alternate_group值相同的track在同一个分组里面。同个分 组里的track,同一时间只能有一个track处于播放状态。当alternate_group为0 时,表示当前track没有跟其他track处于同个分组。一个分组里面,也可以只有 一个track; |
volume | 16 bit | 音量,如果是audio track,则为0x0100,即1.0,否则取o |
reserved | 16 bit | 0 |
matrix | 9x 32 bit | 可忽略 |
width | 32 bit | 宽,[16.16]格式,不必与sample的像素尺寸一致,用于播放时的展示宽高 |
height | 32 bit | 高,[16.16]格式,不必与sample的像素尺寸一致,用于播放时的展示宽高 |
则代码如下:
if data.find(b"tkhd") > 0: # 如果数据块中包含tkhd字符串 index = data.find(b"tkhd") + 4 # 找到tkhd数据块的起始位置,向后偏移4字节 # 从第76位到第80位读取视频长度的值,用大端序转换为小数 temp = struct.unpack(">I", data[index + 76 : index + 76 + 4])[0] if temp > 0: width = temp / 65536 # 读取到的值除以65536,得到视频的宽度 # 从第80位到第84位读取视频宽度的值,用大端序转换为整数 height = struct.unpack(">I", data[index + 80 : index + 80 + 4])[0] height = height / 65536 # 读取到的值除以65536,得到视频的高度
由于可能读到音频的tkhd,所以需要限定值大于零,原始宽高的数据是乘了一个65536,要除掉。(1920 × 65536 = 83886080)
H.264 编码简介
由于目前大部分MP4视频帧的封装格式都是h264,所以我们需要提取出h264的视频帧,然后加入h264的头,就可以把他们当做直播视频流之类的东西,通过PyAV库来进行解析,当然在这之前还需要确定这个视频是不是使用h264来进行编码的,此时需要检测是不是包含avc1 box,avc1 box包含在stsd(sample description box) 中。stsd的字段信息如下:
字段 | 类型 | 描述 |
version | 8 bit | 取0或1,一般取0 |
flags | 24 bit | ㅤ |
entry_ count | 32 bit | entry 个数 |
开始循环 | ㅤ | ㅤ |
AudioSampleEntry0 | 不定大小 | 子box,当handler_ type='soun'时才有 |
VisualSampleEntry() | 不定大小 | 子box,当handler_ type='vide'时才有 |
HintSampleEntry0 | 不定大小 | 子box,当handler_ type="hint 时才有 |
MetadataSampleEntry0 | 不定大小 | 子box,当handler_ type='meta'时才有 |
结束循环 | ㅤ | ㅤ |
有了AVC1 box,就可以提取出对解码比较重要的,在AVCC box中的几个关键信息SPS(Sequence Parameter Sets)和PPS(Picture Parameter Set),SPS 和 PPS 中存放了解码过程中所需要的各种参数,是 H.264 解码的前置条件,没有 SPS 和 PPS,视频将会无法解码,所以在解码的时候,我们总是首先把 SPS 和 PPS 传给解码器,供解码器初始化。AVC1和AVCC box的定义格式如下
AVC1:
字段 | 类型 | 描述 |
reserved | 6 x 8 bit | 0 |
data reference index | 16 bit | 可以检索与当前sample description关联的数据。数据引用存储在data reference box |
pre_defined | 16 bit | 0 |
reserved | 16 bit | 0 |
pre_defined | 3x32 bit | 0 |
width | 16 bit | 像素宽度 |
height | 16 bit | 像素高度 |
horizresolution | 32 bit | 每英寸的像素值(dpi),[16.16]格式的数据,固定为 0x00480000,72 dpi |
vertresolution | 32 bit | 每英寸的像素值(dpi),,[16.16]格式的数据,固定为 0x00480000,72 dpi |
reserved | 32 bit | 0 |
frame_ count | 16 bit | 每个sample中的视频帧数,固定为1 |
compressorname | 32 bit | 0 |
depth | 16 bit | 0x0018,rgb24 位深 |
pre_defined | 16 bit | -1 |
AVCC | ㅤ | ㅤ |
AVCC:
字段 | 类型 | 描述 |
configurationVersion | 8 bit | 固定1 |
AVCProfilelndication | 8 bit | 编码时设:置的profile, 例如base、 main、 high等,具体 参见ISO_ IEC_ 14496-10-AVC 2003.pdf, page 45 |
profile_ compatibility | 8 bit | ㅤ |
AVCL evellndication | 8 bit | 编码时设置的level |
reserved | 6 bit | 111111'b |
lengthSizeMinusOne | 2 bit | ㅤ |
reserved | 3 bit | '111'b |
numOfSequenceParameterSets | 5 bit | sps个数,一般为1 |
sps循环开始 | ㅤ | ㅤ |
sequenceParameterSetLength | 16 bit | ㅤ |
sps | 8 * sequenceParameterSetLength | sps |
sps循环结束 | ㅤ | ㅤ |
numOfPictureParameterSets | 8 bit | pps个数,一般为1 |
pps循环开始 | ㅤ | ㅤ |
pictureParameterSetLength | 16 bit | ㅤ |
pps | 8 * pictureParameterSetLength | pps |
pps循环结束 | ㅤ | ㅤ |
我们还要了解H.264编码的三种帧I帧 P帧 B帧。
- I 帧:帧内编码帧,帧表示关键帧,你可以理解为这一帧画面的完整保留;解码时只需要本帧数据就可以完成(因为包含完整画面)
- 它是一个全帧压缩编码帧,它将全帧图像信息进行JPEG压缩编码及传输
- 解码时仅用I 帧的数据就可重构完整图像
- I 帧描述了图像背景和运动主体的详情
- I 帧不需要参考其他画面而生成
- I 帧是P帧和B帧的参考帧(其质量直接影响到同组中以后各帧的质量)
- I 帧不需要考虑运动矢量
- I 帧所占数据的信息量比较大
- P 帧:前向预测编码帧。P帧表示的是这一帧跟之前的一个关键帧(或P帧)的差别,解码时需要之前缓存的画面叠加上本帧定义的差别,生成最终画面。(也就是差别帧,P帧没有完整画面数据,只有与前一帧的画面差别的数据)
- P帧的预测与重构:P帧是以 I 帧为参考帧,在 I 帧中找出P帧“某点”的预测值和运动矢量,取预测差值和运动矢量一起传送。在接收端根据运行矢量从 I 帧找出P帧“某点”的预测值并与差值相加以得到P帧“某点”样值,从而可得到完整的P帧。
- P帧是 I 帧后面相隔1~2帧的编码帧
- P帧采用运动补偿的方法传送它与前面的I或P帧的差值及运动矢量(预测误差)
- 解码时必须将帧中的预测值与预测误差求和后才能重构完整的P帧图像
- P帧属于前向预测的帧间编码。它只参考前面最靠近它的 I 帧或P帧
- 由于P帧是参考帧,它可能造成解码错误的扩散
- 由于是差值传送,P帧的压缩比较高
- B帧:双向预测内插编码帧。B帧是双向差别帧,也就是B帧记录的是本帧与前后帧的差别(具体比较复杂,有4种情况,但我这样说简单些),换言之,要解码B帧。不仅要取得之前的缓存画面,还要解码之后的画面,通过前后画面的与本帧数据的叠加取得最终的画面。B帧压缩率高,但是解码时CPU会比较累。
- B帧的预测与重构: B帧以前面的 I 或P帧和后面的P帧为参考帧,“找出”B帧“某点”的预测值和两个运动矢量,并取预测差值和运动矢量传送。接收端根据运动矢量在两个参考帧中“找出(算出)”预测值并与差值求和,得到B帧“某点”样值,从而可得到完整的B帧。
- B帧是由前面的 I 或P帧和后面的P帧进行预测的
- B帧传送的是它与前面的 I 或P帧和后面的P帧之间的预测误差及运动矢量
- B帧是双向预测编码帧
- B帧压缩比最高,因为它只反映并参考帧间运动主体的变化情况,预测比较准确
- B帧不是参考帧,不会造成解码错误的扩散
注:I、B、P帧是根据压缩算法的需要,是人为定义的,他们都是实实在在的物理帧。
一般来说,帧的压缩率是7(跟JPG差不多),
P帧是20,B帧可以达到50.可见使用B帧能节省大量空间,
节省出来的空间可以用来保存多一些帧,这样在相同码率下,可以提供更好的画质。
同时这解析的时候也不是顺序解析的,而是有一定的顺序,基本上来说就是I→P→B,看图:
这个顺序是在MP4 moov头中的cttc box里描述的,但是据我的测试,没有这个顺序描述box,ffmpeg也能解析出实际的画面,这可能与我们是使用h264裸流进行解析的有关。
通过FFMPEG将MP4转换为H264裸流
ffmpeg -i input.mp4 -vcodec copy -an output.h264
在H264中,每一个单元被称作NALU,这是H264的基本单位,在NALU之下,还有Slice和宏块(Macroblock),结构层级如下:
一般来说,一个NALU中含有一个Slice,一个Slice中含有多个宏块(Macroblock),在视频中,为了尽可能提高压缩率,上一帧和下一帧之间都是通过演算得到的,一般来说上一帧和下一帧都有共通点,比如出现了一个人站在一个场景里说话,实际上只是部分块发生了位移,这个时候就可以只把运动的块的位移算出来进行保存,就不用存储画面了,通过程序进行解析的效果如下:
这就是宏块的作用,也是为什么P、B帧能压缩,并且压缩率能这么高。
在H264裸流中,每一个NALU都以 0x00 00 01 开头,这也是判断上下NALU的关键。
IDR NALU
在分析h264裸流时,会经常遇到两种NALU:IDR和非IDR:
IDR(Instantaneous Decoding Refresh,即时解码刷新),从名字上可以看出,一遇到IDR,画面就应该立即刷新,同时IDR NALU比较大,我们可以认为IDR NALU ⇒ I 帧,但是I帧≠IDR。所有 IDR 帧都是 I 帧,但并非所有 I 帧都是 IDR 帧。IDR(瞬时解码器刷新)帧是特殊的 I 帧,它不仅包含完整的图片,还表示 IDR 之后的任何 P/B 帧都不允许引用 IDR 之前的帧。
多个NALU描述一个画面的情况
我在实际测试的时候遇到了这样的文件,几个I帧连在一起,其实我们不应该通过看头是不是65来判断是不是I帧,是不是I帧实际上是由NALU中Slice Header中的slice_type规定
可以看到第二项就是slice type,而它的大小为ue(v),这里指的是无符号指数哥伦布算法,这种算法比较简单
指数哥伦布编码