视频采集 via FFmpeg

FFmpeg 简介

FFmpeg 是一套可以用来记录、转换数字音频、视频,并能将其转化为流的开源计算机程序。采用 LGPL 或 GPL 许可证。它提供了录制、转换以及流化音视频的完整解决方案。它包含了非常先进的音频 / 视频编解码库 libavcodec,为了保证高可移植性和编解码质量,libavcodec 里很多 code 都是从头开发的。

FFmpeg 在 Linux 平台下开发,但它同样也可以在其它操作系统环境中编译运行,包括 Windows、Mac OS X 等。这个项目最早由 Fabrice Bellard 发起,2004 年至 2015 年间由 Michael Niedermayer 主要负责维护。许多 FFmpeg 的开发人员都来自 MPlayer 项目,而且当前 FFmpeg 也是放在 MPlayer 项目组的服务器上。项目的名称来自 MPEG 视频编码标准,前面的 “FF” 代表 “Fast Forward”。

FFmpeg 命令行采集视频

FFmpeg 提供了现成的程序用命令行的方式对视频进行采集。

  • 首先需要枚举电脑上的视频采集设备:
>ffmpeg.exe -list_devices true -f dshow -i dummy
… …
[dshow @ 007bd020] DirectShow video devices (some may be both video and audio devices)
[dshow @ 007bd020]  “USB Web Camera - HD"
[dshow @ 007bd020]     Alternative name "@device_pnp_\\?\usb#vid_1bcf&pid_288e&mi_00#7&6c75a67&0&0000#{65e8773d-8f56-11d0-a3b9-00a0c9223196}\global"
[dshow @ 003cd000] DirectShow audio devices
[dshow @ 003cd000]  "Microphone (Realtek High Defini"
[dshow @ 003cd000]     Alternative name "@device_cm_{33D9A762-90C8-11D0-BD43-00A0C911CE86}\Microphone (Realtek High Defini“

