23a2e42396a14d86fbb0eb6457ebd46d.png

写在前面

本文将介绍如何用FFMPEG API做硬件解码。如果对解码的流程不是很熟悉的同学,建议先阅读:

我是小北挖哈哈:视频和视频帧:FFMPEG CPU解码API介绍​zhuanlan.zhihu.com
45ffd139ae6ba90c690ad98a2aa8f12a.png

笔者之前看到过类似问题:视频硬解码和软解码有什么区别?

本质上没什么区别,都是用芯片执行编解码计算。

软硬的称呼容易引起歧义,实质上:用CPU通用计算单元(无论是Intel还是AMD)就是软解;用专用芯片模组(GPU、QSV等)就是硬解。

因此区别也就出来了:底层接口不同、指令集不同、硬件驱动不同。由此引申出来的问题也就显而易见了:

  1. 首先,因为CPU是通用计算单元,所以接口通用,移植性好;而专用芯片模组之间无法移植互用;
  2. 其次,因为CPU接口通用,因此编解码内部很多细节方便开发人员修改;而专用芯片模组,接口和驱动都是不同厂商提供的,很多是非开源,因此比较难控制内部细节。
  3. 最后,目前用CPU做编解码的效果,在实际测试下来会比专用芯片模组的效果好些。不过这个问题可以通过优化算法和芯片解决,这就是厂商的事儿了,我们控制不了。

至于实际生活生产中,到底选择硬解码还是软解码?

要视不同情况而定。比如:

  1. CPU富余、需要精准控制解码流程、有解码算法的优化、通用性要求高,直接使用软解(也就是CPU解码);
  2. 有其他编解码芯片/模组、CPU不够用,就不得不需要转向硬解码(也就是专用芯片解码)。

本文就将介绍如何用FFMPEG API做一般的硬解码,“一般的硬解码”是说在主流的FFMEG中支持的硬解码类型。笔者会结合自己在QSV和CUDA解码上的经验,最后分享一些踩过的坑、扩展的几个小问题。


I. FFMPEG支持的硬解码

首先来看下FFMPEG原生支持哪些硬解码类型,在AVHWDeviceType(libavutil/hwcontext.h)中列举出所有原生支持的硬解码类型:

enum AVHWDeviceType {
    AV_HWDEVICE_TYPE_NONE,
    AV_HWDEVICE_TYPE_VDPAU,
    AV_HWDEVICE_TYPE_CUDA,
    AV_HWDEVICE_TYPE_VAAPI,
    AV_HWDEVICE_TYPE_DXVA2,
    AV_HWDEVICE_TYPE_QSV,
    AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
    AV_HWDEVICE_TYPE_D3D11VA,
    AV_HWDEVICE_TYPE_DRM,
    AV_HWDEVICE_TYPE_OPENCL,
    AV_HWDEVICE_TYPE_MEDIACODEC,
};

上面的AV_HWDEVICE_TYPE_CUDA就是笔者目前正在做的CUDA是NVIDIA的硬件加速库,AV_HWDEVICE_TYPE_QSV则是以前做的QSV是Intel提供的一套集显上的硬件加速方案。

那么,究竟要怎么知道系统当前的FFMPEG究竟支持哪些硬件库

可以通过命令行查看:ffmpeg -hwaccel。在hardware acceleration methods:下面可以看到当前FFMPEG集成的硬解码库。

然后,如果发现自己需要的硬件库不在当前FFMPEG中怎么办?

答案是:很可能需要自己重新编译源码。每个硬件解码库的集成办法不同。如果是QSV,见

我是小北挖哈哈:视频和视频帧:FFMPEG+Intel QSV硬解的环境安装篇​zhuanlan.zhihu.com
45ffd139ae6ba90c690ad98a2aa8f12a.png

如果是其他的硬解码库,请自行上网搜索。(笔者后续会补充怎么在FFMPEG中集成CUDA库,敬请关注)


II. FFMPEG硬解码API

硬解步骤和软解步骤类似,笔者绘制了一幅FFMPEG硬件解码流程图:图中橙色部分是硬解码中有而软解码没有的部分。该图的灵感来自于博客《FFmpeg 示例硬件解码hw_decode》,推荐这个文章,简练而详实。

