使用FFmpeg实现H.264解码

前面通过 H.264 编码将 YUV 像素数据压缩生成了一个 h264 文件。那么想要播放 h264 文件,就需要解压缩取出每一帧的具体像素数据进行播放。本文的内容主要是解码裸流,即从本地读取 h264 文件,解码成 YUV 像素数据的过程。

一、使用 FFmpeg 命令行进行 H.264 解码:
$ ffmpeg -c:v h264 -i in.h264 out.yuv

解码时 -c:v h264 是输入参数。查看本地的解码器:

$ ffmpeg -decoders | grep 264
 VFS..D h264                 H.264 / AVC / MPEG-4 AVC / MPEG-4 part 10
二、使用 FFmpeg 编程实现 H.264 编码

首先需要导入用到的 FFmpeg 库 libavcodeclibavutil。和前面 H.264 编码用到的库是一样的,并且 H.264 解码流程和 AAC 解码流程也是类似的。

H.264 解码流程

1、获取解码器

在我本地默认的解码器就是 h264,通过 ID 或者名称获取到的 H.264 解码器都是 h264

// 使用 ID 获取编码器:
codec = avcodec_find_decoder(AV_CODEC_ID_H264);
// 或者使用名称获取编码器:
codec = avcodec_find_decoder_by_name("h264");

2、初始化解析器上下文

通过 ID 创建 H.264 解析器上下文:

parserCtx = av_parser_init(codec->id);

查看函数 av_parser_init 源码:

// 源码位置:ffmpeg-4.3.2/libavcodec/parser.c
AVCodecParserContext *av_parser_init(int codec_id)
{
    AVCodecParserContext *s = NULL;
    const AVCodecParser *parser;
    void *i = 0;
    int ret;

    if (codec_id == AV_CODEC_ID_NONE)
        return NULL;

    while ((parser = av_parser_iterate(&i))) {
        if (parser->codec_ids[0] == codec_id ||
            parser->codec_ids[1] == codec_id ||
            parser->codec_ids[2] == codec_id ||
            parser->codec_ids[3] == codec_id ||
            parser->codec_ids[4] == codec_id)
            goto found;
    }
    return NULL;

found:
    s = av_mallocz(sizeof(AVCodecParserContext));
    if (!s)
        goto err_out;
    s->parser = (AVCodecParser*)parser;
    s->priv_data = av_mallocz(parser->priv_data_size);
    if (!s->priv_data)
        goto err_out;
    s->fetch_timestamp=1;
    s->pict_type = AV_PICTURE_TYPE_I;
    if (parser->parser_init) {
        ret = parser->parser_init(s);
        if (ret != 0)
            goto err_out;
    }
    s->key_frame            = -1;
#if FF_API_CONVERGENCE_DURATION
FF_DISABLE_DEPRECATION_WARNINGS
    s->convergence_duration = 0;
FF_ENABLE_DEPRECATION_WARNINGS
#endif
    s->dts_sync_point       = INT_MIN;
    s->dts_ref_dts_delta    = INT_MIN;
    s->pts_dts_delta        = INT_MIN;
    s->format               = -1;

    return s;

err_out:
    if (s)
        av_freep(&s->priv_data);
    av_free(s);
    return NULL;
}
// 源码片段 ffmpeg-4.3.2/libavcodec/parsers.c
const AVCodecParser *av_parser_iterate(void **opaque)
{
    uintptr_t i = (uintptr_t)*opaque;
    const AVCodecParser *p = parser_list[i];

    if (p)
        *opaque = (void*)(i + 1);

    return p;
}
// 源码片段 ffmpeg-4.3.2/libavcodec/parsers.c
AVCodecParser ff_h264_parser = {
    .codec_ids      = { AV_CODEC_ID_H264 },
    .priv_data_size = sizeof(H264ParseContext),
    .parser_init    = init,
    .parser_parse   = h264_parse,
    .parser_close   = h264_close,
    .split          = h264_split,
};

源码中的第一步就是通过 ID 查找 parser,此处传入的 codec->id 就是 AV_CODEC_ID_H264。函数 av_parser_iterate 是 parser 迭代器,其内部是在 parser_list 数组中查找 parser(parser_list 在源码文件 ffmpeg-4.3.2/libavcodec/parser_list.c 中)。最终找到的 H.264 解析器是 ff_h264_parser

3、创建解析器上下文

ctx = avcodec_alloc_context3(codec);

4、创建AVPacket

pkt = av_packet_alloc();

5、创建AVFrame

frame = av_frame_alloc();

6、打开解码器

ret = avcodec_open2(ctx, codec, nullptr);

7、打开文件

inFile.open(QFile::ReadOnly)
outFile.open(QFile::WriteOnly)
inLen = inFile.read(inDataArray, IN_DATA_SIZE);

8、读取文件数据 & 解析数据