在我的电脑上有两个采集设备,一个是用来采集视频的摄像头,一个是用来采集音频的麦克风 “Microphone (Realtek High Defini ”。

  • 然后选择摄像头设备进行采集
>ffmpeg.exe -f dshow -i video="USB Web Camera - HD" d:\test.h264
Input #0, dshow, from 'video=USB Web Camera - HD': ← 输入设备
  Duration: N/A, start: 31491.827000, bitrate: N/A
    Stream #0:0: Video: rawvideo (YUY2 / 0x32595559), yuyv422, 640x480, 30 fps, 30 tbr, 10000k tbn, 10000k tbc
Stream mapping:
  Stream #0:0 -> #0:0 (rawvideo (native) -> h264 (libx264))
No pixel format specified, yuv422p for H.264 encoding chosen.
[libx264 @ 02658fe0] 264 - core 152 r2851 ba24899 - H.264/MPEG-4 AVC codec ← 使用的视频编码器
Output #0, h264, to 'd:\test.h264': ← 输出文件
  Metadata:
    encoder         : Lavf57.71.100
    Stream #0:0: Video: h264 (libx264) ([33][0][0][0] / 0x0021), yuv422p, 640x480, q=-1--1, 30 fps, 10000k tbn, 30 tbc
    Metadata:
      encoder         : Lavc57.89.100 libx264
    Side data:
      cpb: bitrate max/min/avg: 0/0/0 buffer size: 0 vbv_delay: -1
frame=  330 fps= 28 q=-1.0 Lsize=    2635kB time=00:00:10.89 bitrate=1980.2kbits/s dup=259 drop=0 speed=0.93x
video:2630kB audio:0kB subtitle:0kB other streams:0kB global headers:0kB muxing overhead: 0.179305% ← 正在录制的视频信息,会实时变化

FFmpeg 在 Windows 上用的是 DirectShow 输入设备进行采集的,然后经由自己的 h.264 encoder 和 file writer 写到磁盘上。

FFmpeg API 采集视频

FFmpeg 还提供了完备的 API 对视频进行采集。下面是使用 FFmpeg 采集并编码视频的流程图。
视频采集 via FFmpeg

颜色空间转换

  • 什么是颜色空间(color space)?
    颜色空间(又称:彩色模型、色彩空间、 彩色系统)是对色彩的一种描述方式,定义有很多种,区别在于面向不同的应用背景。例如:

    • 显示器中采用的 RGB 颜色空间是基于物体发光定义的;
    • 工业印刷中常用的 CMY 颜色空间是基于光反射定义的(CMY 对应了绘画中的三原色:Cyan,Magenta,Yellow)
    • HSV / HSL 颜色空间都是从人视觉的直观反映而提出来的(Hue 色调,Saturation 饱和度,Value / Lightness 亮度)
    • YUV / YCbCr 颜色空间通过亮度-色差来描述颜色,通常用在电视系统、数位摄影等地方,Y 指明亮度(Luminance、Luma),U 和 V 则是色度、浓度(Chrominance、Chroma)

    想要了解更多可以参考我的另一篇博文:RGB 和 YUV 格式

  • 为什么要做颜色空间转换?
    编码器支持的颜色空间(确切的说是 pixel format,如 yuv420p)是有限的,和采集的图像可能不一致,因此在编码前就需要转换源图像颜色空间到目标图像颜色空间。
    FFmpeg 提供了转换的API,主要流程如下:
    视频采集 via FFmpeg

FFmpeg 采集视频代码

以下是整个 FFmpeg 采集过程的概要代码,略去各个函数的具体实现和资源释放。
本文中的代码基于 FFmpeg 4.1。

av_dict_set(&cam_options, "video_size", "640x480", 0);
char fps[10] = {0};
_itoa_s(FRAME_RATE, fps, 10);
av_dict_set(&cam_options, "r", fps, 0);    
hr = open_cap_device(AVMEDIA_TYPE_VIDEO, &cap_fmt_ctx, &cap_codec_ctx, &cam_options);

hr = open_output_video_file(out_file, cap_codec_ctx, 400 * 1000, FRAME_RATE, &out_fmt_ctx, &enc_ctx);

hr = init_cvt_frame_and_sws(AV_PIX_FMT_YUV420P, cap_codec_ctx, &yuv420p_frame, &yuv420p_buffer, &sws_ctx);

hr = avformat_write_header(out_fmt_ctx, NULL);

while (_kbhit() == 0) {
    int finished = 0;
    hr = video_transcode(
        cap_fmt_ctx, cap_codec_ctx, 
        out_fmt_ctx, enc_ctx,
        0, yuv420p_frame, sws_ctx, &finished, false, true );
    if (FAILED(hr) || finished)
        break;
}

flush_encoder(out_fmt_ctx, enc_ctx, false, true);

hr = av_write_trailer(out_fmt_ctx);

... ... // free resources

open_cap_device 函数

在 Windows 上 FFmpeg 使用 DirectShow 的设备进行采集,这里我们先枚举所有的设备,然后选用第一个成功初始化的设备。

int open_cap_device(
    AVMediaType cap_type,
    AVFormatContext **cap_fmt_ctx, 
    AVCodecContext **cap_codec_ctx,
    AVDictionary** options = NULL)
{
    RETURN_IF_NULL(cap_fmt_ctx);
    RETURN_IF_NULL(cap_codec_ctx);
    *cap_fmt_ctx = NULL;
    *cap_codec_ctx = NULL;
    int hr = -1;
    std::string cap_device_name;
    std::vector<std::wstring> cap_devices;

    avdevice_register_all();
    CoInitialize(NULL);

    AVInputFormat* input_fmt = av_find_input_format("dshow");
    GOTO_IF_NULL(input_fmt);

    switch (cap_type) {
    case AVMEDIA_TYPE_VIDEO:
        cap_device_name = "video=";
        hr = enum_dshow_vcap_devices(cap_devices);
        break;
    }
    GOTO_IF_FAILED(hr);

    cap_device_name += unicodeToUtf8(cap_devices[0].c_str());
    hr = avformat_open_input(cap_fmt_ctx, cap_device_name.c_str(), input_fmt, options);
    GOTO_IF_FAILED(hr);

    hr = avformat_find_stream_info(*cap_fmt_ctx, NULL);
    GOTO_IF_FAILED(hr);

    for (unsigned int i = 0; i < (*cap_fmt_ctx)->nb_streams; i++) {
        AVCodecParameters* codec_par = (*cap_fmt_ctx)->streams[i]->codecpar;
        if (codec_par->codec_type == cap_type) {
            av_dump_format(*cap_fmt_ctx, i, NULL, 0);

            AVCodec* decoder = avcodec_find_decoder(codec_par->codec_id);
            GOTO_IF_NULL(decoder);

            *cap_codec_ctx = avcodec_alloc_context3(decoder);
            GOTO_IF_NULL(*cap_codec_ctx);

            /** initialize the stream parameters with demuxer information */
            hr = avcodec_parameters_to_context(*cap_codec_ctx, codec_par);
            GOTO_LABEL_IF_FAILED(hr, OnErr);

            hr = avcodec_open2(*cap_codec_ctx, decoder, NULL);
            GOTO_IF_FAILED(hr);

            break;
        }
    }
    GOTO_IF_NULL(*cap_codec_ctx);

    hr = 0;
RESOURCE_FREE:
    CoUninitialize();
    return hr;

OnErr:
    avformat_free_context(*cap_fmt_ctx);
    *cap_fmt_ctx = NULL;

    if (NULL != *cap_codec_ctx)
        avcodec_free_context(cap_codec_ctx);

    goto RESOURCE_FREE;
}

enum_dshow_vcap_devices 函数

下面是使用 DShow 的 API 对视频采集设备进行枚举。

HRESULT enum_dshow_vcap_devices(std::vector<std::wstring>& devices)
{
    return enum_dshow_devices(CLSID_VideoInputDeviceCategory, devices);
}

HRESULT enum_dshow_devices(const IID& deviceCategory, std::vector<std::wstring>& devices)
{
    HRESULT hr = E_FAIL;
    CComPtr <ICreateDevEnum> pDevEnum =NULL;
    CComPtr <IEnumMoniker> pClassEnum = NULL;
    CComPtr<IMoniker> pMoniker =NULL;
    ULONG cFetched = 0;

    hr = CoCreateInstance(CLSID_SystemDeviceEnum, NULL, CLSCTX_INPROC, IID_ICreateDevEnum, (void**)&pDevEnum);
    RETURN_IF_FAILED(hr);

    hr = pDevEnum->CreateClassEnumerator(deviceCategory, &pClassEnum, 0);
    RETURN_IF_FAILED(hr);
    // If there are no enumerators for the requested type, then
    // CreateClassEnumerator will succeed, but pClassEnum will be NULL.
    RETURN_IF_NULL(pClassEnum);

    while (S_OK == (pClassEnum->Next(1, &pMoniker, &cFetched))) {
        CComPtr<IPropertyBag> pPropertyBag = NULL;
        hr = pMoniker->BindToStorage(NULL, NULL, IID_IPropertyBag, (void**)&pPropertyBag);
        pMoniker = NULL;
        if (FAILED(hr))
            continue;

        CComVariant friendlyName;
        friendlyName.vt = VT_BSTR;
        hr = pPropertyBag->Read(L"FriendlyName", &friendlyName, NULL) ;
        if (SUCCEEDED(hr)) {
            std::wstring strFriendlyName(friendlyName.bstrVal);
            devices.push_back(strFriendlyName);
        }
    }
    RETURN_IF_TRUE(devices.empty(), E_FAIL);

    return S_OK;
}

open_output_video_file 函数

打开一个输出文件并初始化视频编码器。

int open_output_video_file(
    const char *file_name,
    AVCodecContext *in_codec_ctx,
    int64_t bit_rate,
    int frame_rate,
    AVFormatContext **out_fmt_ctx,
    AVCodecContext **enc_ctx)
{
    int hr = -1;

    AVCodecContext *codec_ctx = NULL;
    hr = open_output_file(file_name, in_codec_ctx->codec_type, out_fmt_ctx, &codec_ctx);
    RETURN_IF_FAILED(hr);
    
    hr = init_video_encoder(in_codec_ctx, bit_rate, frame_rate, *out_fmt_ctx, 0, codec_ctx);
    GOTO_LABEL_IF_FAILED(hr, OnErr);

    /** Save the encoder context for easier access later. */
    *enc_ctx = codec_ctx;
    return 0;
    
OnErr:
    avcodec_free_context(&codec_ctx);
    avio_closep(&(*out_fmt_ctx)->pb);
    avformat_free_context(*out_fmt_ctx);
    *out_fmt_ctx = NULL;
    *enc_ctx = NULL;
    return hr;
}

open_output_file 函数

通过文件后缀名 guess 一个最适合的编码器。

int open_output_file(
    const char *file_name,
    AVMediaType stream_type,
    AVFormatContext **out_fmt_ctx,
    AVCodecContext **enc_ctx )
{
    RETURN_IF_NULL(file_name);
    RETURN_IF_NULL(out_fmt_ctx);
    RETURN_IF_NULL(enc_ctx);
    int hr = -1;

    AVIOContext *output_io_ctx = NULL;
    /** Open the output file to write to it. */
    hr = avio_open(&output_io_ctx, file_name, AVIO_FLAG_WRITE);
    RETURN_IF_FAILED(hr);

    /** Create a new format context for the output container format. */
    *out_fmt_ctx = avformat_alloc_context();
    RETURN_IF_NULL(*out_fmt_ctx);

    /** Associate the output file (pointer) with the container format context. */
    (*out_fmt_ctx)->pb = output_io_ctx;

    /** Guess the desired container format based on the file extension. */
    (*out_fmt_ctx)->oformat = av_guess_format(NULL, file_name, NULL);
    GOTO_LABEL_IF_NULL((*out_fmt_ctx)->oformat, OnErr);

    char*& url = (*out_fmt_ctx)->url;
    if (NULL == url)
        url = av_strdup(file_name); 

    /** Find the encoder to be used by its name. */
    AVCodecID out_codec_id = AV_CODEC_ID_NONE;
    switch (stream_type) {
    case AVMEDIA_TYPE_VIDEO:
        out_codec_id = (*out_fmt_ctx)->oformat->video_codec;
        break;
    }
    
    int stream_idx = add_stream_and_alloc_enc(out_codec_id, *out_fmt_ctx, enc_ctx);
    GOTO_LABEL_IF_FALSE(stream_idx >= 0, OnErr);

    return 0;
OnErr:
    avio_closep(&(*out_fmt_ctx)->pb);
    avformat_free_context(*out_fmt_ctx);
    *out_fmt_ctx = NULL;
    *enc_ctx = NULL;
    return hr;
}

init_video_encoder 函数

初始化视频的一些基本参数如:比特率、宽高、帧率、时间戳基准等。

    enc_ctx->bit_rate = video_info.bit_rate;
    enc_ctx->width = video_info.width;
    enc_ctx->height = video_info.height;
    enc_ctx->sample_aspect_ratio = video_info.sample_aspect_ratio;
    enc_ctx->framerate = video_info.frame_rate;
    enc_ctx->time_base = av_inv_q(enc_ctx->framerate);
    enc_ctx->gop_size = 18;
    enc_ctx->max_b_frames = 3;
    enc_ctx->qmin = 10;
    enc_ctx->qmax = 51;
    enc_ctx->pix_fmt = pix_fmt;
    
    switch (enc_ctx->codec->id) {
    case AV_CODEC_ID_H264:
        av_opt_set(enc_ctx->priv_data, "preset", "slow", 0);
        break;
    case AV_CODEC_ID_MPEG1VIDEO:
        /* Needed to avoid using macroblocks in which some coeffs overflow. */
        enc_ctx->mb_decision = 2;
        break;
    }   
    
    if (out_fmt_ctx->oformat->flags & AVFMT_GLOBALHEADER)
        enc_ctx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;

    hr = avcodec_open2(enc_ctx, enc_ctx->codec, NULL);
    
    AVStream* stream = out_fmt_ctx->streams[video_stream_idx];
    hr = avcodec_parameters_from_context(stream->codecpar, enc_ctx);

init_cvt_frame_and_sws 函数

初始化 sw scaler 及 color space converter,以及对应的目标 frame。

int init_cvt_frame_and_sws(
    AVPixelFormat dst_fmt,
    AVCodecContext *src_codec_ctx,
    AVFrame** frame, 
    uint8_t** buffer,
    SwsContext** sws_ctx)
{
    int width = src_codec_ctx->width;
    int height = src_codec_ctx->height;

    *frame = av_frame_alloc();
    (*frame)->width = width;
    (*frame)->height = height;
    (*frame)->format = format;

    int num_bytes = av_image_get_buffer_size( format, width, height, 1 );
    *buffer = (uint8_t *)av_malloc( num_bytes * sizeof(uint8_t) );

    int hr = av_image_fill_arrays((*frame)->data, (*frame)->linesize, *buffer, format, width, height, 1);
    RETURN_IF_FAILED(hr);

    // Fix me: It shouldn't hard code NV12 as HW decoded format, can be refactored with later init
    AVPixelFormat src_pix_fmt = (src_codec_ctx->hw_device_ctx != NULL) ? AV_PIX_FMT_NV12 : src_codec_ctx->pix_fmt;
    *sws_ctx = sws_getContext(width, height, src_pix_fmt, width, height, dst_fmt, SWS_BICUBIC, NULL, NULL, NULL);
    RETURN_IF_NULL(*sws_ctx);

    return 0;
}

video_transcode 函数

解码(Raw 的格式其实不需要) → 色彩空间转换 → 编码 的过程。

    const int output_frame_size = enc_ctx->frame_size;
    std::vector<AVFrame*> decoded_frames;
    while (true) {
        hr = decode_av_frame(in_fmt_ctx, dec_ctx, video_index, decoded_frames, finished);   
             
        for (size_t dec_frame_idx = 0; dec_frame_idx < decoded_frames.size(); ++dec_frame_idx) {
            AVFrame* frame = decoded_frames[dec_frame_idx];
            // convert to YUV420P format
            int height = sws_scale(sws_ctx, (const uint8_t* const*)frame->data, frame->linesize, 0, 
                dec_ctx->height, yuv420p_frame->data, yuv420p_frame->linesize);    
                        
            av_frame_copy_props(yuv420p_frame, frame);
            yuv420p_frame->pict_type = AV_PICTURE_TYPE_NONE;
            
            int data_written = 0;
            hr = encode_av_frame(yuv420p_frame, out_fmt_ctx, enc_ctx, &data_written, interleaved, init_pts);
            if (hr == AVERROR(EAGAIN))
                continue;
            GOTO_IF_FAILED(hr);
        }
        
        if (SUCCEEDED(hr))
            break;
    }
    
    if (*finished)
        flush_encoder(out_fmt_ctx, enc_ctx, interleaved, init_pts);

decode_av_frame 函数

注意:此处已经抛弃了 legacy 的 avcodec_decode_video2,而是使用 avcodec_send_packet 和 avcodec_receive_frame,具体请参考 官方文档

init_packet(&input_packet);
while (true) {
    hr = av_read_frame(in_fmt_ctx, &input_packet);
    if (hr == AVERROR_EOF)
        *finished = 1;
    else
        av_packet_rescale_ts(&input_packet, in_fmt_ctx->streams[input_packet.stream_index]->time_base, in_codec_ctx->time_base);
  
    hr = avcodec_send_packet(in_codec_ctx, *finished ? NULL : &input_packet);
    if (SUCCEEDED(hr) || (hr == AVERROR(EAGAIN))) {
        while (true) {
            frame = av_frame_alloc();
            hr = avcodec_receive_frame(in_codec_ctx, frame);
            if (SUCCEEDED(hr))
                frames.push_back(frame);
            else if (hr == AVERROR_EOF) {
                *finished = 1;
                break;
            }
            else if (hr == AVERROR(EAGAIN)) // need more packets
                break;
        }
    }
    else if (hr == AVERROR_EOF)
        *finished = 1;
    if (*finished || !frames.empty())
        break;
}

encode_av_frame 函数

亲爱的编码函数。

int encode_av_frame(
    AVFrame *frame,
    AVFormatContext *out_fmt_ctx,
    AVCodecContext *enc_ctx,
    int* data_written,
    bool interleaved,
    bool init_pts)
{
    // frame can be NULL which means to flush
    RETURN_IF_NULL(out_fmt_ctx);
    RETURN_IF_NULL(enc_ctx);
    RETURN_IF_NULL(data_written);
    *data_written = 0;
    int hr = -1;
    
    int stream_idx = 0;
    for (unsigned int i = 0; i < out_fmt_ctx->nb_streams; ++i) {
        if (out_fmt_ctx->streams[i]->codecpar->codec_type == enc_ctx->codec_type) {
            stream_idx = i;
            break;
        }
    }

    std::vector<AVPacket*> packets;
    AVPacket* output_packet = NULL;

    if (init_pts)
        frame->pts = g_ttl_v_frames++;

    hr = avcodec_send_frame(enc_ctx, frame);
    if (SUCCEEDED(hr) || (hr == AVERROR(EAGAIN))) {
        while (true) {
            /** Packet used for temporary storage. */
            output_packet = new AVPacket();
            init_packet(output_packet);

            hr = avcodec_receive_packet(enc_ctx, output_packet);
            if (SUCCEEDED(hr)) {
                output_packet->stream_index = stream_idx;
                packets.push_back(output_packet);
            }
            else if (hr == AVERROR(EAGAIN)) // need more input frames
                break;
            else if (hr == AVERROR_EOF)
                break;
            else
                GOTO_IF_FAILED(hr);
        }
    }
    else if (hr != AVERROR_EOF)
        GOTO_IF_FAILED(hr);

    for (size_t i = 0; i < packets.size(); ++i) {
        // set pts based on stream time base.
        AVRational stream_tb = get_stream_time_base(out_fmt_ctx, enc_ctx->codec_type);
        AVPacket* packet = packets[i];
        switch (enc_ctx->codec_type) {
        case AVMEDIA_TYPE_VIDEO:
            if (init_pts) {
                packet->duration = av_rescale_q(1, enc_ctx->time_base, stream_tb);
                packet->pts *= packet->duration;
                packet->dts *= packet->duration;
            }
            else
                av_packet_rescale_ts(packet, enc_ctx->time_base, stream_tb);
            break;
        }

        /** Write one frame from the temporary packet to the output file. */
        if (interleaved)
            hr = av_interleaved_write_frame(out_fmt_ctx, packet);
        else
            hr = av_write_frame(out_fmt_ctx, packet);
        GOTO_IF_FAILED(hr);

        *data_written = 1;
    }

    hr = 0;
RESOURCE_FREE:
    // free resources
    return hr;
}

flush_encoder 函数

终于结束了,最后是擦屁股。

int flush_encoder(
    AVFormatContext* format_ctx,
    AVCodecContext* codec_ctx,
    bool interleaved,
    bool init_pts)
{
    if (!(codec_ctx->codec->capabilities & AV_CODEC_CAP_DELAY))
        return 0;

    int data_written = 0;
    /** Flush the encoder as it may have delayed frames. */
    do {
        int hr = encode_av_frame(NULL, format_ctx, codec_ctx, &data_written, interleaved, init_pts);
        RETURN_IF_FAILED(hr);
    } while (data_written);

    return 0;
}

其他框架下的采集

请参考对应的文章。

视频采集 via FFmpeg
EOF