7411308fa4e53d285ef91fcf1a09a743.png
FFMPEG硬解码流程图

接下来,详细介绍上图橙色部分,和硬解码相关的API函数。

硬解码Step1. 寻找硬解codec

  • AVCodec* avcodec_find_decoder_by_name(const char *name)
    通过名字来寻找对应的AVCodec。每一个解码器的名字一定是全局唯一的,在AVCodec头文件中有相应的描述:
Name of the codec implementation. The name is globally unique among encoders and among decoders (but an encoder and a decoder can share the same name). This is the primary way to find a codec from the user perspective.


其实在FFMPEG内部每一个解码器codec都是一个结构体,维护了该解码器自己的信息、具体执行的函数等信息。比如Intel的QSV解码器(在libavcodec/qsvdec_h2645.c)是:

AVCodec ff_h264_qsv_decoder = {
    .name = "h264_qsv",
    .long_name = NULL_IF_CONFIG_SMALL("H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10 (Intel Quick Sync Video acceleration)"),
    .priv_data_size = sizeof(QSVH2645Context),
    .type = AVMEDIA_TYPE_VIDEO,
    .id = AV_CODEC_ID_H264,
    .init = qsv_decode_init,
    .decode = qsv_decode_frame,
    .flush = qsv_decode_flush,
    .close = qsv_decode_close,
    .capabilities = AV_CODEC_CAP_DELAY | AV_CODEC_CAP_DR1 | AV_CODEC_CAP_AVOID_PROBING | AV_CODEC_CAP_HYBRID,
    .priv_class = &class,
    .pix_fmts = (const enum AVPixelFormat[]){ AV_PIX_FMT_NV12,
 AV_PIX_FMT_P010,
 AV_PIX_FMT_QSV,
 AV_PIX_FMT_NONE },
    .hw_configs = ff_qsv_hw_configs,
    .bsfs = "h264_mp4toannexb",
    .wrapper_name = "qsv",
};

可以看到这个codec支持的codec idAV_CODEC_ID_H264,支持的目标像素格式有{ AV_PIX_FMT_NV12,AV_PIX_FMT_P010,AV_PIX_FMT_QSV,AV_PIX_FMT_NONE }
是的,硬件解码器不同于通用解码器,只能支持有限的目标像素格式。
再来看看CUDA解码器(在libavcodec/cuviddec.c),同样的,他也只能支持有限的目标像素格式:

 AVCodec ff_##x##_cuvid_decoder = { 
        .name = #x "_cuvid", 
        .long_name = NULL_IF_CONFIG_SMALL("Nvidia CUVID " #X " decoder"), 
        .type = AVMEDIA_TYPE_VIDEO, 
        .id = AV_CODEC_ID_##X, 
        .priv_data_size = sizeof(CuvidContext), 
        .priv_class = &x##_cuvid_class, 
        .init = cuvid_decode_init, 
        .close = cuvid_decode_end, 
        .decode = cuvid_decode_frame, 
        .receive_frame = cuvid_output_frame, 
        .flush = cuvid_flush, 
        .bsfs = bsf_name, 
        .capabilities = AV_CODEC_CAP_DELAY | AV_CODEC_CAP_AVOID_PROBING | AV_CODEC_CAP_HARDWARE, 
        .pix_fmts = (const enum AVPixelFormat[]){ AV_PIX_FMT_CUDA, 
 AV_PIX_FMT_NV12, 
 AV_PIX_FMT_P010, 
 AV_PIX_FMT_P016, 
 AV_PIX_FMT_NONE }, 
        .hw_configs = cuvid_hw_configs, 
        .wrapper_name = "cuvid", 
    };

硬解码Step2. 寻找硬解目标像素

在上一节知道了一个事实:硬解码codec支持的目标像素是有限的、且各自不一定相同。因此找到了硬解码codec之后,就得准备设置它的目标像素(pixel format)。

  • enum AVHWDeviceType av_hwdevice_find_type_by_name(const char *name)
    这个函数是通过名称去寻找对应的AVHWDeviceType,这是一个枚举类型的变量(定义在libavutil/hwcontext.h头文件中):
