Glide庫裡,藏了一套你心心念唸的GIF壓縮工具集

語言: CN / TW / HK

theme: channing-cyan

“我報名參加金石計劃1期挑戰——瓜分10萬獎池,這是我的第2篇文章,點選檢視活動詳情

android.gif

移動端的圖片壓縮是一個老生常談的話題,也曾湧現過不少諸如Luban之類的優秀的圖片壓縮工具庫,但在GIF影象領域的壓縮方案卻幾乎處於一片空白。

許多開發者不知道的是,實際上,已經有一套現成的GIF影象壓縮工具集,就內建在你整合的Glide圖片載入框架之中。


大家好,我是潛伏於各大群中收集GIF表情包的星際碼仔,今天我們要分享的是移動端的GIF影象壓縮方案

我們會從GIF影象的基礎知識出發,介紹幾種常見的GIF影象壓縮策略,然後利用Glide框架內部自帶的壓縮工具集來實現。

過程中如有不合理的地方,歡迎隨時"Objection!"。

照例,奉上思維導圖一張:

用Glide實現GIF壓縮.png

GIF影象基礎知識

GIF的全稱是Graphics Interchange Format,即影象交換格式,是CompuServe公司為支援彩色影象的下載,於1987年推出的點陣圖影象格式。

GIF採用Lempel-Ziv-Welch(LZW)無損資料壓縮技術進行壓縮,可以在不降低視覺質量的情況下減少檔案大小

LZW.png

憑藉其體積小、成像相對清晰的優點,GIF在頻寬小、傳輸慢的網際網路初期廣受歡迎。發展至今,以其被大多數主流平臺所支援的高相容性,佔據了動圖格式的大半片江山。

256色

作為一種古老的點陣圖影象格式,GIF的缺點也很明顯,比如僅支援8 bit的色深,每個畫素最多隻能顯示2^8=256種顏色

相比之下,因LZW演算法專利問題而被設計出來替代GIF的PNG格式,即使是不帶透明度的24 bit格式,最多也可顯示2^24=1600多萬種顏色。

戰五渣.gif

256色的限制大大侷限了GIF的應用範圍,使得GIF只適用於包含少量顏色的圖片,比如Logo、卡通人物等,而在色彩豐富甚至帶有漸變效果的圖片上則表現不佳,常常會使圖片伴有明顯的噪點失真。

動效

GIF通過將多張影象儲存在同一個檔案中,並利用人眼視覺殘留的特性,控制連續播放的間隔,以實現簡單的動畫效果,原理上有點類似於小時候玩過的手翻書。

手翻書

同樣,因受256色的限制,GIF動圖大多隻能用於小型的動畫和低解析度的視訊

不過即便如此,相比於靜態圖片,GIF動圖顯然能傳遞更多的資訊,並使溝通雙方的情感交流更加直接、高效,因而得以在社交軟體上被廣泛使用和傳播,近年來流行的表情包文化就是很好的佐證。

調色盤

GIF檔案有一個很重要的概念就是調色盤,個人認為調色盤這個名稱用得很恰當,可以說高度概括了其特徵。

調色盤.png

那什麼是調色盤呢?

前面我們講了,GIF是一種點陣圖影象格式,關於點陣圖的特徵,我們在《Bitmap——Android記憶體刺客》一文中已經有過介紹。簡單講,點陣圖就是由若干個不同顏色的畫素進行排列所構成的畫素陣列。

點陣圖.png

另外我們知道,GIF動圖實際就是連續播放的多張影象,每張影象稱為一幀,幀與幀之間的資訊差異不大,其中的顏色是被大量重複使用的。

於是我們可以建立這樣一張公共的索引表,把每一幀的畫素點所用到的顏色提取出來,組成一個調色盤,併為每個顏色值建立索引

這樣,在儲存真正的畫素陣列時,只需要儲存每個顏色在調色盤裡的對應索引值即可,從而減少儲存的資訊量

調色盤索引.png

如果把調色盤放在檔案頭,作為所有幀公用的資訊,就是全域性調色盤;而如果放在每一幀的幀資訊中,就是區域性調色盤

GIF允許兩種調色盤同時存在,並且區域性調色盤的優先順序更高,當沒有區域性調色盤時,就使用公共調色盤渲染。

很明顯,顏色越豐富,調色盤也就越大,並最終影響到GIF檔案的大小

檔案大小

以我們最為熟悉的表情包為例,GIF動圖型別的表情包的來源大致可分為手繪卡通影象以及視訊片段擷取兩種。

手繪卡通影象的線條單調,顏色均勻,人物動作簡單,因而調色盤大小與影象幀數往往都不大。

手繪卡通影象.gif

而視訊片段擷取之後轉換的GIF,往往保留了原有視訊的高幀數,且視訊內容本身包含了大量的顏色細節,很容易就佔滿整個調色盤的大小。

視訊片段擷取.gif

這也是視訊片段擷取的GIF檔案大小往往比手繪卡通影象的大很多的原因所在。

GIF影象壓縮策略

