从ffmpeg中抓取mv/mb_type/dct_coeff/qp和MBSize等数据(H.264)

    ffmpeg是一个很复杂的库,在我看来,比JM要复杂很多,刨除其包含各种编解码方案,算法的全面性,以及其各种平台的汇编优化等因素,其运行逻辑结构和函数之间的调用关系等都要复杂很多。今天我们不泛泛而谈,来点干货,看看如何从ffmpeg中提取标题中所涉及到的这些编解码过程中的中间数据。由于作者水平有限,时间仓促等原因,很多细节不能详细展开,还请观者多多包涵。

    提到ffmpeg,不得不提的一位大神级人物是雷神(这里向雷神致敬),他真的是一个真正的编解码专家,我也是从他的博客里学到了很多东西,才得以对ffmpeg有了一些自认为比较深入的理解,也才有了这篇博客。

    下面我们一一展开说我们本文的主要内容。

1. mv——运动矢量

    mv——Motion Vector,即运动矢量。做视频运动相关的同学对这个感念应该不陌生,其实它描述的就是block级别的光流,视频编码过程中以块为单位进行亚像素精度的运动矢量的搜索,得到两个相关块之间的运动矢量,描述两帧之间的运动方向和大小。编解码中传递的其实是当前block与相邻block的运动矢量之间的差值,经过熵解码和运动矢量预测,才能得到真正的当前block的运动矢量。

    不过,要提取完整的mv,不仅需要mv的大小,还需要知道该mv对应的block位置坐标,该block的分块大小,另外,抠的比较细的同学可能还需要知道这个mv是描述哪两个帧之间的运动信息,以及,该mv的scale是多少(因为是亚像素精度,那么是1/2像素精度,是1/4像素精度还是1/8像素精度呢),这些都是个问题。但是,经过熵解码和运动矢量预测这一步骤之后,以上的这些信息其实都可以拿到了。

    那么,ffmpeg中怎么能拿到运动矢量数据呢?其实,上面也提到了,mv经过熵解码和运动矢量预测,就可以拿到每个block的mv数据,即:decode_slice中,ff_h264_decode_mb_cabac函数执行完之后,可以通过h->cur_pic.motion_val拿到每一个block的运动矢量信息。注意,这个motion_val是一个矩阵,包含了每一个block的运动矢量的大小。但是,它并没有直接给出运动矢量对应block的大小和位置信息,也没有给出mv来自于哪两个帧以及运动矢量的scale。最最最最重要的是:它不能给出一整帧的数据,即使decode_slice执行完,它也只是给出这一个slice的mv,而不是整个frame的mv数据。有人可以说,以上这些额外的信息我都可以自己查表等方式拿到,一个frame(或者一个NALU)对应的多个slice的mv数据我可以通过多次解码最终组合而成。但是,我想说的是,这太麻烦了,工作量太大了,其实,mv数据是一个很常用的数据,ffmpeg官方已经给出了一个标准途径把这个数据提取出来,还给出了一个官方范例,不信你可以查看你的ffmpeg文件夹下面/doc/example/是不是有个extract_mvs.c文件,这个就是ffmpeg给出的官方示例,告诉你如何提取一整帧的mv数据。

    简单来说,就是在打开解码引擎的时候,插入一个选项“+export_mvs”,该选项定义在libavcodec/options_tables.c中,

        /* Init the video decoder */
        av_dict_set(&opts, "flags2", "+export_mvs", 0);
        if ((ret = avcodec_open2(dec_ctx, dec, &opts)) < 0) {
            fprintf(stderr, "Failed to open %s codec\n",
                    av_get_media_type_string(type));
            return ret;
        }

    这个选项通知ffmpeg的h264解码引擎,要把每一帧的mv所有信息都写入到AVFrame中的side_data中,最后可以通过side_data来提取运动矢量的以上所有数据,方式如下:

            int i;
            AVFrameSideData *sd;

            video_frame_count++;
            sd = av_frame_get_side_data(frame, AV_FRAME_DATA_MOTION_VECTORS);
            if (sd) {
                const AVMotionVector *mvs = (const AVMotionVector *)sd->data;
                for (i = 0; i < sd->size / sizeof(*mvs); i++) {
                    const AVMotionVector *mv = &mvs[i];
                    printf("%d,%2d,%2d,%2d,%4d,%4d,%4d,%4d,0x%"PRIx64"\n",
                           video_frame_count, mv->source,
                           mv->w, mv->h, mv->src_x, mv->src_y,
                           mv->dst_x, mv->dst_y, mv->flags);
                }
            }

    这样,我们就不要对ffmpeg库本身做任何修改,可以在最上层的应用程序中,在正常解码完一帧之后,就不费力的拿到每一帧的完整运动矢量信息。