enum AVHWDeviceType {
 AV_HWDEVICE_TYPE_NONE,
 AV_HWDEVICE_TYPE_VDPAU,
 AV_HWDEVICE_TYPE_CUDA,
 AV_HWDEVICE_TYPE_VAAPI,
 AV_HWDEVICE_TYPE_DXVA2,
 AV_HWDEVICE_TYPE_QSV,
 AV_HWDEVICE_TYPE_VIDEOTOOLBOX,
 AV_HWDEVICE_TYPE_D3D11VA,
 AV_HWDEVICE_TYPE_DRM,
 AV_HWDEVICE_TYPE_OPENCL,
 AV_HWDEVICE_TYPE_MEDIACODEC,
 AV_HWDEVICE_TYPE_VULKAN,
};

这个类型和名称的关系表就简单多了,在FFMPEG代码中是用hw_type_names关系表来维护的(定义在libavutil/hwcontext.c文件中):

static const char *const hw_type_names[] = {
  [AV_HWDEVICE_TYPE_CUDA]   = "cuda",
  [AV_HWDEVICE_TYPE_DRM]    = "drm",
  [AV_HWDEVICE_TYPE_DXVA2]  = "dxva2",
  [AV_HWDEVICE_TYPE_D3D11VA] = "d3d11va",
  [AV_HWDEVICE_TYPE_OPENCL] = "opencl",
  [AV_HWDEVICE_TYPE_QSV]    = "qsv",
  [AV_HWDEVICE_TYPE_VAAPI]  = "vaapi",
  [AV_HWDEVICE_TYPE_VDPAU]  = "vdpau",
  [AV_HWDEVICE_TYPE_VIDEOTOOLBOX] = "videotoolbox",
  [AV_HWDEVICE_TYPE_MEDIACODEC] = "mediacodec",
  [AV_HWDEVICE_TYPE_VULKAN] = "vulkan",
};
  • const AVCodecHWConfig * avcodec_get_hw_config (const AVCodec *codec, int index)
    紧接着,调用这个函数去获取到该解码器codec的硬件属性,比如可以支持的目标像素格式等。而这个信息就存储在AVCodecHWConfig中:
 typedef struct AVCodecHWConfig {
 /**
 * A hardware pixel format which the codec can use. !!!硬解码codec支持的像素格式!!!
 */
 enum AVPixelFormat pix_fmt;
 /**
 * Bit set of AV_CODEC_HW_CONFIG_METHOD_* flags, describing the possible
 * setup methods which can be used with this configuration.
 */
 int methods;
 /**
 * The device type associated with the configuration.
 *
 * Must be set for AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX and
 * AV_CODEC_HW_CONFIG_METHOD_HW_FRAMES_CTX, otherwise unused.
 */
 enum AVHWDeviceType device_type;
} AVCodecHWConfig;
  • enum AVPixelFormat (*get_format)(struct AVCodecContext *s, const enum AVPixelFormat * fmt);
    这是一个回调函数,它的作用就是告诉解码器codec自己的目标像素格式是什么。在上一步骤获取到了硬解码器codec可以支持的目标格式之后,就通过这个回调函数告知给codec,具体做法:
enum AVPixelFormat get_hw_format(struct AVCodecContext *s, const enum AVPixelFormat *fmt) {
 for (const enum AVPixelFormat *p = fmt; *p != -1; p++) {
 if (*p == hw_pix_fmt) return *p;
    }
 return AV_PIX_FMT_NONE;
}

我们也可以通过阅读AVCodec结构内对于这个回调函数的定义,可以知道:

    1. fmt是这个解码器codec支持的像素格式,且按照质量优劣进行排序;
    2. 如果没有特别的需要,这个步骤是可以省略的。内部默认会使用“native”的格式。
* callback to negotiate the pixelFormat
 * @param fmt is the list of formats which are supported by the codec, it is terminated by -1 as 0 is a valid format, the formats are ordered by quality. The first is always the native one.
 * @note The callback may be called again immediately if initialization for the selected (hardware-accelerated) pixel format failed.
 * @warning Behavior is undefined if the callback returns a value not in the fmt list of formats.
 * @return the chosen format
 * - encoding: unused
 * - decoding: Set by user, if not set the native format will be chosen.

