FFmpeg 解码视频流

前言

本文讲解从网络接收 H.265 视频裸流后使用 FFmpeg 相关库解码得到 YUVJ420P 格式的图片然后转为 RGB 格式图片并使用 jpeglib 库保存为 jpeg 文件。仅展示部分代码。

包含对 FFmpeg 的解码 H.265 视频裸流、图片保存格式 YUV 和 RGB 及两者之间的转换、jpeglib 将 RGB 格式图片保存为 jpeg 文件的相关内容。

FFmpeg 库

包含各种库,本文主要使用到以下库:

libavcodec 编码/解码库

libavformat I/O 和复用/解复用库

libavutil 通用实用程序库

libswscale 颜色转换和缩放库

解码相关的结构体

  • AVCodecContext:可以是 编码器 的上下文,也可以是 解码器 的上下文,两者使用的是同一种数据结构(后面有些结构体和 API,编码器和解码器都可以使用,但为了方便,后面只提解码器)。
  • AVCodec:存储解码器信息(为了方便,后面简称就是“解码器”了,基本上也可以这么理解)。
  • AVCodecParameters:存储解码参数。
  • AVPacket:原始数据包(已编码压缩的 IBP 帧),这里面的数据通常是一帧视频的数据,或者一帧音频的数据。本身是没有编码数据的,只是管理编码数据。
  • AVFrame:解码之后的帧。AVFrameAVPacket 类似,是一个管理数据的结构体,本身是没有数据的,只是引用了数据。

与解码器相关的 API 函数

  • const AVCodec* avcodec_find_decoder(enum AVCodecID id):

    根据参数传入的解码器的 AVCodecID 返回指定的解码器。AVCodecID 是一个枚举类型,比如:传入 AV_CODEC_ID_H265 返回解码 H265 视频流的解码器。

  • AVCodecContext* avcodec_alloc_context3 (const AVCodec *codec)

    接收一个AVCodec编解码)参数,返回根据指定解码器初始化后的解码器上下文。如果为 NULL,则不会初始化特定于解码器的默认值,这可能会导致默认设置不理想。

    使用 void avcodec_free_context (AVCodecContext **avctx ) 释放解码器上下文资源。

  • AVCodecParameters *avcodec_parameters_alloc()

    分配新的 AVCodecParameters 并将其字段设置为默认值,返回的AVCodecParameters必须使用 avcodec_parameters_free() 释放。

  • int avcodec_parameters_from_context(struct AVCodecParameters *par,const AVCodecContext *codec)

    根据提供的AVCodecContext中的值填充AVCodecParameters。par 中任何分配的字段都将被释放,并替换为解码器中相应字段的副本。成功时 >= 0,失败时为负 AVERROR 代码。

  • int avcodec_parameters_to_context(AVCodecContext *codec, const struct AVCodecParameters *par)

    根据AVCodecParameters 参数提供的解码器参数(宽高,像素格式等信息)复制到 AVCodecContext(解码器上下文)。

  • int avcodec_open2( AVCodecContext *avctx, const AVCodec* codec, AVDictionary **options)

    使用初始化后AVCodecContext(编解码器上下文)和设定的选项打开一个编/解码器。在使用此函数前,必须使用avcodec_alloc_context3()初始化上下文。

  • int avcodec_send_packet(AVCodecContext* avctx, const AVPacket* avpkt)

    AVCodecContext 绑定的解码器发送一个 AVPacket (需要被解码的一个原始数据帧)。帧的所有权仍属于调用者。返回0表示成功;返回 AVERROR(EAGAIN) 表示当前状态不接受输入,必须调用下面的函数读取输出,然后重新发送数据包;调用前必须用 avcodec_open2() 打开 AVCodecContext

  • int avcodec_receive_frame(AVCcodecContext* avtx, AVFrame* frame)

    AVCodecContext 绑定的解码器读取一个 AVFrame(解码后的一个输出数据帧),返回值为0表示成功,返回一帧;为 AVERROR(EAGAIN) 表示输出不可用,需要继续输入(可能是B\P帧);为 AVERROR_EOF 表示解码器完全刷新,不会再有输出帧。

与 AVPacket 和 AVFrame 相关的函数

  • AVPacket *av_packet_alloc()

    创建一个 AVPacket 并将其字段设置为默认值,失败时返回 NULL。必须使用av_packet_free()释放生成的结构。函数仅分配 AVPacket 本身,而不分配数据缓冲区。这些必须通过其他方式分配,例如使用av_new_packet

  • AVFrame *av_frame_alloc()

    创建一个 AVFrame 并将其字段设置为默认值。失败时返回 NULL。必须使用av_frame_free()释放生成的结构。函数仅分配 AVFrame 本身,而不分配数据缓冲区。这些必须通过其他方式分配,例如使用av_frame_get_buffer()

  • void* av_malloc(size_t *size*)

    分配一个内存块,其对齐方式适合所有内存访问(包括 CPU 上可用的矢量)。类似于 malloc。主要用于为初始化后的AVPacketAVFrame分配 data 内存。

  • int av_packet_from_data(AVPacket *pkt, uint8_t *data, int size):

    从已经使用 av_malloc() 分配内存的数据初始化引用计数的数据包。pkt指向要初始化的数据包,data 为由av_malloc()分配的数据缓冲区。成功返回 0,数据归基础 AVBuffer 所有。调用方可能无法通过其他方式访问数据。

