经过前面三章的学习,我们快要完成我们的目标任务了:使用 ffmpeg 解码视频,并将解码后的视频帧保存在本地(就像对视频截图一样)。
现在就差临门一脚,如何将解码后的视频帧保存到本地呢?这是今天要讨论的内容。
本文参考文章来自 An ffmpeg and SDL Tutorial - Tutorial 01: Making Screencaps。这个系列对新手较为友好,但 2015 后就不再更新了,以至于文章中的 ffmpeg api 已经被弃用了。幸运的是,有人对该教程的代码进行重写,使用了较新的 api,你可以在 rambodrahmani/ffmpeg-video-player 找到这些代码。
本文的代码在 ffmpeg_video_player_tutorial-tutorial01。
在使用 FFmpeg 进行视频处理时,AVPacket 表示被算法压缩后的视频数据,对 AVPacket 进行解码后,我们可以得到 AVFrame,它是用来表示视频画面的一个数据结构,包含了该画面的宽度、高度、像素格式等信息。
像素格式(Pixel format)是一种用于表示和存储数字图像中每个像素数据的方式。它描述了每个像素中哪些颜色通道是可用的,以及这些通道的顺序、比特深度和其他属性。像素格式决定了图像数据在内存中的存储布局。常见的像素格式包括:
这些格式可能有不同的比特深度,比如8位、16位等,表示每个颜色通道的数据精度。不同的像素格式和比特深度将影响颜色的展示和图像的质量,同时也决定了文件大小和处理速度。在计算机图形、视频编码和图像处理中,选择合适的像素格式尤为重要。
在 FFmpeg 的 AVPixelFormat 枚举中有大几十种像素格式,你可能会发问:为什么要有这么多的像素格式?都使用 RGB 不行吗?
将所有图像都使用RGB像素格式的确是可能的,但实际上,不同的像素格式存在的原因在于它们各自适用于不同的场景和需求。这些场景可能需要考虑存储空间、颜色表现力和处理效率等方面的差异。以下是一些不同像素格式存在的原因:
存储和带宽优化:一些像素格式,如YUV,可能需要比RGB更少的存储空间。因为YUV格式考虑到人眼对亮度敏感度高于色度,常常在色度分量上采样更低的分辨率,因此可以在保持相对较高的图像质量的同时减小文件大小。这在视频压缩和传输等场景下特别重要。
颜色空间:RGB是一种基于颜色的光学叠加模型,对某些颜色计算并不直观。其他颜色空间,如HSB(色相、饱和度、亮度)或YUV,可能在特定的颜色操作或计算上更具优势。
兼容性和专业领域需求:例如,在视频行业和电视广播中,YUV格式有更好的性能和兼容性。而在一些图形应用程序中,可能需要包含透明度通道的像素格式,例如RGBA,从而支持透明度相关的效果和操作。
灰度图像:对于只需要单个颜色通道的应用,如文本扫描、医学成像或图像分析等,使用灰度格式可以大幅减少存储和处理资源的需求。
总之,不同的像素格式适用于不同的场景。RGB虽然是一种常用的颜色表示方法,但在某些情况下,使用其他像素格式可能会更加高效或更适合任务需求。
关于 YUV 格式,笔者之前写过一篇 YUV 文件读取、显示、缩放、裁剪等操作教程,有兴趣的读者可以参考参考。
经过前面三章内容,我们现在已经能够将 AVPacket 解码为 AVFrame,为了保存 AVFrame 中的图像数据到本地,需要对 AVFrame 做一次像素格式的转换,这样方便我们直接看到视频帧的内容。
为啥要做像素转换呢?这是因为多数解码后的 AVFrame 使用 YUV 作为像素格式(比 RGB 更少的存储空间),如果你想看 YUV 格式的图片,你需要一个类似 YUV Viewer 的软件来打开 YUV 图片。当然你可以选择用我开发的 simple_yuv_viewer。
但如果我们将 AVFrame 转为 RGB,然后将 RGB 数据保存在 PPM 格式文件中,那么你基本可以使用任意图片预览软件就能打开。方便不少。
首先需要介绍写 PPM 文件,PPM(Portable Pixmap)文件是一种简单的图像文件格式,用于存储彩色图像。PPM文件格式通常存储未压缩的RGB图像数据,因此文件大小相对较大。由于其简易的文件结构和易于解析的特点,该格式常用于学习和测试图像处理算法。
PPM文件包含以下部分:
尽管PPM文件易于处理,但它不适合于大型或复杂图像,因为它占用较大的存储空间且不支持数据压缩。在实际应用中,我们通常会使用其他压缩格式,如JPEG、PNG等。
实际代码中,将一个 RGB 格式的 AVFrame 保存到本地非常简单:
void saveFrame(AVFrame *avFrame, int width, int height, const char* output_name)
{
FILE *pFile;
// Open file
pFile = fopen(output_name, "wb");
if (pFile == NULL) {
return;
}
// Write header
fprintf(pFile, "P6\n%d %d\n255\n", width, height);
// Write pixel data
const int kBytesPerPixel = 3; // R(8) G(8) B(8)
for (int y = 0; y < height; y++) {
uint8_t *img_row = avFrame->data[0] + y * avFrame->linesize[0];
fwrite(img_row, 1, width * kBytesPerPixel, pFile);
}
// Close file
fclose(pFile);
}
在解释上面代码逻辑之前,需要对 linesize 的概念进行说明。linesize,有时候它也叫 stride,或者 pitch,名字不同但含义相同。
linesize是指图像一行数据所占用的字节数。在处理图像时,图像的像素数据是以一行一行的方式存储的。由于一行像素数据不一定和图像宽度相等,因此需要用linesize来表示每行像素数据所占用的字节数。通常情况下,一个像素占用的字节数是已知的,因此可以通过图像宽度和像素占用的字节数计算出每行数据的字节数(linesize)。
当图像数据按行存储时,linesize可能大于实际像素宽度乘以每个像素所需的字节数。例如,如果一幅图像有宽度W、高度H、RGB格式的像素, 并且使用32位对齐存储,则linesize大于3*W。两行相邻像素之间的多余空间可能用于存储其他信息,或者是因为对齐的原因而保留。
了解linesize是有必要的,因为在执行图像处理任务时,你可能需要使用它来遍历图像中每行的所有像素。逐行遍历图像时,需要利用行跨距来确定每一行数据在内存中的起始位置,从而能够正确地访问和处理像素数据。
假设你正在处理一张100×100像素的彩色RGB图像,每个像素通常需要3个字节(24位)来存储红、绿和蓝颜色通道的数据。要进行8位字节对齐,我们首先找出紧密排列时每一行所需的字节数:
所以,在采用8字节对齐存储的情况下,linesize为 304 字节。这意味着在每行末尾有4个字节的填充空间以符合8字节对齐规则。
因此,存放一张 100 * 100 的 RGB 图片,实际使用的内存大小是:100 * 304 = 30400,而不是 100 * 300 = 30000。
回到之前的代码中来,对保存 AVFrrame 到 PPM 文件的逻辑进行一些说明:
avFrame->data[0]
指针指向一片内存,该内存存放着整张图片的 RGB 数据。注意:这片内存包含着用于填充 linesize 的无用数据。avFrame->data[0]
按行存放 RGB 数据,大致如下图,其中灰色表示无用数据。fprintf(pFile, "P6\n%d %d\n255\n", width, height);
这是往 PPM 文件中写入头部信息,其中 “255” 表示 RGB 分量最大值为 255for
中循环写入每一行数据,其中 avFrame->data[0] + y * avFrame->linesize[0];
定位到每一行的开始的位置;fwrite(img_row, 1, width * kBytesPerPixel, pFile);
写入每一行中的所有 RGB 数据。正如前面所说,视频中一般使用 YUV 作为像素格式,为了保存为 PPM 文件,需要将 YUV 转换 RGB。在 FFmpeg 中,已经提供了方便的工具来做像素格式转换的工作,它就是 sws_scale 函数。
sws_scale 函数是 FFmpeg 中的一个关键功能,用于实现视频图像缩放和格式转换。它是由 libswscale 库提供的,这是一个专门用于处理各种像素格式以及実现高效缩放和颜色空间/格式转换的库。
sws_scale 函数的作用具体包括以下几点:
那么如何使用 sws_scale 呢?步骤如下:
SwsContext
结构体sws_scale
函数进行转换即可让我们来看每个步骤具体的实现。这部分代码参考 ffmpeg_image_converter.h 中的实现
首先,使用 sws_getContext
创建 SwsContext
。你需要填很多信息,包括源视频的宽高、像素格式,以及目标宽高、目标像素格式等
sws_ctx = sws_getContext(srcW, srcH, srcFormat, dstW, dstH, dstFormat,
flags, srcFilter, dstFilter, param);
创建一个 AVFrame 用于存放转换后的数据,其中 av_frame_get_buffer
, 函数是用于为 AVFrame 结构体分配内存空间的函数。它会根据 AVFrame 的格式和大小信息,为 AVFrame 的数据指针分配内存并设置行大小,以准备存储原始数据。
frame = av_frame_alloc();
frame->width = dstW;
frame->height = dstH;
frame->format = dstFormat;
frame->format = (int)dstFormat;
frame->width = dstW;
frame->height = dstH;
frame->channels = 0;
frame->channel_layout = 0;
frame->nb_samples = 0;
av_frame_get_buffer(frame, 16);
最后,使用 sws_scale 进行转换
frame->pict_type = in_frame->pict_type;
frame->pts = in_frame->pts;
frame->pkt_dts = in_frame->pkt_dts;
frame->key_frame = in_frame->key_frame;
frame->coded_picture_number = in_frame->coded_picture_number;
frame->display_picture_number = in_frame->display_picture_number;
int output_height = sws_scale(
sws_ctx, (uint8_t const *const *)in_frame->data, in_frame->linesize, 0,
in_frame->height, frame->data, frame->linesize);
本文讲述了如何将一帧视频保存到本地 PPM 文件,以便浏览。介绍了关于像素格式、PPM、Linesize、FFmpeg 中 sws_scale 等知识点。
结合前面三章内容,我们终于完成了第一个任务,对应 An ffmpeg and SDL Tutorial - Tutorial 01: Making Screencaps。接下来我们将继续 ffmpeg 学习路程。