2. QP——量化参数

    量化参数也是一个重要的参数。我们知道,在像素的灰度值数据,经过预测做差,DCT变换,且DC分量经过Hardamad变换之后,下一步就是量化,将频域的残差数据量化到一个较小的范围内,进而降低编码后的比特数。每一个宏块的量化参数QP是不一样的,它在一定程度上反映了这个宏块的能量大小,即该宏块与所有预测块之间的差异程度。

    那么,怎样拿到一帧的量化参数呢?当然,我们依然可以通过修改ffmpeg库,在反熵编码函数执行完之后拿到每个宏块的QP。虽然工作量不大,但也还是麻烦的。所幸的是,ffmpeg又帮我们做了这件事。这次虽然没有直接给出示例,但是也给了一个接口函数,帮我们拿到每一帧中每个宏块的QP数据。这个函数就是av_frame_get_qp_table,它与上面提取mv用到的av_frame_get_side_data函数是一个级别的函数,都定义在libavutil/frame.c中。

    与提取mv一样,我们在上层应用程序中,在执行完一帧的解码之后,可以调用该函数拿到一帧的qp数据,也省去了我们需要的工作量。

    以上,QP和MV都可以通过ffmpeg的接口函数拿到,但是好事到此为止,后面的数据都需要我们一一手动提取,都要通过修改ffmpeg库来拿到(至少在我有限的知识库里面,是需要这样的)。

    但是,这一切只是好梦一场,其实,楼主这样试过,直接通过av_frame_get_qp_table根本屁也提取不到,可能是ffmpeg官方觉得这样返回这个应用比较小众的数据不值得,毕竟多返回一类数据,就需要多一些内存开销。

    那么,为了得到QP,我们还是老老实实地修改ffmpeg库吧,就这么开始吧。

    我们知道,最上层应用程序中调用ffmpeg解码h264码流的接口函数是avcodec_decode_video2函数,该函数调用avctx->codec->decode函数完成真正的解码,而decode函数是一个函数指针,它指向h264_decode_frame,这是因为libavcodec/utils.c中又如下定义

AVCodec ff_h264_decoder = {
    .name                  = "h264",
    .long_name             = NULL_IF_CONFIG_SMALL("H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10"),
    .type                  = AVMEDIA_TYPE_VIDEO,
    .id                    = AV_CODEC_ID_H264,
    .priv_data_size        = sizeof(H264Context),
    .init                  = ff_h264_decode_init,
    .close                 = h264_decode_end,
    .decode        = h264_decode_frame,
    .capabilities          = /*AV_CODEC_CAP_DRAW_HORIZ_BAND |*/ AV_CODEC_CAP_DR1 |
                             AV_CODEC_CAP_DELAY | AV_CODEC_CAP_SLICE_THREADS |
                             AV_CODEC_CAP_FRAME_THREADS,
    .caps_internal         = FF_CODEC_CAP_INIT_THREADSAFE,
    .flush                 = flush_dpb,
    .init_thread_copy      = ONLY_IF_THREADS_ENABLED(decode_init_thread_copy),
    .update_thread_context = ONLY_IF_THREADS_ENABLED(ff_h264_update_thread_context),
    .profiles              = NULL_IF_CONFIG_SMALL(ff_h264_profiles),
    .priv_class            = &h264_class,
};

    所以,我们只要修改h264_decode_frame函数即可?但是,那样会导致其它调用该函数的流程多执行我们定制的步骤,比如,同一个文件下面定义的另一个对象AVCodec ff_h264_vdpau_decoder中也把decode函数指向了h264_decode_frame。所以,我们最稳妥的办法是给AVCodec增加一个成员变量decode_2之类的,然后,在ff_h264_decoder中将decode_2=h264_decode_frame_2,然后,我们将h264_decode_frame函数全部复制过来给h264_decode_frame_2主体,但是h264_decode_frame_2需要多一个参数,用来保存QP数据,它可以是一个指针,也可以是一个数组变量。

修改:

在libavcodec/avcodec.h的AVCodec结构体定义中,在decode成员下面添加如下一行:

int (*decode_2)(AVCodecContext *, void *outdata, int *outdata_size, AVPacket *avpkt, signed char* QP);

同文件下,在avcodec_decode_video2函数声明下面,生命另一个函数