AVCodecAVCodecParameters补充

  • AVCodec 里面放的是 解码器的相关信息

AVCodec 是使用 avcodec_find_decoder() 函数获得的(见上文),传递个函数一个 AVCodecID,返回一个对应的解码器指针。这是引入 FFmpeg 库的时候,他初始化了一堆静态的编解码变量给你。

例如:传递的 AVCodecIDAV_CODEC_ID_H265 ,就会返回与 H265 视频相关的 AVCodec 指针, AVCodecIDAV_CODEC_ID_H264 ,就会返回与 264 视频相关的 AVCodec 指针。

只要是用 H264 编码的视频,使用的解码器都是一样的,用的是同一个 AVCodec

  • AVCodecParameters 里面放的是 解码参数

虽然都是 H265 视频文件,但是他们的宽高,采样这些信息,可能会不一样,这些信息就存在AVCodecParameters里,

avformat_open_input 函数打开一个 MP4 的时候,解码参数就会放在 codecpar 字段里,如下:

int avformat_open_input(AVFormatContext** ps, const char* url, const AVInputFormat* fmt, AVDictionary** options)

但是如果直接获得的裸流,比如本问所描述的,没有文件供打开而获得 AVCodecParameters,因此需要使用AVCodecParameters *avcodec_parameters_alloc()int avcodec_parameters_from_context(struct AVCodecParameters *par,const AVCodecContext *codec)来构建 AVCodecParameters 。

例如:ps 为 NULL 时,自动分配 AVFormatContext 并将其写入 ps 中,url 可以是网络 URL 也可以是本地文件名;fmt为 NULL 时,此参数强制使用个特定的输入格式,不为 NULL 时自动检测格式;options 参数被销毁,并替换为包含未找到的选项的字典,可能为 NULL。例如:

1
2
3
avformat_open_input(&fmt_ctx, filename, NULL,NULL);
AVCodecContext *avctx = avcodec_alloc_context3(NULL);
ret = avcodec_parameters_to_context(avctx, fmt_ctx->streams[0]->codecpar);

上面的 codecpar 就是一个 AVCodecParameters,只需要用 avcodec_parameters_to_context 函数把 codecpar 的参数复制给 AVCodecContext 即可。

解码流程

打开解码器的一般流程

avcodec_alloc_context3()avcodec_parameters_to_context()avcodec_find_decoder()avcodec_open2()

avcodec_alloc_context3avcodec_open2 这两个函数都可以接受 AVCodec 参数,选一个函数来接受即可,千万不要往这两个函数传递不一样的 AVCodec 参数。

avcodec_alloc_context3() 传入 NULL,初始化一个解码器上下文。

avcodec_parameters_to_context()将已有的解码参数复制到解码器上下文。

avcodec_find_decoder()根据传入 ID 获得对应的解码器,根据传传入的解码器上下文和解码器信息打开解码器。

示例代码部分如下(为了简短均为没有检测返回值,实际应用时应该检测返回值):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
AVFormatContext *fmt_ctx = NULL;
int ret = 0;
int err;
char filename[] = "juren-30s.mp4";

fmt_ctx = avformat_alloc_context();

avformat_open_input(&fmt_ctx, filename,NULL,NULL)

AVCodecContext *avctx = avcodec_alloc_context3(NULL);

avcodec_parameters_to_context(avctx, fmt_ctx->streams[0]->codecpar);

AVCodec *codec = avcodec_find_decoder(avctx->codec_id);

H.265视频解码流程

首先,我们已经从网络接收到了待处理的一个个原始视频帧的缓冲区,里面是 H.265 视频裸流。这里注意,裸流可能不包含前四个字节:0x00,0x00,0x00,0x01,这四个字节是区分一帧的关键必须包含,如果没有就手动添加到packet->data开头,否则交由解码器解码时会报错。

开启一个发数据包的循环不断的将其作为 AVPacket 发给解码器。

解码器收到一个 AVPacket后,我们不一定立马就能从解码器读到一个 AVFrame,因为视频可能有 B 帧,一个单独的 B 帧无法得到一个完整的帧。需要继续给解码器提供后续的 AVPacket 才能得到一个完整的帧。同时在我们提供一个 AVPacket 后,也可能会得到两个完整的帧(由于 B 帧的存在)。

因此,我们发一个 AVPacket后,再开启一个循环读解码器,如果返回值为0,就说明得到一帧 YUV 信息,进行一些处理。如果返回值为EAGAIN,它表示解码器需要输入更多的 AVPacket ,才能解码出 AVFrame,我们就跳出第读解码器的循环,进入发 AVPacket 的循环继续发 AVPacket