while ((inLen = inFile.read(inDataArray, IN_INBUF_SIZE)) > 0) {
    inData = inDataArray;
    while (inLen > 0) {
        // 解析器解析数据
        ret = av_parser_parse2(parserCtx, ctx, &pkt->data, &pkt->size, (uint8_t *) inData, inLen, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
        if (ret < 0) {
            ERRBUF(ret);
            qDebug() << "av_parser_parse2 error" << errbuf;
            goto end;
        }

        // 跳过已经解析过的数据
        inData += ret;
        // 减去已经解析过的数据大小
        inLen -= ret;

        qDebug() << "pkt->size:" << pkt->size << "ret:" << ret;

        // 解码
        if (pkt->size > 0 && decode(ctx, pkt, frame, outFile) < 0) {
            goto end;
        }
    }
}

通过和在终端使用命令行解码生成的 YUV 文件大小进行比较,发现通过代码解码生成的 YUV 像素数据有丢失:

$ ls -al
-rw-r--r--   1 mac  staff       110131200 Apr 12 14:22 out_640x480_yuv420p_code.yuv
-rw-r--r--   1 mac  staff       110592000 Apr 12 14:19 out_640x480_yuv420p_terminal.yuv

通过打印可以发现解码结束后 parser 中还剩余 703 字节的数据没有送入 AVPacket 中,需要让 paeser把剩余数据“吐出来”:

pkt->size: 473 ret: 473
解码完成第 237 帧
pkt->size: 0 ret: 703
解码完成第 238 帧
解码完成第 239 帧

解决办法就是当 h264 文件中数据全部读完后再调用一次 av_parser_parse2 函数,将代码改造如下:

// 是否读到文件尾部
int inEnd = 0;

do {
    // 从文件中读取h264数据
    inLen = inFile.read(inDataArray, IN_INBUF_SIZE);
    inData = inDataArray;
    inEnd = !inLen;
    while (inLen > 0 || inEnd) { // 到了文件尾部虽然没有读取到任何数据,也要调用,最后要刷出解析器上下文中的数据 
        ret = av_parser_parse2(parserCtx, ctx, &pkt->data, &pkt->size, (const uint8_t *)inData, inLen, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
        if (ret < 0) {
            char errbuf[1024]; 
            av_strerror(ret, errbuf, sizeof (errbuf));
            qDebug() << "av_parser_parse2 error:" << errbuf;
            goto end;
        }

        // 跳过解析过的数据
        inData += ret;
        // 减去已解析过的数据大小
        inLen -= ret;

        qDebug() << "inEnd:" << inEnd << "pkt->size:" << pkt->size << "ret:" << ret;

        // 解码
        if (pkt->size > 0 && decode(ctx, pkt, frame, outFile) < 0) {
            goto end;
        }
        // 当inEnd = 1时到了文件尾部
        if (inEnd) break;
    }
} while (!inEnd);

查看打印发现 parser 中剩余数据已全部刷出,并且这次和在终端生成的 yuv 文件大小完全一样:

inEnd: 0 pkt->size: 473 ret: 473
解码完成第 237 帧
inEnd: 0 pkt->size: 0 ret: 703
inEnd: 1 pkt->size: 703 ret: 0
解码完成第 238 帧
解码完成第 239 帧
解码完成第 240 帧

9、解码

static int decode(AVCodecContext *ctx,
                  AVPacket *pkt,
                  AVFrame *frame,
                  QFile &outFile) {
    // 发送压缩数据到解码器
    int ret = avcodec_send_packet(ctx, pkt);
    if (ret < 0) {
        char errbuf[1024]; 
        av_strerror(ret, errbuf, sizeof (errbuf));
        qDebug() << "avcodec_send_packet error" << errbuf;
        return ret;
    }

    while (true) {
        // 获取解码后的数据
        ret = avcodec_receive_frame(ctx, frame);
        if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
            return 0;
        } else if (ret < 0) {
            char errbuf[1024]; 
            av_strerror(ret, errbuf, sizeof (errbuf));
            qDebug() << "avcodec_receive_frame error" << errbuf;
            return ret;
        }

        // 将解码后的数据写入文件
        int imgSize = av_image_get_buffer_size(ctx->pix_fmt, ctx->width, ctx->height, 1);
        outFile.write((char *) frame->data[0], imgSize);
    }
}

使用以上方式直接从 frame->data[0] 中读取一帧大小写入文件,你可能会发现播放解码后的 YUV 像素数据会有如下问题:

问题截图

是因为 frame->data[0]frame->data[1] 以及 frame->data[1]frame->data[2] 之间是有 padding 的:

// 打印 frame->data:
qDebug() << frame->data[0] << frame->data[1] << frame->data[2];
// 输出:
0x7fd554693000 0x7fd5546df000 0x7fd5546f2000

// 计算数据缓冲区各平面实际大小:
frame->data[1] - frame->data[0] = 0x7fd5546df000 - 0x7fd554693000 = 311296 字节 = 实际 Y 平面大小
frame->data[2] - frame->data[1] = 0x7fd5546f2000 - 0x7fd5546df000 = 77824 字节 = 实际 U 平面大小

// 各平面的期望大小:
Y 平面大小 = 640 * 480 * 1 = 307200 字节
U 平面大小 = (640 / 2) * (480 / 2) * 1 = 76800 字节
V 平面大小 = (640 / 2) * (480 / 2) * 1 = 76800 字节

可以使用下面方式将 YUV 像素数据写入文件,yuv420p 像素格式色度分量 U 和 V 是 1/2 垂直采样,所以高度要除以 2:

outFile.write((const char *)frame->data[0], frame->linesize[0] * frame->height);
outFile.write((const char *)frame->data[1], frame->linesize[1] * frame->height >> 1);
outFile.write((const char *)frame->data[2], frame->linesize[2] * frame->height >> 1);

10、释放资源

inFile.close();
outFile.close();
av_packet_free(&pkt);
av_frame_free(&frame);
av_parser_close(parserCtx);
avcodec_free_context(&ctx);

参考链接:https://patchwork.ffmpeg.org/project/ffmpeg/patch/tencent_609A2E9F73AB634ED670392DD89A63400008@qq.com/

完整示例代码:

h264_decode.pro:

macx {
    INCLUDEPATH += /usr/local/ffmpeg/include
    LIBS += -L/usr/local/ffmpeg/lib \
        -lavcodec \
        -lavutil
}

ffmpegutils.h:

#ifndef FFMPEGUTILS_H
#define FFMPEGUTILS_H

extern "C" {
    #include <libavformat/avformat.h>
}

// 解码后的YUV参数
typedef struct {
    const char *filename;
    int width;
    int height;
    AVPixelFormat pixFmt;
    int fps;
} VideoDecodeSpec;

class FFmpegUtils
{
public:
    FFmpegUtils();
    static void h264Decode(const char *inFilename, VideoDecodeSpec &out);
};

#endif // FFMPEGUTILS_H

ffmpegutils.cpp:

#include "ffmpegutils.h"

#include <QDebug>
#include <QFile>

extern "C" {
    #include <libavcodec/avcodec.h>
    #include <libavutil/avutil.h>
    #include <libavutil/imgutils.h>
}

#define ERRBUF(ret) \
    char errbuf[1024]; \
    av_strerror(ret, errbuf, sizeof (errbuf))

// 输入缓冲区大小 官方示例程序建议大小
#define IN_INBUF_SIZE 4096

FFmpegUtils::FFmpegUtils()
{

}

static int decode(AVCodecContext *ctx, AVPacket *pkt, AVFrame *frame, QFile &outFile)
{
    int ret = 0;

    // 发送数据到解码器
    ret = avcodec_send_packet(ctx, pkt);

    if (ret < 0) {
        ERRBUF(ret);
        qDebug() << "avcodec_send_packet error:" << errbuf;
        return ret;
    }

    while (true) {
        // 获取解码后的数据
        ret = avcodec_receive_frame(ctx, frame);
        if (ret == AVERROR(EAGAIN) || ret == AVERROR_EOF) {
            return 0;
        } else if (ret < 0) {
            ERRBUF(ret);
            qDebug() << "avcodec_receive_frame error:" << errbuf;
            return ret;
        }

        // 将解码后的数据写入文件
        // 写入Y平面数据
        outFile.write((const char *)frame->data[0], frame->linesize[0] * frame->height);
        // 写入U平面数据
        outFile.write((const char *)frame->data[1], frame->linesize[1] * frame->height >> 1);
        // 写入V平面数据
        outFile.write((const char *)frame->data[2], frame->linesize[2] * frame->height >> 1);
    }
}

void FFmpegUtils::h264Decode(const char *inFilename, VideoDecodeSpec &out)
{
    // 返回值
    int ret = 0;

    // 输入文件(h264文件)
    QFile inFile(inFilename);
    // 输出文件(yuv文件)
    QFile outFile(out.filename);

    // 解码器
    AVCodec *codec = nullptr;
    // 解码上下文
    AVCodecContext *ctx = nullptr;
    // 解析器上下文
    AVCodecParserContext *parserCtx = nullptr;
    // 存放解码前的h264数据
    AVPacket *pkt = nullptr;
    // 存放解码后的yuv数据
    AVFrame *frame = nullptr;

    // 存放读取的h264文件数据
    // 加上AV_INPUT_BUFFER_PADDING_SIZE是为了防止某些优化过的reader一次性读取过多导致越界(参考了FFmpeg示例代码)
    char inDataArray[AUDIO_INBUF_SIZE + AV_INPUT_BUFFER_PADDING_SIZE];
    char *inData = nullptr;
    // 输入数据缓冲区中剩余的待解码的数据长度
    int inLen = 0;
    // 是否读取到了输入文件尾部
    int inEnd = 0;

    // 获取H264解码器,也可以根据解码器名称获取
    codec = avcodec_find_decoder(AV_CODEC_ID_H264);
    if (!codec) {
        qDebug() << "decoder h264 not found";
        return;
    }

    // 初始化解析器上下文
    parserCtx = av_parser_init(codec->id);
    if (!parserCtx) {
        qDebug() << "av_parser_init error";
        return;
    }

    // 创建解码上下文
    ctx = avcodec_alloc_context3(codec);
    if (!ctx) {
        qDebug() << "avcodec_alloc_context3 error";
        goto end;
    }

    // 创建AVPacket
    pkt = av_packet_alloc();
    if (!pkt) {
        qDebug() << "av_packet_alloc error";
        goto end;
    }

    // 创建AVFrame
    frame = av_frame_alloc();
    if (!frame) {
        qDebug() << "av_frame_alloc error";
        goto end;
    }

    // 打开解码器
    ret = avcodec_open2(ctx, codec, nullptr);
    if (ret < 0) {
        ERRBUF(ret);
        qDebug() << "open decoder error:" << errbuf;
        goto end;
    }

    // 打开h264文件
    if (!inFile.open(QFile::ReadOnly)) {
        qDebug() << "open file failure:" << inFilename;
        goto end;
    }

    // 打开yuv文件
    if (!outFile.open(QFile::WriteOnly)) {
        qDebug() << "open file failure:" << out.filename;
    }

    do {
        // 从文件中读取h264数据
        inLen = inFile.read(inDataArray, AUDIO_INBUF_SIZE);
        // inData指向inDataArray首元素
        inData = inDataArray;
        // 设置是否到了文件尾部
        inEnd = !inLen;

        while (inLen > 0 || inEnd) { // 到了文件尾部,虽然没有读取任何数据,但也要调用av_parser_parse2(修复bug)
            ret = av_parser_parse2(parserCtx, ctx, &pkt->data, &pkt->size, (const uint8_t *)inData, inLen, AV_NOPTS_VALUE, AV_NOPTS_VALUE, 0);
            if (ret < 0) {
                ERRBUF(ret);
                qDebug() << "av_parser_parse2 error:" << errbuf;
                goto end;
            }

            // 跳过解析过的数据
            inData += ret;
            // 减去已解析过的数据大小
            inLen -= ret;

            // 解码
            if (pkt->size > 0 && decode(ctx, pkt, frame, outFile) < 0) {
                goto end;
            }

            // 当inEnd = 1时到了文件尾部
            if (inEnd) break;
        }
    } while (!inEnd);

    // 刷出缓冲区中剩余数据

    // 方式一:
    decode(ctx, nullptr, frame, outFile);

    // 方式二:
    // pkt->data = nullptr;
    // pkt->size = 0;
    // decode(ctx, pkt, frame, outFile);

    // 输出参数
    out.width = ctx->width;
    out.height = ctx->height;
    out.pixFmt = ctx->pix_fmt;
    // 用framerate.num获取帧率,并不是time_base.den
    out.fps = ctx->framerate.num;

end:
    inFile.close();
    outFile.close();
    av_packet_free(&pkt);
    av_frame_free(&frame);
    av_parser_close(parserCtx);
    avcodec_free_context(&ctx);
}

方法调用:

#include "mainwindow.h"
#include "ui_mainwindow.h"

#include <QDebug>

#include <ffmpegutils.h>

extern "C" {
    #include <libavutil/imgutils.h>
}

#define IN_FILE "/Users/mac/Downloads/pic/in_640x480_yuv420p.h264"
#define OUT_FILE "/Users/mac/Downloads/pic/out_640x480_yuv420p_code.yuv"

MainWindow::MainWindow(QWidget *parent)
    : QMainWindow(parent)
    , ui(new Ui::MainWindow)
{
    ui->setupUi(this);
}

MainWindow::~MainWindow()
{
    delete ui;
}

void MainWindow::on_decodeH264Button_clicked()
{
    VideoDecodeSpec spec;
    spec.filename = OUT_FILE;
    FFmpegUtils::h264Decode(IN_FILE, spec);

    qDebug() << "宽度:" << spec.width;
    qDebug() << “高度:" << spec.height;
    qDebug() << “像素格式:" << av_get_pix_fmt_name(spec.pixFmt);
    qDebug() << “帧率:" << spec.fps;
}
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,457评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,837评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,696评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,183评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,057评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,105评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,520评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,211评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,482评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,574评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,353评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,213评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,576评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,897评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,489评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,683评论 2 335

推荐阅读更多精彩内容