我们现在说的GIF图片全称是Graphics Interchange Format,是一种256色的图片压缩(LZW协议)文档,主要用来存储动态图片。GIF图片有两种格式分布是1987年的GIF87a和1989年GIF89a版本。
其中GIF89a扩展了图形控制区块、备注、说明、应用程序接口等四个区块,并提供了对透明色和多帧动画的支持。这也是我们现在最广为使用的协议。
GIF89a 设定部分属性如下图所示
1995年Netscape 为了让GIF可以更好的支持动画和视频剪辑,GIF89a又增加了图像控制扩展功能(Graphics Control Extension (GCE)),每个frame的图片都可以添加GCE部分,用来描述frame之间的延迟时间和循环次数。
上图中 31D行,定义的就是重复播放次数。
所以,如果你的GIF动画不循环播放,一种可能是GCE设置成了1就是只播放一次;第二种可能是你的GIF动画中没有GCE部分。要是GIF动画没有GCE,那就要看播放GIF动画的工具是怎么处理默认逻辑了,有可能只播放一次,也有可能循环播放。
网上制作循环GIF图一般使用PS软件,我这里补充一段将不循环的GIF图转为循环的Python代码,对于程序员们更友好和便捷。
这个问题是有实际场景的:知乎目前的文章只允许插入1MB大小以内的GIF图,而大多数表情包之外的动图都超出了这个大小,这时就需要对GIF的尺寸进行裁剪,或者进行抽帧。在Mac自带的预览功能里可以直接完成这两个操作,但代价就是原本循环播放的GIF变得只动一次了。使用以下代码:
from PIL import ImageSequence, Image
import imageio, os
location = "/path/to/" # 文件目录
gif_file = os.path.join(location, "origin.gif") # 输入GIF文件名
out_file = os.path.join(location, "output.gif") # 输出GIF文件名
# 这里如果想使用灰度图,可以使用`.convert("L")`代替,参考https://pillow-cn.readthedocs.io/zh_CN/latest/handbook/concepts.html#mode
frames = [frame.copy().convert("RGBA") for frame in ImageSequence.Iterator(Image.open(gif_file))]
imageio.mimsave(out_file, frames, 'GIF', duration=0.5) # duration表示两帧之间播放的间隔时间,也可不设,使用默认
再添加一些逻辑,可以将不循环的GIF图批量转为循环的。在GIF的保存时,不要使用网上流行的这种(可能会影响循环):
frames[0].save("out.gif", save_all=True, append_images=frames[1:])
另外,也可以针对知乎的这个1MB场景,配合以上代码,写一个自动的GIF压缩程序~
导语GIF(Graphics Interchange Format)原义是“图像互换格式”,是CompuServe公司在1987年开发出的图像文件格式,可以说是互联网界的老古董了。
GIF格式可以存储多幅彩色图像,如果将这些图像连续播放出来,就能够组成最简单的动画。所以常被用来存储“动态图片”,通常时间短,体积小,内容简单,成像相对清晰,适于在早起的慢速互联网上传播。
本来,随着网络带宽的拓展和视频技术的进步,这种图像已经渐渐失去了市场。可是,近年来流行的表情包文化,让老古董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文件主要由以下几部分组成。
- 文件头
- 图像帧信息
- 注释
下面我们来分别探究每个部分。
文件头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 = 24bit)表达,比如0xFFFFFF可以表示一个白色像素点,0x000000表示一个黑色像素点。
如果我们采用最原始的存储方式,把每个点的颜色值写进文件,那么我们的图像信息就要占据就是3×M×N字节,这是静态图的情况,如果一张GIF图里有K帧,点阵信息就是3×M×N×K。
下面这张兔子snowball的表情有18帧,分辨率是200×196,如果用上述方式计算,文件尺寸至少要689K。
但实际文件尺寸只有192K,它一定经历过什么……
我们可以使用命令行图片处理工具gifsicle来看看它的信息。
gifsicle -I snowball.gif > snowball.txt
我们得到下面的文本
5.gif 19 images
logical screen 200x196
global color table [128]
background 93
loop forever
extensions 1
+ image #0 200x196 transparent 93
disposal asis delay 0.04s
+ image #1 200x188 transparent 93
disposal asis delay 0.04s
........
可以看到,global color table [128]就是它的调色盘,长度128。
为了确认,我们再用二进制查看器查看一下它的文件头
可以看到Packet里的字段的确符合我们的描述。
在实际情况中,GIF图具有下面的特征
(1)一张图像最多只会包含256个RGB值。
(2)在一张连续动态GIF里,每一帧之间信息差异不大,颜色是被大量重复使用的。
在存储时,我们用一个公共的索引表,把图片中用到的颜色提取出来,组成一个调色盘,这样,在存储真正的图片点阵时,只需要存储每个点在调色盘里的索引值。
如果调色盘放在文件头,作为所有帧公用的信息,就是公共(全局)调色盘,如果放在每一帧的帧信息中,就是局部调色盘。GIF格式允许两种调色盘同时存在,在没有局部调色盘的情况下,使用公共调色盘来渲染。
这样,我们可以用调色盘里的索引来代表实际的颜色值。
一个256色的调色盘,24bit的颜色只需要用9bit就可以表达了。
调色盘还可以进一步减少,128色,64色,etc,相应的压缩率就会越来越大……
还是以兔子为例,我们还可以尝试指定它的调色盘大小,对它进行重压缩
gifsicle --colors=64 5.gif > 5-64.gif
gifsicle --colors=32 5.gif > 5-32.gif
gifsicle --colors=16 5.gif > 5-16.gif
gifsicle --colors=2 5.gif > 5-2.gif
......
依然使用gifsicle工具,colors参数就是调色盘的长度,得到的结果
注意到了2的时候,图像已经变成了黑白二值图。
居然还能看出是个兔子……
所以我们得出结论——如果可以接受牺牲图像的部分视觉效果,就可以通过减色来对图像做进一步压缩。
文件头所包含的对我们有用的信息就是这些了,我们继续往后看。
帧信息描述帧信息描述就是每一帧的图像信息和相关标志位,在逐项了解它之前,我们首先探究一下帧的存储方式。
我们已经知道调色盘相关的定义,除了全局调色盘,每一帧可以拥有自己的局部调色盘,渲染顺序更优先,它的定义方式和全局调色盘一致,只是作用范围不同
直观地说,帧信息应该由一系列的点阵数据组成,点阵中存储着一系列的颜色值。点阵数据本身的存储也是可以进行压缩的,GIF图所采用的是LZW压缩算法。
这样的压缩和图像本身性质无关,是字节层面的,文本信息也可以采用(比如常见的gzip,就是LZW和哈夫曼树的一个实现)
基于表查询的无损压缩是如何进行的?基本思路是,对于原始数据,将每个第一次出现的串放在一个串表中,用索引来表示串,后续遇到同样的串,简化为索引来存储(串表压缩法)
举一个简单的例子来说明LZW算法的核心思路。
有原始数据:ABCCAABCDDAACCDB
可以看出,原始数据里只包括4个字符A,B,C,D,四个字符可以用2bit的索引来表示,0-A,1-B,2-C,3-D。
原始字符串存在重复字符,比如AB,CC,都重复出现过。用4代表AB,5代表CC,上面的字符串可以替代表示为45A4CDDAA5DB
这样就完成了压缩,串长度从16缩减到12。对原始信息来说,LZW压缩是无损的。
除了采用LZW之外,帧信息存储过程中还采取了一些和图像相关的优化手段,以减小文件的体积,直观表述就是——公共区域排除、透明区域叠加
这是ImageMagick官方范例里的一张GIF图。
根据直观感受,这张图片的每一帧应该是这样的。
但实际上,进行过压缩优化的图片,每一帧是这样的。
首先,对于各帧之间没有变化的区域进行了排除,避免存储重复的信息。
其次,对于需要存储的区域做了透明化处理,只存储有变化的像素,没变化的像素只存储一个透明值。
这样的优化在表情包中也是很常见的,举个栗子
上面这个表情的文件大小是278KB,帧数是14
我们试着用工具将它逐帧拆开,这里使用另一个命令行图像处理工具ImageMagick
gm convert source.gif target_%d.gif
可以看出,除了第一帧之外,后面的帧都做了不同程度的处理,文件体积也比第一帧小。
这样的压缩处理也是无损的,带来的压缩比和原始图像的具体情况有关,重复区域越多,压缩效果越好,但相应地,也需要存储一些额外的信息,来告诉引擎如何渲染,具体包括
- 帧数据长宽分辨率,相对整图的偏移位置
- 透明彩色索引——填充透明点所用的颜色
- Disposal Method——定义该帧对于上一帧的叠加方式
- Delay Time——定义该帧播放时的停留时间
其中值得额外说明的是Disposal Method,它定义的是帧之间的叠加关系,给定一个帧序列,我们用怎样的方式把它们渲染成起来。
详细参数定义,可以参考该网站的范例
http://www.theimage.com/animation/pages/disposal.html
Disposal Method和透明颜色一起,定义了帧之间的叠加关系。在实际使用中,我们通常把第一帧当做基帧(background),其余帧向前一帧对齐的方式来渲染,这里不再赘述。
理解了上面的内容,我们再来看帧信息的具体定义,主要包括
- 帧分隔符
- 帧数据说明
- 点阵数据(它存储的不是颜色值,而是颜色索引)
- 帧数据扩展(只有89a标准支持)
1和3比较直观,第二部分和第四部分则是一系列的标志位,定义了对于“帧”需要说明的内容。
帧数据说明
除了上面说过的字段之外,还多了一个Interlace Flag,表示帧点阵的存储方式,有两种,顺序和隔行交错,为 1 时表示图像数据是以隔行方式存放的。最初 GIF 标准设置此标志的目的是考虑到通信设备间传输速度不理想情况下,用这种方式存放和显示图像,就可以在图像显示完成之前看到这幅图像的概貌,慢慢的变清晰,而不觉得显示时间过长。
帧数据扩展是89a标准增加的,主要包括四个部分。
1、程序扩展结构(Application Extension)主要定义了生成该gif的程序相关信息
2、注释扩展结构(Comment Extension)一般用来储存图片作者的签名信息
3、图形控制扩展结构(Graphic Control Extension)这部分对图片的渲染比较重要
除了前面说过的Dispose Method、Delay、Background Color之外,User Input用来定义是否接受用户输入后再播放下一帧,需要图像解码器对应api的配合,可以用来实现一些特殊的交互效果。
4、平滑文本扩展结构(Plain Text Control Extension)
89a标准允许我们将图片上的文字信息额外储存在扩展区域里,但实际渲染时依赖解码器的字体环境,所以实际情况中很少使用。
以上扩展块都是可选的,只有Label置位的情况下,解码器才会去渲染
需求场景——给表情包减负说完了基本原理,来分析一下我们的实际问题。
给大量表情包生成缩略图,在不损耗原画质的前提下,尽可能减少图片体积,节省用户流量。
之前说过,单纯依靠resize大法不能满足我们的要求,没办法,只能损耗画质了,主要有两个思路,减少颜色和减少帧数。
减少颜色——图片情况各异,标准难以控制,而且会造成缩略图和原图视觉差异比较明显
减少帧数——通过提取一些间隔帧,比如对于一张10帧的动画,提取其中的提取1,3,5,7,9帧。来减少图片的整体体积,似乎更可行。
先看一个成果,就拿文章开头的图做栗子吧
看上去连贯性不如以前,但是差别不大,作为缩略图的视觉效果可以接受,由于帧数减小,体积也可以得到明显的优化。体积从428K缩到了140K
但是,在开发初期,我们尝试暴力间隔提取帧,把帧重新连接压成新的GIF图,这时,会得到这样的图片。
主要有两个问题。
1、帧数过快
2、能看到明显的残留噪点
分析我们上面的原理,不难找到原因,正是因为大部分GIF存储时采用了公共区域排除和透明区域叠加的优化,如果我们直接间隔抽帧,再拼起来,就破坏了原来的叠加规则,不该露出来的帧露出来了,所以才会产生噪点。
所以,我们首先要把原始信息恢复出来。
两个命令行工具,gifsicle和ImageMagick都提供这样的命令。
gm convert -coalesce source.gif target_%d.gif
gifsicle --unoptimize source.gif > target.gif
还原之后抽帧,重建新的GIF,就可以解决问题2了。
注意重建的时候,可以应用工具再进行对透明度和公共区域的优化压缩。
至于问题1,也是因为我们没有对帧延迟参数Delay Time做处理,直接取原帧的参数,帧数减少了,速度一定会加快。
所以,我们需要把抽去的连续帧的总延时加起来,作为新的延迟数据,这样可以保持缩略图和原图频率一致,看起来不会太过鬼畜,也不会太过迟缓。
提取出每一帧的delay信息,也可以通过工具提供的命令来提取。
gm identify -verbose source.gif
gifsicle -I source.gif
在实际应用中,抽帧的间隔gap是根据总帧数frame求出的
frame<8 gap=1
9<frame<20 gap=2
21<frame<30 gap=3
31<frame<40 gap=4
frame>40 gap=5
delay值的计算还做了归一化处理,如果新生成缩略图的帧间隔平均值大于200ms,则统一加速到均值200ms,同时保持原有节奏,这样可以避免极端情况下,缩略图过于迟缓。
具体实现本文介绍的算法主要应用于手Q热图功能的后台管理系统,使用Nodejs编写。
ImageMagick是一个较为常用的图像处理工具,除了gif还可以处理各类图像文件,有node封装的版本可以使用。
gifsicle只有可执行版本,在服务器上重新编译源码后,采用spawn调起子进程的方式实现。
ImageMagick对于图片信息的解析较为方便,可以直接得到结构化信息。
gifsicle支持命令管道级联,处理图片速度较快。
实际生产过程中,同时采用了两个工具。
const {spawn} = require('child_process');const image = gm("src2/"+file)
image.identify((err, val) => { if(!val.Scene){
console.log(file+" has err:"+err) return
} let frames_count = val.Scene[0].replace(/\d* of /, '') * 1
let gap = countGap(frames_count) let delayList = []; let totaldelay = 0
if(val.Delay!=undefined){ let i for (i = 0; i < val.Delay.length; i ++) {
delayList[i] = val.Delay[i].replace(/x\d*/, '') * 1
totaldelay+=delayList[i]
} for (; i < val.Scene.length; i ++) {
delayList[i] = 8
totaldelay+=delayList[i]
}
}else{ for (let i = 0; i < val.Scene.length; i ++) {
delayList[i] = 8
totaldelay+=delayList[i]
}
} let totalFrame = parseInt(frames_count/gap) //判断是否速度过慢,需要进行归一加速处理
if(totaldelay/totalFrame>20){ let scale =(totalFrame*1.0*20)/totaldelay for (let i = 0; i < delayList.length; i ++) {
delayList[i] = parseInt(delayList[i] * scale)
}
} let params=[] params.push("--colors=255") params.push("--unoptimize") params.push("src2/"+file) let tempdelay = delayList[0] for (let i = 1; i < frames_count; i ++) { if(i%gap==0){ params.push("-d"+tempdelay) params.push("#"+(i-gap))
tempdelay=0
}
tempdelay += delayList[i]
} params.push("--optimize=3") params.push("-o") params.push("src2/"+file+"gap-keepdelay.gif")
spawn("gifsicle", params, { stdio: 'inherit' })
})
测试时,采用该算法随机选择50张gif图进行压缩,原尺寸15.5M被压缩到6.0M,压缩比38%,不过由于该算法的压缩比率和具体图片质量、帧数、图像特征有关,测试数据仅供参考。
本文到这里就结束了,原来看似简单的表情包,也有不少文章可做。
谢谢观看,希望文中介绍的知识和研究方法对你有所启发。
You Don't Know Gif - 分析 gif 文件和一些奇怪的 gif 功能发表 - 2022-01-13 | 18分钟
是的,我指的是您可以在大型网站(如 Google 拥有的Tenor或 Facebook 拥有的 giphy )上找到的主流常见 gif 。每个人都喜欢的用于共享动画短片的文件格式。
大多数人都知道的gif大多数人都知道 gif,gif 是一种动画文件格式。您可能看过 gif 文件并认为这些文件非常大。也许你看着它们会想:哇,这些图片清晰度很低。但归根结底,当您想到 gif 时,您可能会将其视为短动画文件格式。
然而,这个用例与编写 gif 的人们期望它的用途截然不同。在这篇文章中,我们将深入剖析 gif 文件,并在此过程中讨论它的一些更时髦的特性。
请注意,这篇文章应该是对如何理解 gif 格式及其一些更深奥的功能的有趣探索。如果您想真正学习如何解析 gif,我会推荐以下资源:
- W3 规范
- Matthew Flickinger 的:gif 中有什么?
- 我发现这个来自 ntfs.com 的指南对早期部分也很有帮助
在这段时间里,我实际上使用这些资源制作了一个几乎不兼容的 gif 解析器,这些资源称为awesome-gif,它将解析一些 gif。我不建议使用它。
反正上贴。
gif的历史gif 文件格式由 Compuserve 于 1987 年创建。早在 1987 年,gif 是一种相当紧凑的格式!它使用压缩,而不仅仅是任何压缩,而是 LZW 压缩。许多较旧的文件格式(一些由 Compuserve 制作)使用 RLE(运行长度编码),在许多情况下效率不高。gif 的一大成功因素是其稳定的压缩比和良好的色域(完整的 256 色,哇!)。1
两年后,创建了 gif 文件格式的附录 (gif89a),其中添加了许多我们今天知道和喜爱的功能。
通过 gif89a 规范,我们可以快速总结 gif89 与 gif87a 支持的所有功能。
Appendix A. Quick Reference Table. Block Name Required Label Ext. Vers. Application Extension Opt. (*) 0xFF (255) yes 89a Comment Extension Opt. (*) 0xFE (254) yes 89a Global Color Table Opt. (1) none no 87a Graphic Control Extension Opt. (*) 0xF9 (249) yes 89a Header Req. (1) none no N/A Image Descriptor Opt. (*) 0x2C (044) no 87a (89a) Local Color Table Opt. (*) none no 87a Logical Screen Descriptor Req. (1) none no 87a (89a) Plain Text Extension Opt. (*) 0x01 (001) yes 89a Trailer Req. (1) 0x3B (059) no 87a Unlabeled Blocks Header Req. (1) none no N/A Logical Screen Descriptor Req. (1) none no 87a (89a) Global Color Table Opt. (1) none no 87a Local Color Table Opt. (*) none no 87a Graphic-Rendering Blocks Plain Text Extension Opt. (*) 0x01 (001) yes 89a Image Descriptor Opt. (*) 0x2C (044) no 87a (89a) Control Blocks Graphic Control Extension Opt. (*) 0xF9 (249) yes 89a Special Purpose Blocks Trailer Req. (1) 0x3B (059) no 87a Comment Extension Opt. (*) 0xFE (254) yes 89a Application Extension Opt. (*) 0xFF (255) yes 89a legend: (1) if present, at most one occurrence (*) zero or more occurrences (+) one or more occurrences
对于以前没有阅读过整个规范的人来说,其中大部分内容都是胡言乱语,所以让我们讨论一下 gif 是如何组合在一起的,我们将在此过程中讨论它的一些奇怪之处。
在我们从规范开始之前有一些乐趣:
Appendix D. Conventions. Animation - The Graphics Interchange Format is not intended as a platform for animation, even though it can be done in a limited way.
无论如何,让我们开始吧;)
gif的解剖结构我将通过一个示例来完成此操作,因此,如果您想继续学习,请随意!右键单击并下载,一切顺利。
2
如果您在家里跟随,您所需要的只是一台安装了 hexdump 工具的机器。我将使用预装在大多数 unix(Linux、macOS)上的 xxd,或者可以与包 vim-common一起安装。
gif 标题每个 gif 都以一个标题开头,其中的魔法位表示它是什么类型的 gif,以及一些额外的信息,提供有关图像的基本细节。