硬解码Step3. 准备和打开硬解码

  • int av_hwdevice_ctx_create(AVBufferRef **pdevice_ref, enum AVHWDeviceType type, const char *device, AVDictionary *opts, int flags)
    这个函数的作用是,创建硬件设备相关的上下文信息AVHWDeviceContext,包括分配内存资源、对硬件设备进行初始化。
    准备好硬件设备上下文AVHWDeviceContext后,需要把这个信息绑定到AVCodecContext,就可以像软解一样的流程执行解码操作了。

硬解码Step4. 取回数据

按照一般软解的流程,在调用avcodec_receive_frame()之后,得到的数据其实还在硬件模组/芯片上,也就是说,如果是用CUDA解码,数据是在显存上(或者说是在显卡encoder/decoder的buffer上)的。对于很多应用而言,解码之后往往还要进行后续操作,比如保存成一幅幅图片之类的,那么就需要把数据取回。

  • int av_hwframe_transfer_data(AVFrame *dst, const AVFrame *src, int flags)
    这个函数是负责在cpu内存和硬件内存(原文是hw surface)之间做数据交换的。也就是说,它不但可以把数据从硬件surface上搬回系统内存,反向操作也支持;甚至可以直接在硬件内存之间做数据交互。

III. FFMPEG硬解码的问题

问题1:如果要转成硬解码不直接支持的像素格式怎么做?

回答:硬解码不直接支持,那么希望在解码后直接得到是不可能的。实际的做法主要提供2种最常见的:

  1. 目标格式用硬解格式,接着通过av_hwframe_transfer_data()转到内存,最后通过sws_scale()完成。
  2. 目标格式用NV12(大部分硬解码都支持),接着把AVFrame的数据通过av_image_copy_to_buffer()拷贝出来,最后用opencv工具做转换。

以上提供的两种办法,在最后一步都是用CPU完成的,如果有更好的办法,欢迎留言或者私信交流。

笔者在这里踩过一些坑:

坑1:直接通过回调函数让解码器codec目标像素格式设置为自己需要的格式,比如BGR24。结果马上就报错了,解码根本执行不起来。

坑2:不做av_hwframe_transfer_data()直接sws_scale(),然后就coredump了。

问题2:数据在硬件设备和系统内存直接搬运,有没有更好的办法?

回答:看情况而定。一般来说经常建议采用av_hwframe_transfer_data(),这个函数内部会去调用硬解码内部的数据拷贝做搬运,但是实际效果往往视具体实现而异。比如笔者之前在做qsv的时候发现这个函数极其占用CPU资源(之后和Intel一起修复了这个问题);而在使用CUDA的时候,性能是可以接受的。

在上一问题的第2种解决方案中,如果你需要的格式正好是硬解码codec支持的,那么直接设置为这个目标格式,最后用av_image_copy_to_buffer()拷贝出来,可以省去av_hwframe_transfer_data()

问题3:怎么知道硬件设备支持哪些目标像素格式?

回答:av_hwdevice_get_hwframe_constraints()

这个函数可以获取到这个硬件设备的限制,如最大、最小长宽和目标像素格式。


写在后面

笔者在接触了2个平台的硬解码之后,发现他们各自的特性都不相同。而且,除了往更多的平台做扩展、探究和实践之外,硬解码工作实际上也有很多深度可挖掘的课题:

  1. 除了使用FFMPEG原生的硬解码器codec,其实也可以用户自己使用FFMPEG+厂商提供的编解码API共同完成;
  2. 如果是FFMPEG没有支持的硬解码方案,也可以自己在FFMPEG源码上增加hw相关的文件做集成。
  3. ... ...

本文如有任何纰漏或者不正确的地方,欢迎补充和纠正。也欢迎一起交流技术问题。:)

Logo

魔乐社区(Modelers.cn) 是一个中立、公益的人工智能社区,提供人工智能工具、模型、数据的托管、展示与应用协同服务,为人工智能开发及爱好者搭建开放的学习交流平台。社区通过理事会方式运作,由全产业链共同建设、共同运营、共同享有,推动国产AI生态繁荣发展。

更多推荐