初探 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 的片段作为演示片段, 因为人物动作幅度较大, 背景天空有渐变过渡色, 一旦发生颜色失真就比较明显, 非常适合演示.
原神凯亚舞剑动作, demo.mp4 (~ 3 MB)
>> 效果对比: 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 也是调整为上述参数后, 直接输出, 不加压缩后处理:
notion image
二者对比效果如下:
ffmpeg default 输出, 1.39 MB
ffmpeg default 输出, 1.39 MB
ezgif default 输出, 2.25 MB
ezgif default 输出, 2.25 MB
 
可以看到, ffmpeg default 体积更小, 但失真明显, 出现了常见的棋盘格/网状光栅效应 (crosshatch pattern). ezgif 体积稍大, 但颜色细节好太多, 只在个别地方出现了失真(仔细看舞剑且镜头剧烈晃动时背景天空出现了一些青色噪点). 显然, ezgif 输出的结果就符合我上面提到的高质量 GIF 要求.
那么 ezgif 的效果是如何实现的呢? 下面我将讲述如何用 ffmpeg 命令行实现近似的效果.

2. 透明度优化

和视频编解码中的运动向量 (motion vector) 类似, gif 编码中有个非常重要的算法叫透明度优化. 相邻两帧之间其实有很多色彩重复的区域, 因此在保存下一帧时没必要所有信息全部保存, 可以只保存像素发生差异的区域, 剩下的区域设置为透明, 直接从上一帧取过来补充上就好.
notion image
因此, 上图的 gif 使用透明度优化可以像下面这样保存帧信息(仅做示例, 具体有很多不同的实现算法). 这样省去了重复的色彩信息, 节省了可观的空间.
notion image
根据 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
ffmpeg_default_frames
开启透明度优化
ffmpeg_default_frames 开启透明度优化
ffmpeg_default_frames_no_transdiff
关闭透明度优化
ffmpeg_default_frames_no_transdiff 关闭透明度优化
透明度优化算法的优势在于, 在基本不改变画质的情况下, 对于背景大面积静止不动的样本, 压缩效果非常显著. 因此建议是采用默认设置打开的. 具体的实现算法可以参考 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 插值而不是默认的双线性插值.
显示效果如下, 右下角为预留的透明色.
palette.png
palette_100color.png
palette_100color.png
另外一个比较重要的参数是 stats_mode, 它指定了在统计颜色分布直方图的时候要考虑的区域, 可选full, diff 和 single. full 和 diff 用于生成全局调色板, single 用于给每一帧生成单独调色板. full 模式下, 视频中所有的像素都会拿来进行色彩分布统计, diff 模式下则只考虑下一帧中不同于上一帧的像素. 默认采用 full 模式. 因此, 在有限的颜色下, diff 模式显然更偏好运动区域.
下面从 ffmpeg gif 的一位开发者 ubitux博文中摘录两种模式下的效果对比图.
mode=full 的 gif 效果
mode=full 的 gif 效果
mode=diff 的 gif 效果
mode=diff 的 gif 效果
该 gif 的特点是静态背景下文字在运动. 放大看某个运动时刻:
mode = full 下某时刻放大显示
mode = full 下某时刻放大显示
mode = diff 下某时刻放大显示
mode = diff 下某时刻放大显示
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 可关闭抖动算法.
还是摘录 ubitux博文 中各种抖动算法的效果对比.
原图效果: 大小 31.82 KB
原图, 31.82 K
原图, 31.82 K
关闭抖动 + 各种抖动算法:
dither=none (73.10 K)
dither=none (73.10 K)
dither=bayer:bayer_scale=1 (132.80K)
dither=bayer:bayer_scale=1 (132.80K)
dither=bayer:bayer_scale=2 (118.80K)
dither=bayer:bayer_scale=2 (118.80K)
dither=bayer:bayer_scale=3 (103.11K)
dither=bayer:bayer_scale=3 (103.11K)
dither=floyd_steinberg (101.78K)
dither=floyd_steinberg (101.78K)
dither=sierra2 (89.98K)
dither=sierra2 (89.98K)
dither=sierra2_4a (109.60K)
dither=sierra2_4a (109.60K)
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 输出.
demo_dither_none.gif, 1.56 MB
demo_dither_none.gif, 1.56 MB
可以看到, 在背景的天空颜色平滑过渡区域上有明显的颜色带.
(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
ezgif default 输出, 2.25 MB
ezgif default 输出, 2.25 MB
gifgen 自定义命令输出, 1.74 MB
gifgen 自定义命令输出, 1.74 MB
比如这里我设定 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 编码属实有些落后了, 或许该遗弃了.
 

 
 
参考资料:
浓缩的才是精华:浅析 GIF 格式图片的存储和压缩
个人介绍, 在Web前端摸爬滚打的码农一枚,对技术充满热情的菜鸟,致力为手Q的建设添砖加瓦。 GIF ( Graphics Interchange Format )原义是"图像互换格式",是 CompuServe 公司在1987年开发出的图像文件格式,可以说是互联网界的老古董了。 GIF 格式可以存储多幅彩色图像,如果将这些图像(( https://www.qcloud.com/document/product/460/6925?fromSource=gwzcw.59167.59167.59167)连续播放出来,就能够组成最简单的动画。所以常被用来存储"动态图片",通常时间短,体积小,内容简单,成像相对清晰,适于在早起的慢速互联网上传播。 本来,随着网络带宽的拓展和视频技术的进步,这种图像已经渐渐失去了市场。可是,近年来流行的表情包文化,让老古董 GIF 图有了新的用武之地。 表情包通常来源于 手绘图像,或是 视频截取 ,目前有很多方便制作表情包的小工具。 这类图片通常具有文件体积小,内容简单,兼容性好(无需解码工具即可在各类平台上查看),对画质要求不高的特点,刚好符合 GIF 图的特性。 所以,老古董 GIF 图有了新的应用场景。 新的应用场景带来新的需求,本文所探究的问题来自于某个业务场景下--为用户批量推送GIF表情包。 一批图像大约有200-500张,以缩略图列表的形式展示在客户端。 根据我们使用测试数据进行的统计 GIF 图表情包的尺寸大部分在200k-500k之间,批量推送的一个重要问题就是数据量太大,因此,我们希望能够在列表里展示体积较小的缩略图,用户点击后,再单独拉取原图。 传统的 GIF 缩略图是静态的,通常是提取第一帧,但在表情包的情形下,这种方式不足以表达出图片中信息。比如下面的例子 第一帧完全看不出重点啊! 所以,我们希望缩略图也是动态的,并尽可能和原图相似。 对于传统图片来说,文件大小一般和图片分辨率(尺寸)正相关,所以,生成缩略图最直观的思路就是缩小尺寸,resize大法。 但是在 GIF 图的场合,这个方式不再高效,因为 GIF 图的文件大小还受到一个重要的因素制约-- 帧数 以这张柴犬表情为例,原图宽度200,尺寸1.44M,等比缩放到150之后,尺寸还是1.37M,等比缩放到100,相当于尺寸变为原来的四分之一,体积还是749K 可见,resize大法的压缩率并不理想,收效甚微。 而且,我们所得到的大部分表情图素材,分辨率已经很小了,为了保证客户端展示效果,不能够过度减少尺寸,不然图片会变得模糊。 所以,想要对GIF图进行压缩,只能从别的方向入手。 想要压缩一个文件,首先要了解它是如何存储的。毕竟,编程的事,万变不离其宗嘛。 作为一种古老的格式,GIF的存储规则也相对简单,容易理解,一个GIF文件主要由以下几部分组成。 下面我们来分别探究每个部分。 GIF格式文件头和一般文件头差别不大,也包含有: 格式声明 Signature 为"GIF"3 个字符;Version 为"87a"或"89a"3 个字符。 逻辑屏幕描述块 前两字节为像素单位的宽、高,用以标识图片的视觉尺寸。 Packet里是调色盘信息,分别来看: Global Color Table Flag为全局颜色表标志,即为1时表明全局颜色表有定义。 Color Resolution 代表颜色表中每种基色位长(需要+1),为111时,每个颜色用8bit表示,即我们熟悉的RGB表示法,一个颜色三字节。 Sort Flag 表示是否对颜色表里的颜色进行优先度排序,把常用的排在前面,这个主要是为了适应一些颜色解析度低的早期渲染器,现在已经很少使用了。 Global Color Table 表示颜色表的长度,计算规则是值+1作为2的幂,得到的数字就是颜色表的项数,取最大值111时,项数=256,也就是说GIF格式最多支持256色的位图,再乘以Color Resolution算出的字节数,就是调色盘的总长度。 这四个字段一起定义了调色盘的信息。 Background color Index 定义了图像透明区域的背景色在调色盘里的索引。 Pixel Aspect Ratio 定义了像素宽高比,一般为0。 什么是 调色盘 ?我们先考虑最直观的图像存储方式,一张分辨率M×N的图像,本质是一张点阵,如果采用Web最常见的RGB三色方式存储,每个颜色用8bit表示,那么一个点就可以由三个字节(3BYTE = ...
 

 

© wizyoung 2021 - 2022 - Build with Next.js & Notion