初探 FFMPEG 视频转高质量 GIF
date
Oct 5, 2021
slug
video2gif-with-high-quality
status
Published
tags
coding
type
Post
outer_link
summary
探索如何利用 ffmpeg 将视频转换为高质量 gif 文件, 即画质足够清晰, 同时文件在可接受范围. 多图警告⚠️
1. 缘起
某日, 一同事写内部宣传稿, 演示某人脸算法, 为求生动, 需要将一小段视频转成 GIF 嵌入文稿中进行展示, 却在尽可能保留更多人脸面部细节和生成的 GIF 体积不至于太大间犯了难, 求助于我. 本人一脸懵逼, 只好临时东查西找, 东拼西凑, 用 FFMPEG 一通乱搞也算是交了差. 后来闲暇时间梳理时, 发现视频转 GIF 的背后竟也有一些非常有趣的细节, 就稍作整理, 以备日后查阅.
同事的需求是将视频转换为高质量的 GIF, 这里的高质量可以理解为生成的 GIF 尽量清晰, 保留足够多的细节, 同时体积不至于过大 (比如超过 10 MB). 简单而言, 就是对文件大小没有过于苛刻的要求, 但是画质一定要好.
其实后来, 我发现 ezgif 这个在线视频转 GIF 的网站做的非常完善, 转换压缩一条龙, 支持各种参数微调, 一般情况下可以无脑选它做最终方案(强烈推荐). 不过早期我是不知道这个网站的, 用 FFMPEG 也是实现了差不多的效果. 因此, 也可以将二者对比, 看看差异.
以 B 站这个原神战斗视频为例, 在 1080P 画质下截取 01:04 后大概 3s 的片段作为演示片段, 因为人物动作幅度较大, 背景天空有渐变过渡色, 一旦发生颜色失真就比较明显, 非常适合演示.
>> 效果对比: ffmpeg default vs ezgif.com default
这里 ffmpeg default 是指不加其他特殊附加参数或者滤波器, 直接用
ffmpeg -i input.mp4 output.gif
选用默认配置进行转换. 在对比时, 我另外设置分辨率缩放为 480P, fps 为 10. 因此, 这里 ffmpeg 转换的命令是
ffmpeg -i demo.mp4 -vf "fps=10,scale=480:-1" ffmpeg_default.gif
. ezgif default 也是调整为上述参数后, 直接输出, 不加压缩后处理:二者对比效果如下:
可以看到, ffmpeg default 体积更小, 但失真明显, 出现了常见的棋盘格/网状光栅效应 (crosshatch pattern). ezgif 体积稍大, 但颜色细节好太多, 只在个别地方出现了失真(仔细看舞剑且镜头剧烈晃动时背景天空出现了一些青色噪点). 显然, ezgif 输出的结果就符合我上面提到的高质量 GIF 要求.
那么 ezgif 的效果是如何实现的呢? 下面我将讲述如何用 ffmpeg 命令行实现近似的效果.
2. 透明度优化
和视频编解码中的运动向量 (motion vector) 类似, gif 编码中有个非常重要的算法叫透明度优化. 相邻两帧之间其实有很多色彩重复的区域, 因此在保存下一帧时没必要所有信息全部保存, 可以只保存像素发生差异的区域, 剩下的区域设置为透明, 直接从上一帧取过来补充上就好.
因此, 上图的 gif 使用透明度优化可以像下面这样保存帧信息(仅做示例, 具体有很多不同的实现算法). 这样省去了重复的色彩信息, 节省了可观的空间.
根据 ffmpeg 文档, 透明度优化是默认开启的,可以在 gifflags 选项下设置关闭 transdiff 来禁用这一功能. 尝试下:
$ ffmpeg -i demo.mp4 -vf "fps=10,scale=480:-1" ffmpeg_default.gif
$ ffmpeg -i demo.mp4 -vf "fps=10,scale=480:-1" -gifflags -transdiff ffmpeg_default_no_transdiff.gif
$ ls -lh ffmpeg_default*gif
-rw-r--r-- 1 chenyang staff 1.4M 10 6 01:46 ffmpeg_default.gif
-rw-r--r-- 1 chenyang staff 1.5M 10 6 01:45 ffmpeg_default_no_transdiff.gif
关闭透明度优化后, 体积并没有变大多少, 只增加了 100 KB 左右, 10% 不到. 这是因为, 示例视频相邻帧变化较大, 可设置为透明的背景区域较小, 因此增益不大. 我们用 ImageMagick 逐帧拆解验证下.
$ mkdir ffmpeg_default_frames && convert ffmpeg_default.gif ffmpeg_default_frames/%d.png
$ mkdir ffmpeg_default_frames_no_transdiff && convert ffmpeg_default_no_transdiff.gif ffmpeg_default_frames_no_transdiff/%d.png
透明度优化算法的优势在于, 在基本不改变画质的情况下, 对于背景大面积静止不动的样本, 压缩效果非常显著. 因此建议是采用默认设置打开的. 具体的实现算法可以参考 GIF Disposal Methods. 实践时, 通常把第一帧设定为基准帧, 有点像视频编码中的 I 帧.
3. 抖动(Dithering)
要理解第一节 gif 对比图中 ffmpeg default 中为何会出现棋盘格伪影, 就绕不开抖动 (dithering) 这个概念. 抖动, 简单来讲, 就是利用人眼视觉假象, 用较少的颜色空间去近似模拟更广的颜色空间, 达到更好的显示效果.
举两个有趣的例子. 下图 1 是利用交替的红色和蓝色来模拟紫色, 当格子逐渐密集时, 紫色逼近效果会逐渐变好. 图 2 是 B 站稚晖君自制带电子墨水屏的智能 NFC 卡片那一期中, 在只能显示黑色和白色的单色点阵屏上用抖动算法模拟显示了灰度图 (近视眼摘掉眼镜后效果更佳). 日常使用的黑白报刊图片, 也是用黑色墨水 + 抖动算法实现了灰度图显示效果. 另外, 电子产品中常说的由 8 bit “抖” 上 10 bit, 1080P “抖” 上的伪 4K 显示器, 同样也是这个原理.
我们日常使用的 RGB 图片是 24 bit 的, 每个像素点有RGB 3 个颜色通道, 每个通道 8 bit 取值范围 0-255, 共 1600 万多种颜色. 而 gif 是只允许最大 8 bit, 也就是一共 256 种颜色来渲染每帧图像, 那么不可避免就会丢失很多颜色细节. 自然也就需要抖动算法来解决这个问题了.
刚才提到的多少种基础颜色, 一般术语上称为调色板(Palette), 也有人翻译为颜色表. 因此, 抖动算法整体上需要两步 (two-pass): 获取合适的调色板 + 颜色抖动算法.
3.1 调色板
有限的颜色数量下, 使用合适的调色板对画质影响是显著的.
调色板制定方案可分为两种: (1) 每帧图像使用不同的调色板, 画质更优; (2) 全局调色板, 所有帧均采用统一的调色板, 体积更小
ffmpeg 中 palettegen 选项下提供了很多参数来生成调色板(文档)
首先是 max_colors, reserve_transparent, transparency_color. max_colors 指定了调色板用多少种颜色, reserve_transparent 指定是否为上面提到的透明度算法预留一个透明色. 默认 max_colors=256, reserve_transparent=on, 也就是选用 255 种颜色, 最后一种颜色留给透明. 这里透明也可以通过 transparency_color 指定具体哪种颜色.
生成 palette 的 ffmpeg 命令:
# 使用默认参数, 等效于 ffmpeg -i demo.mp4 -vf "fps=10,scale=480:-1:flags=lanczos,palettegen=max_colors=256:reserve_transparent=on" palette.png
$ ffmpeg -i demo.mp4 -vf "fps=10,scale=480:-1:flags=lanczos,palettegen" palette.png
# 只用 100 种颜色 (最后一种为透明)
$ ffmpeg -i demo.mp4 -vf "fps=10,scale=480:-1:flags=lanczos,palettegen=max_colors=100" palette_100color.png
因为本来颜色数量就少的可怜, 因此使用更好的 lanczos 插值而不是默认的双线性插值.
显示效果如下, 右下角为预留的透明色.
另外一个比较重要的参数是 stats_mode, 它指定了在统计颜色分布直方图的时候要考虑的区域, 可选full, diff 和 single. full 和 diff 用于生成全局调色板, single 用于给每一帧生成单独调色板. full 模式下, 视频中所有的像素都会拿来进行色彩分布统计, diff 模式下则只考虑下一帧中不同于上一帧的像素. 默认采用 full 模式. 因此, 在有限的颜色下, diff 模式显然更偏好运动区域.
该 gif 的特点是静态背景下文字在运动. 放大看某个运动时刻:
full 模式下, 由于静止不动的背景占据大部分, 因此背景的天空明显获得了更多的偏重, 运动的字体则因占据一小部分颜色空间导致显示效果偏差. diff 模式则相反, 字体更细腻了, 天空就丢失了些细节.
因此, 在控制 gif 质量时, 调色板就值得微调下. 适当减少一点颜色数量并不会带来明显的画质下降. 再根据是否需要更加关注运动物体, 来决定是使用 full 还是 diff 模式.
3.2 抖动算法
在获得调色板后, ffmpeg 中提供了 paletteuse 选项来调整抖动算法. 参考文档.
paletteuse 下最重要的参数是 dither, 指定抖动算法的名称. ffmpeg 中实现的抖动算法有 5 种: bayer, heckbert, floyd_steinberg, sierra2, sierra2_4a. 五种算法中, bayer 称为有序抖动 (Ordered Dithering), 剩下的称为误差扩散抖动 (Diffusing Dithering).
bayer 算法, 简单说, 就是将图像划分成一堆 n*n 的小格子, 每个格子内根据抖动参考表卡阈值赋值. 这种方法简单, 速度快, 能避免颜色带效应(color banding, 当关闭 dither 的时候就看到明显的颜色带), 但是会产生规律的棋盘格伪影. 本文一开始使用 ffmpeg default 生成的 gif 显然就是用了 bayer 算法.
误差扩散算法, 简单说, 就是计算当前参考点的实际颜色和调色板中最接近的颜色的色差, 按某些规则扩散到周围像素点. 比如 floyd_steinberg 算法的伪代码就很简单:
for each y from top to bottom do
for each x from left to right do
oldpixel := pixels[x][y]
newpixel := find_closest_palette_color(oldpixel)
pixels[x][y] := newpixel
quant_error := oldpixel - newpixel
pixels[x + 1][y ] := pixels[x + 1][y ] + quant_error × 7 / 16
pixels[x - 1][y + 1] := pixels[x - 1][y + 1] + quant_error × 3 / 16
pixels[x ][y + 1] := pixels[x ][y + 1] + quant_error × 5 / 16
pixels[x + 1][y + 1] := pixels[x + 1][y + 1] + quant_error × 1 / 16
使用 bayer 算法时, 可以给 ffmpeg 传递参数 bayer_scale, 取值范围 0 - 5, 默认为 2. 数值越小, 棋盘格越明显, 但是颜色带伪影会更少.
另外一个参数是 diff_mode, 默认为 none, 可设置为 rectangle. 打开的意思是抖动的时候只限制在运动的区域, 这个参数没太懂, 不过作者的意思是一般建议打开, 体积会稍微小一点, 但我发现其实差别很不明显.
最后一个参数是 new, 设置为 on 表示输出时每帧都使用新的调色板. 3.1 小节设置 stats_mode=single 后再设定 new=on 就可以每帧都使用自己单独的调色板了. 如果 stats_mode 不是 single 这里设定 new=on, 还是使用全局调色板.
使用方法, 以 bayer_scale = 2 的 bayer 算法为例:
ffmpeg -i demo.mp4 -i palette.png -lavfi "fps=10,scale=480:-1:flags=lanczos [x]; [x][1:v] paletteuse=dither=bayer:bayer_scale=2" out.gif
注意到这里是同时输入原始视频文件和调色板图像, 因此用 -lavfi 来接受多个输入流, 第一个视频流经过降低 fps 和分辨率后命名为 x, 和第二个输入调色板一起输送给 paletteuse. 设置 dither=none 可关闭抖动算法.
原图效果: 大小 31.82 KB
关闭抖动 + 各种抖动算法:
dither = none 时出现了明显的颜色带效应, 因为相邻区域颜色相近, 都划分到了同一个颜色.
4. 高质量 GIF 生成
回到开始, 尝试把凯亚舞剑的 demo.mp4 生成高质量的 gif. 同样还是 fps=10, lanczos 插值到 480P 分辨率.
(1) 默认配置 + 关掉抖动
ffmpeg -i demo.mp4 -vf "fps=10,scale=480:-1:flags=lanczos,split[split1][split2];[split1]palettegen[pal];[split2][pal]paletteuse=dither=none" demo_dither_none.gif
参数解释: 前面为了 debug 方便单独存储了调色板图片后再作为第二步的输入. 这里直接用 ffmpeg filter 一步到位. -vf 后面用逗号隔开各个选项组, 每个选项组里面多个参数用冒号分隔. 先把视频降低 fps 和分辨率后再进行两步法生成 gif. split[split1][split2] 表示将输入分割为两个流并命名为 split1 和 split2. split1 用于生成调色板, 输入给 palettegen 得到名为 pal 的调色板输出. split2 视频流 + pal 输入给 paletteuse 得到最终 gif 输出.
可以看到, 在背景的天空颜色平滑过渡区域上有明显的颜色带.
(2) 打开抖动
这里优化的点就比较多了. 我们可以写成通用的脚本 gifgen.sh:
set -e
# global filter
fps=10
scale=480:-1
interpolation=lanczos
# for palettegen
max_colors=256 # up to 256
reserve_transparent=on
stats_mode=full # chosen from [full, diff, single]
# for paletteuse
dither=bayer # chosen from [bayer, heckbert, floyd_steinberg, sierra2, sierra2_4a, none]
bayer_scale=3 # [0, 5]. only works when dither=bayer. higher means more color banding but less crosshatch pattern and smaller file size
diff_mode=rectangle # chosen from [rectangle, none]
new=off # when stats_mode=single and new=on, each frame uses different palette
ffmpeg -i $1 -vf "fps=$fps,scale=$scale:flags=$interpolation,split[split1][split2];[split1]palettegen=max_colors=$max_colors:reserve_transparent=$reserve_transparent:stats_mode=$stats_mode[pal];[split2][pal]paletteuse=dither=$dither:bayer_scale=$bayer_scale:diff_mode=$diff_mode:new=$new" -y $2
使用: 修改相关参数后,
./gifgen.sh demo.mp4 res.gif
比如这里我设定 max_colors=150, stats_mode=single, dither=sierra2, diff_mode=rectangle, new=on 得到如上自定义输出, 大小 1.74 MB, 体积上比 ezgif 默认配置输出稍小, 却在画质上差不多甚至有些细节处理得更好(仔细看天上的绿色点状噪点, 右侧更好)
一个有趣的点: 在有些视频上, stats_mode=single, new=off 得到的视频体积是最小的, 不过画质可能变差.
通过工具 gifsicle 可以查看 gif 的一些信息, 也可以做一些后处理. 这里我们查看二者的信息差异.
查看压缩信息对比:
gifsicle --sinfo input.gif
. ezgif 是全局调色板, 自定义生成的是局部调色板. 同时, 自定义生成的每帧大小确实小一些.$ gifsicle --sinfo ezgif_default.gif
* ezgif_default.gif 32 images
logical screen 480x270
global color table [256]
background 0
loop forever
+ image #0 480x270 transparent 0
compressed size 68020
comment Created with ezgif.com video to GIF converter
local color table [256]
disposal asis delay 0.10s
+ image #1 480x270 transparent 0
compressed size 74188
disposal asis delay 0.10s
... 省略 ...
$ gifsicle --sinfo new_res.gif
* new_res.gif 32 images
logical screen 480x270
global color table [256]
background 0
loop forever
+ image #0 480x270 transparent 0
compressed size 53187
local color table [256]
disposal asis delay 0.10s
+ image #1 480x270 transparent 0
compressed size 58854
local color table [256]
disposal asis delay 0.10s
... 省略 ...
综合下来, 生成高质量的 gif 文件其实也不算复杂, 无非是在调色板上做点微调, 再根据肉眼感官判定斟酌就好. 可惜现有的很多第三方 gif 软件处理得尤为糟糕, 往往体积大质量还差. ezgif 是一个非常棒的 gif 处理网站, 强烈推荐. 如果要进一步压缩文件体积, 牺牲一点画质, ffmpeg + gifsicle 搭配也能达到非常好的效果 (比如文章一开始的 gif 在不改变分辨率情况下压缩到 600 KB 还是比较容易的). 不过话说回来, 相比于视频编码, gif 编码属实有些落后, 或许该遗弃了.
参考资料: