最近工作中遇到一个需求。现有代码中的图形库使用 ImageMagic 加载图片并做简单处理,但是在移植到 iOS 平台的过程中遇到了些问题。于是找到我,看能否用 FFmpeg 实现图片的从文件中读取加载、存储到文件中、以及缩放、裁剪等简单处理,并对比与 ImageMagic 相关功能的效率
于是就边学边做,用 FFmpeg API 接口实现了一个 MyImage 类,提供Load(string filename), Load(uint8_t* buffer, int w, int h, AVPixelFormat fmt) , Write(string filename), Write(uint8_t* buffer, int bufsize, enum AVPixelFormat format), width(), height(), format(), Resize(int to_w, to_h), Crop(int x, int y, int w, int h)等接口。
编解码是大头
但很容易理解。关键就是把一张图片看作是只有一帧的视频。
读取
所以,读取图片文件的整个代码流程如下:
bool Load(string filename) {
AVFormatContext* fmt_ctx = nullptr;
AVCodecContext* codec_ctx = nullptr;
AVCodec* codec = nullptr;
avformat_open_input(&fmt_ctx, filename.c_str(), nullptr, nullptr);
// 代码删除了所有判断 api 调用失败的情况
int stid = av_find_best_stream(fmt_ctx, AVMEDIA_TYPE_VIDEO, -1, -1, &codec, false);
codec_ctx = avcodec_alloc_context3(codec);
avcodec_parameters_to_context(codec_ctx, fmt_ctx->streams[stid]->codecpar);
avcodec_open2(codec_ctx, codec, nullptr);
AVFrame* frame = av_frame_alloc();
frame->format = codec_ctx->pix_fmt;
frame->width = codec_ctx->width;
frame->height = codec_ctx->height;
av_frame_get_buffer(frame, 0);
AVPacket pkt;
av_init_packet(&pkt);
int ret = 0;
while (av_read_frame(fmt_ctx, &pkt) >= 0) {
if (pkt.stream_index == stid) {
avcodec_send_packet(codec_ctx, &pkt);
while ((ret = avcodec_receive_frame(codec_ctx, frame)) != 0) {
if (ret == AVERROR_EOF) break;
avcodec_send_packet(codec_ctx, nullptr);
}
av_packet_unref(&pkt);
break;
} else {
av_packet_unref(&pkt);
}
}
av_packet_unref(&pkt); // 注意:缺少这句将造成内存泄漏
return frame->width * frame->height > 0;
}
以上,就算是成功地将一张 .jpg 或者 .png 或者其他扩展名的图片,读取到内存中了。只是暂时它存储在 frame->data 里,我们没有拷贝到自己申请的内存中。缺少倒数第三行会造成内存泄漏,参见我的文章FFmpeg av_packet_unref() 不严谨导致的一次内存泄漏,每次 6MB。当然,正常编码中 AVFrame* frame 是一个类成员变量,而非局部变量。此处为了方便,简化了。
存储
同样的,存储的代码流程如下:
bool Save(string filename) {
if (!frame || frame->data[0]) { // 假设 frame 是个类成员变量
cout << "nothing to save to file." << endl;
return false;
}
AVFormatContext* ofmt_ctx = nullptr;
AVCodecContext* ocodec_ctx = nullptr;
AVCodec* ocodec = nullptr;
int ret = avformat_alloc_output_context2(&ofmt_ctx, nullptr, nullptr, filename.c_str());
if (!ofmt_ctx) {
cout << "could not deduce output format from file extension, using image2. << endl;
avformat_alloc_output_context2(&ofmt_ctx, nullptr, "image2", filename.c_str());
}
ocodec = avcodec_find_encoder(ofmt_ctx->oformat->video_codec);
AVStream* st = avformat_new_stream(ofmt_ctx, nullptr);
st->id = ofmt_ctx->nb_streams - 1;
ocodec_ctx = avcodec_alloc_context3(ocodec);
ocodec_context_->codec_id = oformat_context_->oformat->video_codec;
ocodec_context_->codec_type = AVMEDIA_TYPE_VIDEO;
ocodec_context_->width = frame_->width;
ocodec_context_->height = frame_->height;
st->time_base = av_make_q(1, 25);
ocodec_context_->time_base = st->time_base;
ocodec_context_->sample_aspect_ratio = av_make_q(1, 1);
ocodec_context_->profile = FF_PROFILE_MJPEG_HUFFMAN_PROGRESSIVE_DCT;
ocodec_context_->pix_fmt = AV_PIX_FMT_YUVJ420P;
avcodec_open2(ocodec_ctx, ocodec, nullptr);
avcodec_parameters_from_context(st->codecpar, ocodec_ctx);
avio_open(&ofmt_ctx->pb, filename.c_str(), AVIO_FLAG_WRITE);
avformat_write_header(ofmt_ctx, nullptr);
avcodec_send_frame(ocodec_ctx, frame);
AVPacket pkt = {0};
avcodec_receive_packet(ocodec_ctx, &pkt);
av_packet_rescale_ts(&pkt, ocodec_ctx->time_base, st->time_base);
pkt.stream_index = st->index;
av_write_frame(ofmt_ctx, &pkt);
av_packet_unref(&pkt);
av_write_trailer(ofmt_ctx);
avio_closep(&ofmt_ctx->pb);
return true;
}
同样地,过程中省略了一些必要的 ffmpeg api 执行错误判断。
缩放
缩放听简单的,就是读取出来的图片在 frame 中,再利用 swscale 相关接口进行一次缩放或者图片颜色格式转换即可,与视频缩放一样。暂不赘述。
裁剪
由于 FFmpeg 的裁剪功能是用滤镜实现的。感觉颇为麻烦,于是,自己参考其 crop 滤镜实现了一种基于 RGBA 颜色格式的裁剪方法。具体代码如下:
bool Crop(int x, int y, int w, int h) { // 以 (x,y) 为起始,裁剪宽 w 高 h 的图片
// 如果不是 RGBA 颜色格式,需先转换成 RGBA 颜色格式,此处省略
// 转换成 RGBA 后的图片帧缓存在 frame 中
croped_frame->width = w;
croped_frame->height = h;
croped_frame->format = frame->format;
av_image_alloc(croped_frame->data, croped_frame->linesize, croped_frame->width, croped_frame->height, (AVPixelFormat)croped_frame->format, 1);
uint8_t * p = nullptr;
for (int i = 0; i < 8 && frame->linesize[i] > 0; i++) {
// p = frame->data[i];
// p += y * frame->linesize[i];
// p += (frame->linesize[i] / frame->width) * x;
// croped_frame->linesize[i] = (frame->linesize[i] / frame->width) * w;
// av_image_copy_plane(croped_frame->data[i], croped_frame->linesize[i], p, frame->linesize[i], croped_frame->linesize[i], croped_frame->height);
frame->data[i] += y * frame->linesize[i];
frame->data[i] += (frame->linesize[i] / frame->width) * x;
croped_frame->linesize[i] = (frame->linesize[i] / frame->width) * w;
av_image_copy_plane(croped_frame->data[i], croped_frame->linesize[i], frame->data[i], frame->linesize[i], croped_frame->linesize[i], croped_frame->height);
}
av_frame_free(&frame);
frame = croped_frame;
croped_frame = nullptr;
return true;
}
现在看起来,这个 crop 方法,可能存在一定的内存泄漏啊。因为 data[i] 被 += 操作移走了,av_frame_free 时估计会释放不完所有内存。因此,这里应该用一个临时变量 uint8_t * p 来代替其进行操作。由于是后期发现的 bug,因此代码中用 注释 进行标记。
for循环里,i < 8 是因为目前 FFmpeg 结构体中的 AVFrame->data[8] 是个长度为 8 的指针数组。当然,也得保证 frame->linesize[i] > 0 才行,其实这一条件,在这里,决定了,它只会执行一次,因为 RGBA 颜色格式的 frame->linesize[1] = 0。
假设我们有一张位图,当前指针指向位图的 (0,0) 位置,第一句,frame->data[i] += y * frame->linesize[i]; 是将指针纵向向下移动 y 行。这时,指针来到了 (0,y - 1)位置
第二句 frame->data[i] += (frame->linesize[i] / frame->width) * x; 这里面,() 中的表达式计算了一个像素的字节数,然后乘上 x,就是讲指针向右移动 x 列,目前,指针来到了 (x - 1,y - 1) 位置。然后调用 ffmpeg 的接口 av_image_copy_plane 即可。
测试
在一台 linux 机器上,使用了一张 5.76MB 的 jpg 图片进行了简单的测试。
加载 100 次用时 15 秒,而使用 ImageMagic 则用时 29 秒。
使用 FFmpeg 的 swscale 对图片进行缩放,从 5k5k 到 1k1k,反复 100 次,用时 8 秒。使用 ImageMagic 反复缩放 10 次即用时 18 秒。
实现了一个 crop 方案,crop 50 次,用时 1 秒,而 ImageMagic 用时 2 秒。
结语
虽然 FFmpeg 对图片进行加载、处理,比 ImageMagic 和 stb_image 快不少。但是,FFmpeg 毕竟比较复杂,而且相关依赖更多。具体取舍还得三思。
我们是刚好工程中有音视频模块,本就用到 FFmpeg,所以,既杀牛又杀鸡的,就用一把刀也无所谓了。而且我们需要反复加载多张图片,如果只加载一次图片(例如之前实现的安全围栏的纹理素材),则 hpp 的 stb_image 是最好最简单的选择了。只需一个头文件,和一个接口,即可实现。