attribute_deprecated
int avcodec_decode_video2_2(AVCodecContext *avctx, AVFrame *picture,
                         int *got_picture_ptr,
                         const AVPacket *avpkt, signed char* QP);

在ff_h264_decoder中增加如下一行,

.decode_2                = h264_decode_frame_2,

在libavcodec/h264dec.c中,复制h264_decode_frame函数定义,修改函数名和形参列表如下

static int h264_decode_frame_2(AVCodecContext *avctx, void *data,
                             int *got_frame, AVPacket *avpkt, signed char* QP)

并在函数主体最后,找到这一句:

ret = finalize_frame(h, pict, h->next_output_pic, got_frame);

在下面添加这一句:

memcpy(QP, h->cur_pic_ptr->qscale_table, h->mb_num*sizeof(signed char));

在libavcodec/utils.c中,将avcodec_decode_video2函数复制粘贴一边,修改函数名为avcodec_decode_video2_2,并在其中找到下面这一句内容,

 ret = avctx->codec->decode(avctx, picture, got_picture_ptr,&tmp);

这句话改成如下四句:

if (avctx->codec->name == AV_CODEC_ID_H264)
    ret = avctx->codec->decode_2(avctx, picture, got_picture_ptr, &tmp, QP);
else
    ret = avctx->codec->decode(avctx, picture, got_picture_ptr, &tmp)

    经过这样修改,重新编译,再运行,记得在调用avcodec_decode_video2_2时候,多传入已分配内存的QP指针,就可以在应用程序中获得完整的一帧的量化参数。

3. MB_Type——宏块类型

    宏块类型反映了一个宏块是帧内预测还是帧间预测,还有一个宏块是否按照16X16整个宏块预测做差,还是分成两个16X8或8X16,或者4个8X8,至于每个8X8是否继续切分成两个8X4或4X8或4个4X4来进行预测做差,则需要继续查询sub_mb_type才能知道。下面来看看如何修改ffmpeg库可以拿到这个数据。

    as we know,上层应用程序解码视频调用的函数是:avcodec_decode_video2_2,对于h264视频,该函数进一步调用ff_h264_decode_frame来解码264码流,且解码过程中,ffmpeg维护了一个mb_type指针,与qscale_table一样,定义在H264Picture结构体中(libavcodec/h264dec.h中),该指针指向了一帧的宏块类型表,且不是空,所以可以像取得QP那样拿到mb_type,具体操作也是修改avcodec_decode_video2_2,decode_2,ff_h264_decode_frame_2中的形参列表,添加一个usigned int* mb_type参数,并传递给最终的ff_h264_decode_frame_2函数,且也在

ret = finalize_frame(h, pict, h->next_output_pic, got_frame);

之后,使用memcpy语句将mb_type复制出来即可。

memcpy(mb_type, h->cur_pic_ptr->mb_type, h->mb_num*sizeof(unsigned int));

之后,重新编译ffmpeg库,再重新编译应用程序,即可拿到一整帧的mb_type表。

4. DCT_Coeff——DCT系数

    DCT系数也是编解码的一个重要的中间参数,它反映的是每个编码块与预测块的差值水平,对于I帧来说,DCT系数就是每个编码块与由其相邻块中部分像素经过8种映射规则(之一)形成的预测块之间的差值的DCT变换后的系数,对于P帧和B帧来说,就是当前编码块与其在相邻帧中对应块之间像素值差的DCT变换系数。也就是说,DCT系数不像是JPEG中那样是像素值的DCT变换,反映纹理丰富程度,而是预测残差的DCT变换,反映的是帧内纹理变化程度或者帧间差异程度。这就决定了,依靠H264中的DCT系数,你很难,甚至根本看不出原图中到底有什么内容,有什么样的纹理,当然,也很难进行图像匹配。但是,它也有它的利用价值,就是它反映出原始视频中该编码块的变化程度,好好利用还是很有价值的。

    DCT系数的获取也需要修改ffmpeg库。细致的步骤就不赘述了,比如函数声明和添加参数之类的。下面直接上干货。

    对于I_16_16(帧内16*16大小的块,即一整个宏块作为一个块预测)之外,在h264_slice.c中(调用的)函数ff_h264_decode_mb_cabac执行完之后,就可以拿到该宏块的DCT系数,存储在sl->mb中,且存储方式很奇特,是4*4内列优先存查,8*8内4个4*4呈zig-zag方式存储,4个8*8以zig-zag方式存储,如下图所示。