GIF檔案過大,對於如何儲存和傳輸都是一個難題,下面就來介紹一下幾種常見的GIF影象壓縮策略:

縮放

作為一種點陣圖影象格式,GIF檔案的大小是跟解析度呈正相關的,解析度越高,所包含的畫素個數就越多,影象也就越清晰,但相應的檔案體積也就越大。

鑑於我們通常是在一個有限的展示區域內顯示GIF影象的,因此更合理點的做法應該是先對原始的GIF影象先進行一輪下采樣,以提供一個較低解析度版本的縮圖,減少記憶體佔用,再貼合展示區域的尺寸進行一輪精確的縮放

減色

減色也就是減少調色盤的顏色,同樣可以達到壓縮的效果。但是GIF本身僅支援的最高256色已經是捉襟見肘了,再進一步減色,可能會使影象質量的損失更加明顯。而且這種方式的壓縮率也比較低,減去一半顏色也可能只壓縮10%左右。

以下是分別將調色盤的顏色減少至64色、16色和2色的效果:

64color.gif

16color.gif

2color.gif

可以看到,隨著調色盤顏色的減少,圖片逐漸暗淡,顏色過渡也愈加粗糙,到最後甚至只剩下黑白兩色。

抽幀

前面講過,GIF是通過逐幀播放單幅影象以達到連續動畫的效果的。而抽幀,顧名思義,就是從這些影象中每間隔一定的幀數抽取出單幅影象,通過降低幀率以達到降低GIF檔案整體大小的效果

比如電影的常見幀率為24fps(幀每秒),擷取其中的3秒並轉換為GIF後,幀率依舊保持在24fps,那麼總共要儲存72幅影象;如果通過抽幀,將幀率降到12fps,就只要儲存36幅影象就可以了。

不過,抽幀會影響到GIF動效的流暢度,因為幀率降低之後,幀與幀之間的延遲時間變長,可能會達不到人眼視覺殘留特性的閾值,從而在視覺感受上會有明顯的卡頓

重慶森林.gif

透明度儲存

開始介紹這種方式之前,我們先來看一張GIF圖:

透明度儲存示例.gif

根據直覺,我們猜想這張GIF圖拆解後的每一幀應該是這樣的:

透明度儲存示例拆解猜想.png

然而實際上,每一幀是這樣的:

透明度儲存示例拆解實際情況.png

也就是說,透明度儲存這種方式是通過只完整保留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格式的檔案頭與其他格式的檔案頭作用一致,都是位於檔案開頭的一段資料,用於描述檔案的一些重要屬性,指示開啟該檔案的程式應該怎樣處理這個檔案

主要包含:

格式宣告

格式宣告.png

  • Signature 為檔案型別的簽名,此處為“GIF”3 個字元;
  • Version 為GIF釋出的版本號,可能是“87a”或“89a”。
邏輯螢幕描述塊

邏輯螢幕描述塊.png

  • 前兩位元組用以標識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

可以看到,暴力抽幀後的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 -> {
            true
        }
        frameRateReciprocalSum > outFrameRateReciprocal -> {
            frameRateReciprocalSum -= outFrameRateReciprocal
            true
        }
        else -> {
            false
        }
    }
}

} /* * 根據目標幀率進行抽幀 * @param frameCount 幀數 * @param gifFrames 影象幀序列 / private fun GIFFrameSampler.sample( frameCount: Int, gifFrames: List ): List { 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() - 1 setPalSize(palSize) // 調整解析度 setSize(outWidth, outHeight) // 調整幀率 setFrameRate(options.targetFrameRate.toFloat()) } } ``` /* * 將處理後的影象幀序列重新編碼 * @param sampleFrames 抽幀後的影象幀序列 / private fun AnimatedGifEncoder.encode(sampleFrames: List) { // 開始寫入 start(options.sink?.path!!) // 逐一新增幀 sampleFrames.forEach { addFrame(it) } // 完成,關閉輸出檔案 finish()

    options.listener?.onCompleted()
}

```

一個Demo

https://github.com/madchan/GlideGIFCompressor.git

為了方便演示以上所提及策略的實際壓縮效果,我寫了一個GIF影象壓縮前後對比的Demo,可以通過調整寬高、幀率、色彩三個屬性的數值來分別實現縮放、抽幀、減色三種壓縮策略:

Demo.png

如果這個Demo對你有幫助,希望不吝點個哈~

好了,以上就是今天要分享的內容。最後提一個問題,除了GIF,你還知道有哪些動圖格式呢?歡迎在評論區或後臺討論哈~

少俠,請留步!若本文對你有所幫助或啟發,還請:

  1. 點贊👍🏻,讓更多的人能看到!
  2. 收藏⭐️,好文值得反覆品味!
  3. 關注➕,不錯過每一次更文!

===> 公眾號:「星際碼仔」💪

你的支援是我繼續創作的動力,感謝!🙏

參考

  • 濃縮的才是精華:淺析 GIF 格式圖片的儲存和壓縮 https://cloud.tencent.com/developer/article/1004763
  • 如何正確壓縮GIF格式檔案?來看京東設計師的總結! https://www.uisdc.com/gif-compression