Glide库里,藏了一套你心心念念的GIF压缩工具集
移动端的图片压缩是一个老生常谈的话题,也曾涌现过不少诸如Luban之类的优秀的图片压缩工具库,但在GIF图像领域的压缩方案却几乎处于一片空白。
许多开发者不知道的是,实际上,已经有一套现成的GIF图像压缩工具集,就内置在你集成的Glide图片加载框架之中。
大家好,我是潜伏于各大群中收集GIF表情包的星际码仔,今天我们要分享的是 移动端的GIF图像压缩方案 。
我们会从GIF图像的基础知识出发,介绍几种常见的GIF图像压缩策略,然后利用Glide框架内部自带的压缩工具集来实现。
过程中如有不合理的地方,欢迎随时"Objection!"。
照例,奉上思维导图一张:
GIF图像基础知识
GIF的全称是 Graphics Interchange Format ,即 图像交换格式 ,是CompuServe公司为支持彩色图像的下载,于1987年推出的位图图像格式。
GIF采用 Lempel-Ziv-Welch (LZW)无损数据压缩技术进行压缩,可以 在不降低视觉质量的情况下减少文件大小 。
凭借其 体积小、成像相对清晰 的优点,GIF在带宽小、传输慢的互联网初期广受欢迎。发展至今,以其被大多数主流平台所支持的 高兼容性 ,占据了动图格式的大半片江山。
256色
作为一种古老的位图图像格式,GIF的缺点也很明显,比如 仅支持8 bit的色深,每个像素最多只能显示2^8=256种颜色 。
相比之下,因LZW算法专利问题而被设计出来替代GIF的PNG格式,即使是不带透明度的24 bit格式,最多也可显示2^24=1600多万种颜色。
256色的限制大大局限了GIF的应用范围,使得GIF 只适用于包含少量颜色的图片 ,比如Logo、卡通人物等,而在色彩丰富甚至带有渐变效果的图片上则表现不佳,常常会使图片伴有明显的噪点失真。
动效
GIF通过 将多张图像存储在同一个文件中,并利用人眼视觉残留的特性,控制连续播放的间隔,以实现简单的动画效果 ,原理上有点类似于小时候玩过的手翻书。
同样,因受256色的限制, GIF动图大多只能用于小型的动画和低分辨率的视频 。
不过即便如此,相比于静态图片,GIF动图显然能传递更多的信息,并使沟通双方的情感交流更加直接、高效,因而得以在社交软件上被广泛使用和传播,近年来流行的表情包文化就是很好的佐证。
调色盘
GIF文件有一个很重要的概念就是 调色盘 ,个人认为调色盘这个名称用得很恰当,可以说高度概括了其特征。
那什么是调色盘呢?
前面我们讲了,GIF是一种 位图图像格式 ,关于位图的特征,我们在 《Bitmap——Android内存刺客》 一文中已经有过介绍。简单讲,位图就是由若干个不同颜色的像素进行排列所构成的像素阵列。
另外我们知道,GIF动图实际就是连续播放的多张图像,每张图像称为一帧,帧与帧之间的信息差异不大,其中的颜色是被大量重复使用的。
于是我们可以建立这样一张公共的索引表, 把每一帧的像素点所用到的颜色提取出来,组成一个调色盘,并为每个颜色值建立索引 。
这样,在存储真正的像素阵列时, 只需要存储每个颜色在调色盘里的对应索引值即可,从而减少存储的信息量 。
如果把调色盘放在文件头,作为所有帧公用的信息,就是 全局调色盘 ;而如果放在每一帧的帧信息中,就是 局部调色盘 。
GIF允许两种调色盘同时存在,并且局部调色盘的优先级更高,当没有局部调色盘时,就使用公共调色盘渲染。
很明显, 颜色越丰富,调色盘也就越大,并最终影响到GIF文件的大小 。
文件大小
以我们最为熟悉的表情包为例,GIF动图类型的表情包的来源大致可分为 手绘卡通图像 以及 视频片段截取 两种。
手绘卡通图像的线条单调,颜色均匀,人物动作简单,因而调色盘大小与图像帧数往往都不大。
而视频片段截取之后转换的GIF,往往保留了原有视频的高帧数,且视频内容本身包含了大量的颜色细节,很容易就占满整个调色盘的大小。
这也是视频片段截取的GIF文件大小往往比手绘卡通图像的大很多的原因所在。
GIF图像压缩策略
GIF文件过大,对于如何存储和传输都是一个难题,下面就来介绍一下几种常见的GIF图像压缩策略:
缩放
作为一种位图图像格式,GIF文件的大小是跟分辨率呈正相关的,分辨率越高,所包含的像素个数就越多,图像也就越清晰,但相应的文件体积也就越大。
鉴于我们通常是在一个有限的展示区域内显示GIF图像的,因此更合理点的做法应该是 先对原始的GIF图像先进行一轮下采样,以提供一个较低分辨率版本的缩略图,减少内存占用,再贴合展示区域的尺寸进行一轮精确的缩放 。
减色
减色也就是 减少调色盘的颜色 ,同样可以达到压缩的效果。但是GIF本身仅支持的最高256色已经是捉襟见肘了,再进一步减色,可能会使图像质量的损失更加明显。而且这种方式的压缩率也比较低,减去一半颜色也可能只压缩10%左右。
以下是分别将调色盘的颜色减少至64色、16色和2色的效果:
可以看到,随着调色盘颜色的减少,图片逐渐暗淡,颜色过渡也愈加粗糙,到最后甚至只剩下黑白两色。
抽帧
前面讲过,GIF是通过逐帧播放单幅图像以达到连续动画的效果的。而抽帧,顾名思义,就是 从这些图像中每间隔一定的帧数抽取出单幅图像,通过降低帧率以达到降低GIF文件整体大小的效果 。
比如电影的常见帧率为24fps(帧每秒),截取其中的3秒并转换为GIF后,帧率依旧保持在24fps,那么总共要储存72幅图像;如果通过抽帧,将帧率降到12fps,就只要储存36幅图像就可以了。
不过,抽帧会影响到GIF动效的流畅度,因为帧率降低之后,帧与帧之间的延迟时间变长,可能会达不到人眼视觉残留特性的阈值,从而在视觉感受上会有明显的卡顿。
透明度存储
开始介绍这种方式之前,我们先来看一张GIF图:
根据直觉,我们猜想这张GIF图拆解后的每一帧应该是这样的:
然而实际上,每一帧是这样的:
也就是说,透明度存储这种方式是通过只完整保留GIF的第一帧,排除后续帧没有变化的区域,只存储有变化的像素,而对于没变化的像素只存储一个透明值,从而避免存储重复的信息来达到压缩的效果的,适合GIF图像本身具有较大的静态区域的情况。
今天利用Glide框架内部自带的压缩工具集来实现的,主要是前面的三种压缩策略。
GIF图像压缩工具集
终于讲到正题了,让我们来看Glide框架内部都自带了哪些GIF压缩工具:
-
GifHeader
:GIF文件头。包含了GIF动图的帧数与每个独立帧的宽高等基本元数据,用于解码GIF。
-
StandardGifDecoder
:GIF解码器。从 GIF 图像源读取帧数据,并将其解码为独立的帧。
-
AnimatedGifEncoder
:GIF编码器。编码由一个或多个帧组成的 GIF 文件。
核心的类其实就以上几个。严格来讲,这一套GIF编解码实现类并非完全是Glide的原创,而是改编自其他开发者发布的示例开源代码,只不过为了支持GIF的编解码而内置到Glide库中而已。
GIF图像压缩步骤
接下来,我们就利用这一套压缩工具集来实现GIF压缩。
步骤1:解析GIF文件头,以获取其帧数及每个帧的源宽高
GIF格式的文件头与其他格式的文件头作用一致,都是 位于文件开头的一段数据,用于描述文件的一些重要属性,指示打开该文件的程序应该怎样处理这个文件 。
主要包含:
格式声明
- Signature 为文件类型的签名,此处为“GIF”3 个字符;
- Version 为GIF发布的版本号,可能是“87a”或“89a”。
逻辑屏幕描述块
- 前两字节用以标识GIF图像的视觉宽高,单位是像素。
- Packet fields里包含的就是全局调色盘的信息了,比如全局调色盘的大小等,这里是简单介绍,就不一一展开了。
解析GIF文件头需要用到
GifHeaderParser
类,该类负责从表示GIF动图的数据中创建
GifHeaders
类。
但实际
GifHeaderParser
类除了会解析GIF文件头外,还会读取GIF文件内容块,以获取帧数及局部调色盘等关键信息。
示例代码如下:
// 1.解析GIF文件元数据
val gifMetadataParser = GIFMetadataParser()
val gifMetadata = gifMetadataParser.parse(options.source!!)
fun parse(source: Uri): GIFMetadata {
val file = File(source.path)
gifData = ByteBufferUtil.fromFile(file)
gifHeader = parseHeader(gifData)
val duration = getDuration(gifHeader)
return GIFMetadata(
width = gifHeader.width,
height = gifHeader.height,
frameCount = gifHeader.numFrames,
duration = getDuration(gifHeader),
frameRate = getFrameRate(gifHeader.numFrames, duration),
gctSize = getGctSize(gifHeader),
fileSize = file.length()
* 解析GIF文件头
private fun parseHeader(data: ByteBuffer): GifHeader {
return GifHeaderParser().apply { setData(data) }.parseHeader()
}
步骤2:对比源宽高与目标宽高,计算出采样后只比目标宽高稍大些的样本大小
做过Bitmap内存优化工作的同学,看到样本大小(sampleSize)这个字眼是否有眼前一亮的感觉?是的,GIF解码器同样支持以2的次幂的样本大小对原始图像进行下采样,从而返回较小的图像以节省内存。
这一步样本大小的计算主要参考了Glide框架中对于Bitmap部分处理的源码思路,感兴趣的可阅读我之前写的 《Glide,你为何如此优秀?》 ,这里就不重复讲了。
// 2.解码出完整的图像帧序列,并进行下采样
val gifDecoder = constructGifDecoder(gifMetadataParser.gifHeader, gifMetadataParser.gifData, gifMetadata)
val gifFrames = gifDecoder.decode()
* 构造GIF解码器
* @param gifHeader GIF头部
* @param gifData GIF数据
* @param gifMetadata GIF元数据
private fun constructGifDecoder(
gifHeader: GifHeader,
gifData: ByteBuffer,
gifMetadata: GIFMetadata
): StandardGifDecoder {
if(context == null) throw IllegalArgumentException("Context can not be null.")
val sampleSize = calculateSampleSize(
gifMetadata.width,
gifMetadata.height,
options.targetWidth,
options.targetHeight
return StandardGifDecoder(GifBitmapProvider(Glide.get(context).bitmapPool)).apply {
setData(
gifHeader,
gifData,
sampleSize
* 计算下采样大小
* @param sourceWidth 源宽度
* @param sourceHeight 源高度
* @param targetWidth 目标宽度
* @param targetHeight 目标高度
private fun calculateSampleSize(
sourceWidth: Int,
sourceHeight: Int,
targetWidth: Int,
targetHeight: Int
): Int {
val widthPercentage = targetWidth / sourceWidth.toFloat()
val heightPercentage = targetHeight / sourceHeight.toFloat()
val exactScaleFactor = Math.min(widthPercentage, heightPercentage)
outWidth = round((exactScaleFactor * sourceWidth).toDouble())
outHeight = round((exactScaleFactor * sourceHeight).toDouble())
val widthScaleFactor = sourceWidth / outWidth
val heightScaleFactor = sourceHeight / outHeight
val scaleFactor = Math.max(widthScaleFactor, heightScaleFactor)
var powerOfTwoSampleSize = Math.max(1, Integer.highestOneBit(scaleFactor))
return powerOfTwoSampleSize
}
步骤3:顺序解码每一帧,还原为完整的图像帧序列
之所以需要这一步,主要是因为部分GIF图像采用了前面所介绍的透明度存储方式来进行压缩,如果暴力抽帧,也即跳过中间帧直接进行抽帧,则最终会得到这样的图片:
可以看到,暴力抽帧后的GIF图会有明显的残留噪点,这是因为后续帧存储的仅仅是与第一帧对比有变化的像素,所以我们要先顺序解码每一帧,借助叠加方式、透明色索引等信息来还原出完整的图像帧。
/**
* 解码出完整的图像帧序列
private fun StandardGifDecoder.decode(): List<Bitmap> {
return (0 until frameCount).mapNotNull {
advance()
nextFrame
}
步骤4:根据目标帧率进行抽帧,并重新计算帧间延迟
注意是根据目标帧率,而不是目标帧数。如果只是减少帧数,而帧间延迟保持不变,会造成GIF动效的总时长也相应变短,直观感受上就是动画明显加快了。
根据目标帧率进行抽帧,就是保持GIF动效的总时长不变,只是减少1秒内播放的图像帧数,也即减少帧率,为此需要我们重新计算帧间延迟,对播放速度进行减缓处理。
// 3.根据目标帧率进行抽帧
val gifFrameSampler = GIFFrameSampler(gifMetadata.frameRate, options.targetFrameRate)
val sampledGifFrames = gifFrameSampler.sample(gifMetadata.frameCount, gifFrames)
class GIFFrameSampler(inputFrameRate: Int, outputFrameRate: Int) {
private val inFrameRateReciprocal = 1.0 / inputFrameRate
private val outFrameRateReciprocal = 1.0 / outputFrameRate
private var frameRateReciprocalSum = 0.0
private var frameCount = 0
fun shouldRenderFrame(): Boolean {
frameRateReciprocalSum += inFrameRateReciprocal
return when {
frameCount++ == 0 -> {
frameRateReciprocalSum > outFrameRateReciprocal -> {
frameRateReciprocalSum -= outFrameRateReciprocal
else -> {
false
* 根据目标帧率进行抽帧
* @param frameCount 帧数
* @param gifFrames 图像帧序列
private fun GIFFrameSampler.sample(
frameCount: Int,
gifFrames: List<Bitmap>
): List<Bitmap> {
return (0 until frameCount).mapNotNull {
if (shouldRenderFrame()){
gifFrames[it]
} else {
}
步骤5:重新编码为GIF文件,并依照配置参数进行精确缩放和减色
了解了GIF动效的原理之后,重新编码的流程就变得很清晰了,无非就是将抽取之后的图像帧序列逐一添加回编码器,以写入必要的文件头数据以及图像的像素数据,并根据目标帧率调整帧与帧之间的延迟时间,就可以重新编码生成新的GIF图像了。
// 4.将处理后的图像帧序列重新编码
val gifEncoder = constructGifEncoder()
gifEncoder.encode(sampledGifFrames)
* 构造GIF编码器
private fun constructGifEncoder(): AnimatedGifEncoder{
return AnimatedGifEncoder().apply {
// 调整全局调色盘大小
val palSize = (Math.log(options.targetGctSize.toDouble())/Math.log(2.0)).toInt() - 1
setPalSize(palSize)
// 调整分辨率
setSize(outWidth, outHeight)
// 调整帧率
setFrameRate(options.targetFrameRate.toFloat())
* 将处理后的图像帧序列重新编码
* @param sampleFrames 抽帧后的图像帧序列
private fun AnimatedGifEncoder.encode(sampleFrames: List<Bitmap>) {
// 开始写入
start(options.sink?.path!!)
// 逐一添加帧