图1 ffmpeg中的宏块dct系数存储和读取方式

    需要注意的是,sl->mb是一个指针,指向一段连续的内存,存储了256个数据,所以,这一宏块的这16行16列数据是相当于存储在一个数组里面的,而我们要将它转换成原图尺寸大小的一片矩阵内存中,就需要我们按照上述方式正确读取和排列dct系数,这里不赘述了。

    针对I_16_16的宏块,ffmpeg怎么读取dct系数呢?这里要说明一下,ff_h264_decode_mb_cabac中对I_16_16的宏块,仅仅做了反熵编码和反量化,还没有对16个DC系数进行反Hadamard变换,因此,要想拿到正确的DCT系数,还需要完成一步反Hadamard变换才可以,这一步是在ff_h264_hl_decode_mb中进行的,而在ff_h264_hl_decode_mb完成后,sl->mb却被清零了(这是为了下一个宏块正确解码而必须做的?反正我把sl->mb保留了之后,后面的宏块解码就出错了),所以,仍需要进入ff_h264_hl_decode_mb中去修改,但是,该函数其实最终指向FUNC(hl_decode_mb)(在h264_mb_template.c中),所以需要在这里改,而且需要将保存DCT系数的指针作为形参传递给该函数,我的做法依然是将该函数复制一遍,重新起一个名字,且多一个保存DCT系数的形参。

    函数体内的修改是,hl_decode_mb_idct_luma函数被调用前,将sl->mb复制到所传递过来的指针中,

memcpy(dct_coeff, sl->mb, 256*sizeof(short));

    如果你要将dct_coeff再保存在原图尺寸的矩阵中,则需要重新排列上述dct系数矩阵。这里不再赘述了。

5. MBSize——宏块编码长度

    这个参数花了我整整两个星期才搞定,因为之前实在不明白cabac编码原理,所以就花了很长时间理解cabac算法,以及基础的算术编码知识,和cabac之前的二进制过程,这里推荐一本书,讲解的挺清楚的,很多网上的资料也是来源于此,此外,这本书还需要对照着H264官方手册看。

    总之,要搞清楚一点,cabac之后的01码流不代表任何原始数据信息,而是代表一个小数,一个概率,该概率反映了各语法元素二进制化之后的序列的分布特性,且相同的二进制序列在不同的上下文中得到的概率肯定是不一样的。对这个概率的解码,可以得到原始各语法元素二进制化之前的01序列,对这个01序列再反二进制化才可以得到原始的语法元素,再将语法元素还原成原始的DCT残差,mb_type等元素,又需要一个步骤。不过,一个个步骤执行太慢,ffmpeg将后面几个步骤合而为一了,所以代码就比较难看懂。这里仅仅关注熵解码这一步,我们需要知道一个宏块占据了cabac之后多少个bit,就需要知道cabac解码前后,解码指针指向的位置,仔细观察可以发现,sl->cabac就是解码时的cabac信息,cabac是一个结构体对象,其结构体定义如下:

typedef struct CABACContext{
    int low;
    int range;
    int outstanding_count;
    const uint8_t *bytestream_start;
    const uint8_t *bytestream;
    const uint8_t *bytestream_end;
    PutBitContext pb;
}CABACContext;

其中,bytestream就是cabac读取码流的指针,可以通过解码该宏块前后的bytestream地址之差计算出当前宏块在bit流中的位置

    在解码时候,由于cabac一次性移位进来26个bit,进行熵解码,如果仅根据字节地址来计算宏块大小,会有最大8个bit的误差,在不要求精度的情况下,也可以接受。但是如果要精确值,则还需要计算出字节内的bit级位置信息,这可以通过low中的拖尾0个数计算出来。

    // 熵解码前的拖尾0个数,用于计算bit级长度
    trailings_before = ff_ctz(sl->cabac.low);
    bitstream_start = sl->cabac.bytestream;

    // 该宏块的其他解码过程
    ...
    // 该宏块的byte级长度
    cur_MBSize = sl->cabac.bytestream - bitstream_start;
    // 熵解码后的拖尾0个数,用于计算bit级长度
    trailings_after = ff_ctz(sl->cabac.low);
    cur_MBSize += (trailings_after - trailings_before );
    经过上述 “字节”长度“字节地址内bit”长度相加,即可得到该宏块的bit长度。


6. 几个暗坑

    按照上述方式修改代码就一定能获得正确的数据了吗?如果你认为博大精深的ffmpeg却没有几个坑的话,那就太naive了!!!其实,ffmpeg中还是有不少坑的。下面就捡我知道的几个来说一说吧,如果你恰好遇到过,那说不定我们可以产生共鸣。

