移动端的图片压缩是一个老生常谈的话题,也曾涌现过不少诸如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 / outWidthval heightScaleFactor = sourceHeight / outHeightval 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 / inputFrameRateprivate val outFrameRateReciprocal = 1.0 / outputFrameRateprivate var frameRateReciprocalSum = 0.0private var frameCount = 0fun shouldRenderFrame(): Boolean {frameRateReciprocalSum += inFrameRateReciprocalreturn when {frameCount++ == 0 -> {true}frameRateReciprocalSum > outFrameRateReciprocal -> {frameRateReciprocalSum -= outFrameRateReciprocaltrue}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 {null}}}

步骤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() - 1setPalSize(palSize)// 调整分辨率setSize(outWidth, outHeight)// 调整帧率setFrameRate(options.targetFrameRate.toFloat())}}
    /*** 将处理后的图像帧序列重新编码* @param sampleFrames 抽帧后的图像帧序列*/private fun AnimatedGifEncoder.encode(sampleFrames: List<Bitmap>) {// 开始写入start(options.sink?.path!!)// 逐一添加帧sampleFrames.forEach { addFrame(it) }// 完成,关闭输出文件finish()options.listener?.onCompleted()}

一个Demo

为了方便演示以上所提及策略的实际压缩效果,我写了一个GIF图像压缩前后对比的Demo,可以通过调整宽高、帧率、色彩三个属性的数值来分别实现缩放、抽帧、减色三种压缩策略:

如果这个Demo对你有帮助,希望不吝点个哈~

好了,以上就是今天要分享的内容。最后提一个问题,除了GIF,你还知道有哪些动图格式呢?欢迎在评论区或后台讨论哈~

少侠,请留步!若本文对你有所帮助或启发,还请:

  1. 点赞

    Glide库里,藏了一套你心心念念的GIF压缩工具集相关推荐

    1. 一统江湖的大前端(5)editorconfig + eslint——你的代码里藏着你的优雅

      [摘要]<一统江湖的大前端>系列是自己的前端学习笔记,旨在介绍javascript在非网页开发领域的应用案例和发现各类好玩的js库,不定期更新. 如果你对前端的理解还是写写页面绑绑事件,那 ...

    2. 《我在风衣里藏了把刀》—— 转

      那天,我在风衣里藏了把刀,因为我要杀掉一个仇人. 我非常恨她,但又不敢骂她,所以我只好选择谋杀. 她的个子不高,却是武校的高才生,我估计空手打不过她,所以得藏把刀. 她很漂亮,但从来都不看我一眼,所以 ...

    3. 科比,库里,篮球和我——一点感想

      "这是好比至亲的离去的那种感受,麻木感,是还来不及伤心的那种麻木.就像一剑刺进了你的心脏,你是感受不到疼痛的." "DEVASTATING" 看到科比离世的新闻 ...

    4. 一个潜藏在 Elixir 代码库里 7 年的性能问题

      Tubi 是一个流媒体服务平台,有成千上万的用户每天通过这个平台免费观看他们喜爱的电影.电视剧.每当一位用户按下「播放」按钮,Tubi 的视频播放器就会向一个内部服务发送 API 请求以获取视频的 m ...

    5. 冤大头?NBA球星库里花了 116 万买了一只猴头像,到底咋回事?

      上面这个公号,是我的一个备用号,为了防止万一哪天大号失联,平时一周我也会发三篇左右的我的思考,读书笔记,认知感悟等文章,带领大家一起探索精神与财务自由之路. 大家好,我是校长. 最近 NFT 非常火, ...

    6. 沙发变身遥控器,涂鸦里藏PCB,MIT技术宅的智能家居竟然是这样

      鱼羊 郭一璞 发自 凹非寺 量子位 报道 | 公众号 QbitAI 城乡结合部土味开关,不知道你有没有印象: 强行欧式,强行蕾丝,强行少女心,处处透露着改革开放早期人们对色彩的渴求. 也可能是上一辈人 ...

    7. web静态资源访问规则||webjars的访问配置——webjars是maven库里面对css js image打的一个jar包

      Html css js image  txt   web项目中 放在 Webapp 在springboot项目中  静态资源放置的位置 Springboot默认的静态资源目录 (1)在src/main ...

    8. 多少个没收到会收敛_三分历史纪录2973个,库里2483个,库里生涯结束三分会是多少个?...

      小球时代的"主旋律"是攻防转换的速度,还是位置分化的模糊,或者是中锋的凋零?都不是,小球时代的主旋律,是三分球. 三分球从21世纪的第二个10年开始,在各支球队进攻中扮演的角色越来 ...

    9. 梦之队奥运30人大名单:詹皇库里甜瓜双少领衔

      北京时间1月19日,美国男篮今天正式公布了30人大名单,由两届奥运会金牌得主勒布朗-詹姆斯和卡梅罗-安东尼领衔. 除了詹姆斯和安东尼之外,入选这份30人大名单的NBA球员还包括:阿尔德里奇(马刺).哈 ...

    10. java调用so库中的native方法_Java如何调用本地.so库里的方法

      首先在此之前希望你已经掌握了基本JNI常识的运用,比如Java代码如何调用本地native的方法,native方法如何访问本地变量,本地方法等以及其他相关的基础知识.在此我还是贴上Activity的部 ...

    最新文章

    1. 凸集 凸函数 凸优化
    2. linux防火墙 限制端口,Linux开启防火墙并限制开放端口
    3. python输出举例_python字符串格式化输出及相关操作代码举例
    4. 面试题 为什么用线程池?解释下线程池参数
    5. 汉诺塔问题递归算法python代码_[python]汉诺塔问题递归实现
    6. c++数据结构中 顺序队列的队首队尾_数据结构与算法—队列详解
    7. matlab求负数分数幂问题
    8. python编程输入,Python编程:输入变量返回nam
    9. 截取年月日在hana中怎么写_2020高会评审进行中 工作业绩怎么写才能在评审时脱颖而出呢?...
    10. [function.strtotime] 错误对策
    11. java如何看jdk文档_如何在IntelliJ IDEA中查看JDK外部文档?
    12. CPU监控工具(CPU使用率及CPU温度监控)
    13. 华为交换机各种配置实例
    14. BasicVSR++: Improving Video Super-Resolution with Enhanced Propagation and Alignment阅读笔记
    15. 【Nginx】Nginx配置文件详解
    16. 开源项目推荐系列(短信网关)
    17. python实现批量变更阿里云DNS解析记录状态
    18. Rust中的channel
    19. android7.1系统集成高德地图
    20. 塔罗牌怎么引流?如何利用塔罗牌引流?做塔罗牌如何引流?

    热门文章

    1. 用c语言判断一个数是否为素数
    2. 人机大战历程————思考与反思
    3. 【毕设论文——必修篇】开题报告要写些什么?这里有参考模板
    4. python调用手机蓝牙_python bluetooth蓝牙信息获取蓝牙设备类型的方法
    5. pdf文档怎样转换成word文档?2022pdf转word软件推荐
    6. 火狐浏览器快速代理插件(FoxyProxy Standard)
    7. settings.xml
    8. 安卓游戏 我叫mt 3.5.4.0 3540,data.dat 文件解包记录
    9. 远区场matlab仿真,matlab结题报告(电偶极子的辐射场)博客_0.doc
    10. Lodop,前端自定义打印