转帖地址:http://blog.csdn.net/fan2273/article/details/77653700
基于DirectShow的框架H.264 RTP Sender Filter
开发框架与环境:
1.VS2017——工具集为V120-VS2013
2.jrtplib-3.11.1 jthread-1.3.3 编译为32位版
3.程序为32位程序
4.DirectShow链路图如下(控制台为RTP发送地址与本机端口)
5.RTP拆包方式为FU-A
6.x264编码filter为ffdshow codec(本filter支持输入为H264的sample)
7.本程序开源(部分实现借鉴了许多CSDN博客与大牛的程序),请遵守开源协议
git地址:https://github.com/EthanXzhang/RTP-Sender-Filter
实现部分
有空的话应该会整理个更详细的版本,这里主要就说一下遇到的问题好了。
实现DirectShow+jrtplib的H264收发包程序,总共用时约两周,主要的精力和时间花在了RTP协议、RTP拆包与H.264
字节流处理、H.264格式的解析上。
1.H.264的NAL单元(NALU)
H.264编码实现了网络层也就是NAL,其中每一个单元(NALU)适用于网络传输,详细的这里不阐述了,捡重点的方
便大家快速理解。
编码器对原始采集视频(图像)进行处理后,输出的每一帧即是一个NALU。这里的每一帧包括编码器初始化输出的
PPS和SPS。
实际上,我们需要用RTP实现的传输,就是从输入pin中的数据,提取每一帧(NALU),对NALU进行打包发送。
H.264编解码,怎样完成?需要怎样保持数据的发送?
首先,H.264的编码,除了初始化编码器后,输出PPS和SPS外,之后的所有NALU单元,都为I-P-B帧的组合。其中,
I帧为关键帧,解码器遇到这一帧后,会清除重置解码器的预测基准(具体请参考H.264编码与运动预测模型),而P
帧和B帧则是前向预测帧和双向预测帧。H.264由于进行了运动预测,因此除了I帧外,P帧和B帧仅需要较少的bit进行
编码,从而减少传输的数据量。
既然考虑网络传输,肯定需要知道接收端/解码器,需要什么信息才能完成解码。网上一些资料说到,需要PPS和
SPS,解码器才能获得编码的信息。这部分可能是基于封装后的H.264编码视频,而不是指实时采集的H.264视频流。
这里可以告诉大家的是,解码器需要的一切信息,都在编码器定时编码输出的I帧中。也就是,接收端开启后,收到到
来的第一个I帧后,便可以开始正常的解码工作。之后遇上任何网络波动、丢包、延迟等情况,都会在下一个I帧后重
置。I帧的速率,由编码器控制。由于I帧需要较大的编码量,因此一般编码器缺省I帧速率居中,通常受输出压缩率与
质量影响。
较高的I帧速率,可以保证较好的动态分辨率,降低延迟、丢包等带来的视频模糊、拖影、花屏影响,但相对的会增
加网络传输的数据量。(通常一个I帧的数据量是P B帧的五倍以上)
如果网络丢包严重、延迟较高,有需要较好的动态分辨率和画质,可以通过提高I帧的编码速率来解决。
这一部分,简单来说,编码器输出一个个NALU,而我们不需要管这个NALU是具体什么帧,只需要对它进行RTP
拆包并发送就可以了。解码器会自动等待I帧,并开始之后的解码工作。
2.NALU的结构
无论这个NALU是PPS还是SPS,又或者I P B帧,他们都具有一样的结构。
startcode+NALU头+NALU数据。
startcode,起始码,主要是帮助解码器从数据流中分辨NALU用。startcode格式十分固定,但根据编码器规范,
可能具有两种不同的形式。
三位的0x000001与四位的0x00000001。无论哪种格式,都可以通过读0后读1判断。当遇到一个startcode后,紧接
着startcode后的数据就是该NALU的数据,直到遇到下一个startcode。
对于实时采集编码来说,由于我们使用的filter处理数据单位为Sample,每一个MediaSample中携带的数据即是一帧
数据,因此,永远都是以startcode开头,并且无需判断该NALU结束(直接使用actualsize数据长度取数据)
但是,这里有一个问题。
由于MediaSample的getPointer(BYTE **pb)方法,获得指向内存的指针。而BYTE为unsigned char型,0x00在
char型中默认为NULL。也就是,此时返回的指针会提示指向的数据为空('0' \0),但实际只是因为指向的第一个char
型内存单元为0x00。
此时,不要慌张,通过循环判断*pb==0x00(或NULL);pb++的方法,使指针后移,便可取到startcode后的NALU
数据。紧接着startcode,是一个字节NALU头,8位bit组成。由高位到低位依次是F(1bit)-NRI(2bit)-TYPE(5bit)。
这个部分后面的RTP拆包需要用到,因此需要保存下来(我使用了一个结构体,方便赋值)
NALU头之后,便是H.264的帧数据,这部分原封不动保留下来,直接装载到RTP包的playload中就好了。
PS:这一部分,主要可能遇到的问题是startcode的处理。由于startcode开头有2-3位的0x00,很多人使用
IMediaSample->getPointer方法会以为取得了空指针,而不断怀疑filter与Sample的问题。实际上,只要对指针pb进行
后移处理就好。(其实,我这里就被坑了3天,才反应过来)
3.RTP发包
JRTPLib使用的UDP协议进行发包。UDP协议是不可靠传送协议,因此,装载数据量大的UDP包容易被路由丢弃。
因此,根据RTP协议,通常将每一个RTP包的最大装载量限制在1400(JRTPLIB中用MAX定义为1360大小)。
RTP拆包协议主要由FU-A和FU-B两种,这里我主要使用的是FU-A拆包方式,具体的可以百度更详细的资料,不过
度展开。FU-A的好处是,可以直接使用VLC等播放器,对你的发送端进行测试,而不需要开发出接收端。
当你需要对NALU进行拆包时,假设拆了N个包,那么这里面只有两种包,即前N-1种和最后一包N。(最后一包不
同)FU-A协议,需要在每一个NALU数据前(去掉NALU header),额外添加FU indicator和FU header,各一个字节。
不需要拆包的NALU单元,直接发送即可(包括NALU头)。
FU
indicator有以下格式:
+---------------+
|0|1|2|3|4|5|6|7|(注意,左边为高位,右边为低位,此处0-7表示比特流的起始到终止的方向)
+-+-+-+-+-+-+-+-+
|F|NRI| Type |
+---------------+
FU指示字节的类型域 Type=28表示FU-A。。NRI域的值必须根据分片NAL单元的NRI域的值设置。
FU header的格式如下:
+---------------+
|0|1|2|3|4|5|6|7|
+-+-+-+-+-+-+-+-+
|S|E|R| Type |
+---------------+
S: 1 bit
当设置成1,开始位指示分片NAL单元的开始。当跟随的FU荷载不是分片NAL单元荷载的开始,开始位设为0。
E: 1 bit
当设置成1, 结束位指示分片NAL单元的结束,即, 荷载的最后字节也是分片NAL单元的最后一个字节。当跟随的FU
荷载不是分片NAL单元的最后分片,结束位设置为0。
R: 1 bit
保留位必须设置为0,接收者必须忽略该位。
Type: 5 bits
NAL单元荷载类型定义见下表
表1. 单元类型以及荷载结构总结
Type Packet Typename
---------------------------------------------------------
0 undefined -
1-23 NALunit Single NAL unit packet per H.264
24 STAP-A Single-time aggregation packet
25 STAP-B Single-time aggregation packet
26 MTAP16 Multi-time aggregation packet
27 MTAP24 Multi-time aggregation packet
28 FU-A Fragmentation unit
29 FU-B Fragmentationunit
30-31 undefined
简单来说,FU-A的拆包方式,indicator你只需要注意TYPE设置为28,F和NRI全部取NALU的头对应的位。Header你
只需要注意TYPE取得NALU头的type,S E R全部置0(最后一包的E置1)。
这里说明一下FU-A的工作方式。
接收端根据RTP包中头一位(可能为FU indicator或NALU header)的TYPE位,判断这个包具体是什么。
当紧接着一包的FU header E位为1时候,接收端便知道要进行组包工作。
JRTPLib通过RTP包的mark位判断是否是最后一包,进行组包(具体见代码)。
4.filter的实现
这部分没太多可说的,不过因为DirectShow filter相关的资料现在越来越难找,因此也大概说一下遇到的问题。
本程序前后使用了CBaseRenderer、CBaseVideoRenderer、CTransformFilter实现。
其实,选用哪个filter,主要看当前filter的目的和在整个链路中的定位。
我当前使用的是Transform,作为中间filter。
实际上,我是被迫这么做的。
早期我打算使用的Renderer,继承实现doRender方法,来进行RTP发包。但由于IMediaSample的getPointer一直为
空,而我的资料又很有限。在前后换了使用Base和Video的父类,使用了pin方法Receive,仍然解决不了后,我就更换
了Transform filter来尝试实现。(这个filter的资料相对多一些)
实际上,只是因为startcode的0x00,字符指针为NULL而已。
关于filter部分,实际上需要实现的基本只有以下几个部分:
1.filter信息与注册(名字、CLSID、pin属性)
2.checktype方法(不同位置的check方法不同,用以检测pin口是否匹配,通常必须实现)
3.关键的处理方法(doRender、Transform、FillBuffer)
4.createInstance与构造函数完成初始化
5.RTP传输问题处理
进行RTP传输的时候,视频经常会出现灰蒙、抖动、花屏。
总结来说,基本就是延迟、丢包、乱序的问题。
但在本地收发测试中,丢包和延迟的问题基本不会存在,一般也不可能存在乱序的情况。
那么,为什么本地VLC测试,还会出现上述状况呢?
请使用秒表在采集端进行测试……
经过我的检测发现,是发送端的时间戳和发送频率、延迟设置的问题。
由于实时采集,一般来说,发送频率和帧率是匹配,播放端才能还原出和采集端同速的图像。
如果发送端,发送速度高于帧率,播放端接收到就会马上播放,因此时常会处于等待状态,出现延迟的情况。
如果发送端,发送速度慢与帧率,播放端则会慢速播放,而下一个I帧到来又会刷新还没播放完的P B帧,出现卡顿的情况。
而类似画面抖动,帧间预测导致画面中动作往复、影响重叠,则是RTP包乱序,或者P B帧跟随前面的I帧顺序不对,
也大多是因为上述问题产生的。