6.1 MV个数变少

    是的,MV的个数会变少!你也不知道去了哪里。我们上述所给出的取mv的方法来源于ffmpeg的官方示例程序,所以应该是没问题的,但是呢,实际运行时候,却会发现mv的个数会比实际的个数要少一些,比如,1088*1920的图像,宏块个数为1088*1920/256=8160个,而mv个数应该比这个多,比如9320个,但是呢,你通过side_data拿到的mv个数说不定只有8960个,那么你怎么知道它变少了呢?当然是踩到坑里才发现的呀!比如,你发现,(272, 480)处有一个宏块,这个宏块的运动矢量为0,且该宏块只有一个mv,但是会发现它指向的下一个mv所在的坐标变成了(272, 512),那么中间的(272, 496)处的mv去哪了呢?虽然它也可能是0,但是也不应该被忽略了呀!!!为什么被略过了呢?我也不知道,这个坑有空的人去研究吧,希望有朝一日能看到有关的研究成果,或者ffmpeg官方修正了这个bug,那就万事大吉。

6.2 mb_xy与实际的宏块的索引值不同

    按理说,mv_xy应该是通过mb_y*coded_width/16+mb_x(称作mb_index)计算出来的,反映了这个宏块在所有宏块按行排列中的索引值,而我们提取mb_type/QP都是通过mb_xy来从相应的数据结构中提取出来的,那么提取出来之后,我们在应用程序中,也可以通过mb_index(=mb_y*coded_width/16+mb_x)重新计算出的索引值来取用。但是,实际应用中才发现,特么按照本文第2/3章中的方法将mb_type和QP拷贝出来之后,使用mb_index再来取用数据时候却发现,除了第一行的数据是正确的之外,第二行的第一个数据是0,第二行的第二个数据是其实是第二行第一个宏块的数据,而第三行的第二个数据是0,第三行的第三个数据其实是第三行第二个宏块的数据,如下图。


图2 直接按照mb_index来读取qp数据时候遇到的qp多0和错位现象

    之所以是这样,是因为ffmpeg解码或者x264编码时候,为每行多分配了一个宏块(见下图3),并且由于其没有真正的QP和mb_type,所以其对应QP和mb_type都是0。我们观察mb_xy这个值的时候,会发现,一行的末尾mb_xy=119(从0开始),但是第二行的开头却是(mb_xy=121),跳过的这个120就是第一行的多出来的宏块。

    所以,如果我们按照mb_xy来保存数据,但是,如果我们在应用程序不做区分,在读取数据时候还按照mb_index(=mb_y*coded_width/16+mb_x)来读取数据的话,就必定会出现上述错位的现象。

图3 mb_xy对应的qp分布图

    因此,获取qp和mb_type时正确的做法是,再h264_slice.c文件中,像读取mb_size和DCT系数那样,一个宏块一个宏块的按照其再图像坐标系下的排列方式来保存qp和mb_type,而不能完全依赖ffmpeg,一次性地读取和保存一帧对应地所有宏块地数据。

    同样地,如果再读取和保存mb_size和DCT系数时候,也采用了mb_xy的索引值,也会得到类似的错位结果,因此也需要修改为按照mb_index(=mb_y*coded_width/16+mb_x)来读取/保存,这样应用程序取用数据时候才不会出错。

6.3 I帧也会提取出运动矢量

    这个真的是相当坑的!!!!!!!!!!虽然ffmpeg官方给出的extract_mv.c中的那种提取运动矢量的方式确实是拯救了不少人,但也不得不说,ffmpeg另一方面也再砸自己招牌啊,搞出那么多bug!其实内部可能就是一个设置的原因,检测到I帧,就把side_data中的mv_data清零得了,干嘛非得给每个宏块返回一个为0的运动矢量啊!恶心人嘛不是。

    骂归骂,还得用不是,言归正传。好在,我们可以在应用层做这个事情,我们只要发现当前帧是I帧,即AVFrame指针对象->pict_type == AV_PICTURE_TYPE_I(不确定是不是这样写的了,读者可以自行检查),如果是I帧,那么不去读取运动矢量即可;否则,如果是P帧或者B帧,则可以读取运动矢量。注意:P帧也可以有多个运动矢量,只不过都是与当前P帧之前的帧计算出来的,只有B帧才有后向的运动矢量。








  • 10
    点赞
  • 34
    收藏
    觉得还不错? 一键收藏
  • 12
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 12
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值