当所有的 AVPacket 都已经发完时,解码器还可能在等待后续的 AVPacket 到来以解码之前收到的 B 帧,但此时我们已经没有 AVPacket 可以发送给解码器了。此时需要往解码器发一个 sizedata 都是 0 的 AVPacket(NULL)。这样解码器就会把它内部剩余的帧,全部都刷出来。

当解码器完全没有帧可以输出的时候,就会返回 AVERROR_EOF

解码后得到的 AVFrame

部分字段:

  • frame->width,frame->height:帧的宽高。
  • frame->pts:帧的显示时间点
  • frame->pkt_duration:帧的持续时间
  • frame->format:帧的格式,有很多,整数枚举类型,0 代表 AV_PIX_FMT_YUV420P
  • frame->key_frame:(已弃用,使用 flags 中的AV_FRAME_FLAG_KEY代替)代表当前帧是不是关键帧,第一帧通常都是关键帧。I 帧为关键帧,可以单独解码出一个AVFrame ,B、P 帧不是关键帧,不能单独解码出一个AVFrame
  • frame->pict_type:这个是 AVPictureType 枚举类型,1 代表 AV_PICTURE_TYPE_I ,即 I 帧。
  • frame->data:存储帧的 Y、U、V 信息。

解码的到的一般是 YUV420P 格式即上面frame->format的值:AV_PIX_FMT_YUV420P。它是一个存储帧的格式,以一定的方式存储 Y、U、V 信息(这方面展开说有些多,这里只看 YUV420P ):

  • Y 表示一帧的一块区域的明亮度,U 和 V 表示这块区域的色度与浓度。每个 Y U V 在内存中都占8位。

  • 420 表示4:2:0采样,一个像素一个 Y,水平和垂直方向上都是每两个像素采一个 U 和 V。即每四个Y共用一组 UV 分量。内存上 Y 是 UV 大小的四倍。

  • P 表示Planar,YUV 按平面分开放,三个平面依次存放,YUV都是连续存储的:

    image-20240716163803984

AVFrame 结构体里面有一个 data 字段,存储了 YUV 数据, YUV420P 使用 planar 格式的内存布局来存储,YUV420 使用 packed 格式的内存布局来存储,这里以 YUV420P 为例:

data[0] 会指向 Y 数据,data[1] 指向 U 数据,data[2] 指向 V 数据。还有一个 linesize 数组字段来管理这些数据的大小。例如linesize[0]表示data[0]指向的 Y 数据的一行的大小。YUV420P 的格式,U 或者 V 的大小应该是 Y的 4 分之一。

如果直接打印出 linesize 的值,会发现 U、V 的值是 Y 的二分之一。这是因为linesize 里存的是 stride 值。

stride 值 = 图像宽度 * 分量数 * 单位样本宽度 / 水平子采样因子 / 8

分量数就是通道数,YUV420P 是 planer 内存布局 ,所以分量数是 1。单位样本宽度是指一个样本的宽度占多少位,YUV420P 为 16 位。

最重要的是水平采样因子,水平子采样因子指在水平方向上每多少个像素采样出一个色度样本。YUV420P 水平方向其实是每两个像素采样一个色度样本,所以是水平采样因子是 2。

还有一个垂直采样因子,YUV420P 的 垂直采样因子 也是 2 。但是 垂直采样因子不会影响 stride 值。

frame->linesize 并不是 UV 分量的真实数据大小,而是一个 stride 值。并且从上面可以看出,如果使用的是 YUV420P 格式,会有:stride 值 = width * 1 * 16 / 2 / 8 = width,实际上就是帧的宽度 width。最终这个值可能还会内存对齐。

YUV420P 格式的帧保存为图片

通过data字段我们就可以得到帧的 Y、U、V 信息。 通过 linesize 字段我们可以的到他们的大小。但是一般情况下是data字段都是有内存对齐现象存在的。如果linesize 里存的 stride 值大小不足2的n次方,则会填充多余数据以达到2的n次方。例如,计算的到的linesize[0]为480,而它实际大小确是512。因此我们需要镜多余的数据剔除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

int a = 0, i;
for (i = 0; i<frame->height; i++)
{
memcpy(m_YuvBuf + a, frame->data[0] + i * frame->linesize[0], frame->width);
a += frame->width;
}
for (i = 0; i<frame->height / 2; i++)
{
memcpy(m_YuvBuf + a, frame->data[1] + i * frame->linesize[1], frame->width / 2);
a += frame->width / 2;
}
for (i = 0; i<frame->height / 2; i++)
{
memcpy(m_YuvBuf + a, frame->data[2] + i * frame->linesize[2], frame->width / 2);
a += frame->width / 2;
}

到这里,已经将 H265 裸流保存为了 YUV420P 的文件。

参考资料

FFmpeg 官网

FFmpeg doxygen文档