在安卓中壓縮GIF的幾種方法(附例項程式碼)

語言: CN / TW / HK

theme: smartblue

持續創作,加速成長!這是我參與「掘金日新計劃 · 10 月更文挑戰」的第1天,點選檢視活動詳情

前言

最近在划水摸魚的時候,看到有位大佬發了一篇 GIF 壓縮思路的文章。

讓我突然想起來,很久以前我在我的專案 隱雲圖解制作 中就實現了一個動圖工具箱,其中一個功能就是壓縮GIF。

不過這位大佬只介紹了其中幾種使用方法,還有一些方法他沒有說到,正好我可以拆解我的專案對此做一個補全。

壓縮方法介紹

降低解析度

和靜態圖片以及視訊一樣,GIF檔案的尺寸和解析度呈正相關關係,解析度越高需要儲存的影象資訊越多,所以GIF檔案大小就會越大。

因此我們可以通過降低GIF的解析度來減小檔案體積,但是實際上並不是所有場景都適用於減少解析度。

如果是表情包之類的GIF,那麼就無所謂,只要還能看見就可以隨意減少解析度;如果是用於固定場景(例如商城頭圖)則不能隨便改解析度,因為在這些場景下對解析度有嚴格要求。

降低顏色深度

由於GIF這個格式已經十分古老了,所以它在今天也還是隻支援256色,對於顏色簡單的動畫來說勉強夠用,對於實際拍攝的視訊轉成的GIF現在的256色都已經有點捉襟見肘了,更別說繼續降低顏色位數。

所以這個方法只適用於顏色比較簡單的GIF檔案。

降低幀率

雖然一般來說,需要幀率達到24人眼看起來才會覺得流暢,但是實際上,GIF的幀率只要在10左右都還是比較流暢的。

並且大多數動圖的動畫其實並不需要高幀率,因此降低GIF幀率不失為一種減少檔案體積的好辦法。

更多方法

根據GIF格式的原理,我們還可以使用僅儲存變化內容、使用透明度幀、合理應用調色盤、藉助第三方工具的壓縮演算法等方法來實現降低GIF檔案大小。

壓縮效果預覽

下面是我使用不同壓縮方法壓縮後的效果:

| 壓縮方法 | 影象 | 大小 | 影象引數 | | :-----: | :-----: | :-----: | :-----: | | 原圖 | p1.gif | 5.49mb | 解析度: 540x532 ; 幀率: 33FPS ; 顏色深度: 256| | 降低解析度 | p2.gif | 3.47mb | 解析度: 270x266 ; 幀率: 33FPS ; 顏色深度: 256 | | 降低顏色深度 | p3.gif | 4.41mb | 解析度: 540x532 ; 幀率: 33FPS ; 顏色深度: 128 | | 降低幀率 | p4.gif | 6.79mb | 解析度: 540x532 ; 幀率: 16FPS ; 顏色深度: 256 | | Gifsicle無失真壓縮 | p5.gif | 4.69mb | 解析度: 540x532 ; 幀率: 33FPS ; 顏色深度: 256 |

從上面的表格中,可以看出降低解析度能夠大幅減少檔案大小。

降低顏色深度雖然也能減少大小,但是影象失真嚴重。

降低幀率後文件大小不減反增,其實降低幀率應該是可以減小檔案大小的,只是因為這裡我降低解析度後沒有重新做壓縮優化,導致大小反而增加了。(壓縮優化即上面提到的僅儲存變化內容和透明度,原圖已經進行過壓縮優化,但是這裡我降低幀率後反而把壓縮優化全丟失了。)

使用 Gifsicle 無失真壓縮也能夠大幅減少檔案大小,並且影象質量幾乎沒有損失。

其實 Gifsicle 還可以進行有失真壓縮,雖然名字叫有失真壓縮,但是實際肉眼幾乎看不出來差別。

另外,這裡列舉的只是單一壓縮方法,實際使用時不會只使用一種壓縮,而是多種壓縮方法混合使用。

壓縮方法實現

使用 FFmpeg

對於降低幀率,我們這裡使用的是 FFmpeg 來實現,關於怎麼在安卓上使用 FFmpeg 可以參考我的這篇文章: 在安卓專案中使用 FFmpeg 實現 GIF 拼接

命令十分簡單:

kotlin val gifPath = "input.gif" val savePath = "output.gif" val frameRate = 12 // 新幀率 val cmd = FFMpegArgumentsBuilder.Builder() .setOverride(true) .setInput(gifPath) .setFrameRate(frameRate) .setOutput(savePath) .build() .cmd FFmpegKit.executeWithArguments(cmd)

可以看到,我們這裡直接使用了 FFmpeg 進行抽幀,而沒有做任何的優化處理,這也是為什麼在上面的測試中,降低幀率反而會使得檔案體積更大。

使用 Gifsicle

對於除幀率外的壓縮,我們均使用 Gifsicle 來實現,關於如何在安卓上使用 Gifsicle 可以看我的文章: 在安卓專案中使用gifsicle編輯GIF動圖-Android NDK 編譯 Gifsicle 為可執行檔案

需要注意的是,其實使用 Gifsicle 也可以完成抽幀的需求,但是 Gifsicle 抽幀需要自己計算並明確指定抽出哪些幀,相比於 FFmpeg 會自動計算並刪除幀,我們只需要指定最終匯出影象需要多少幀即可,所以我偷懶直接使用 FFmpeg 來抽幀了。

雖然 FFmpeg 抽幀後反而會導致體積增大,但是不用擔心,接下來我們就會說如何避免這個情況。

Gifsicle 為我們提供了非常多的 GIF 操作命令,對於壓縮 GIF 這個需求,我們可以使用:

  1. --resize 更改解析度
  2. --lossy 有失真壓縮
  3. --colors 或者 -k 更改顏色位數
  4. -Ox 無損優化壓縮

更改解析度和更改顏色位數不用過多介紹,這裡著重介紹一下 Gifsicle 提供的無失真壓縮(優化)指令:-O1 -O2 -O3;以及有失真壓縮指令 --lossy 。

無失真壓縮

無失真壓縮使用指令 -O[level] 其中的 level 為壓縮級別,可以填寫1-3,數字越大,壓縮效果越強:

-O1 : 僅儲存每幀之間變化的部分

-O2 : 僅儲存每幀之間變化的部分,並啟用透明度。

-O3 : 同時嘗試多種優化方式。

無失真壓縮的原理即通過對比幀與幀之間的影象區別,後面的幀儲存的不是完整的影象,而是相對於前面的幀的不同的地方。

例如這張 gif :

pig

解開每幀後實際是這樣的:

export

可以看到除了第一幀儲存的是完整的影象,後面儲存的都只是相對於前一幀有變化的部分。

這對於動圖中有大量靜態部分的圖片壓縮效果非常明顯,並且對動圖質量幾乎沒有任何影響。

需要注意的是,開啟 O3 級別壓縮後,因為混合使用了多種優化演算法,所以對於某些GIF也可能出現體積不降反增的現象(例如將已優化過後的GIF使用相同指令再優化一次就大概率會使得檔案大小增加)

有失真壓縮

使用 --lossy[=lossiness] 可以對 GIF 進行有失真壓縮。

其中 lossiness 為壓縮值,它是一個整數。

該選項預設值是 20,當值為 200 時就已經是非常大的壓縮值了。

但是需要注意的是,由於演算法限制,並不是值越大壓縮效果越好:

It works best when only little loss is introduced, and due to limitation of the compression algorithm very high loss levels won't give as much gain.

它的實現原理:

GIF's LZW compression is based on a "dictionary" of strings of pixels seen. Normal encoder searches the dictionary for the longest string of pixels that exactly matches pixels in the image. Lossy encoder picks longest string of pixels that's "similar enough" to pixels in the image (plus some magic to hide the distortions with dithering).

簡單理解就是通過優化 GIF 的壓縮演算法,原壓縮演算法在在編碼時需要匹配完全一致的資料,但是 lossy 通過更改為匹配 “足夠相似” 的資料來進行壓縮。當然,這意味著會造成資料的丟失,表現在影象上就是會產生一些抖動和噪點。

效果如下:

  1. 未壓縮 3.3 MB p6.gif
  2. 壓縮後 1.2 MB p7.gif

混合多種壓縮方法

在介紹完上述壓縮方法和引數後,我在專案中實際應用時其實是混合了多種方式來壓縮的。

例如,在我提到的這個 GIF工具 功能中,有一個一鍵壓縮至指定大小,或預設大小的功能:

s1.jpg

該功能我在實現時會優先使用無失真壓縮方法壓縮,如果無失真壓縮後尺寸不能滿足則依次使用對質量影響較小的方法嘗試壓縮,直至尺寸達到預設值:

```kotlin suspend fun compressGif2Size( activity: FragmentActivity?, sourcePath: String, targetSize: Long, resultPath: String, gifDrawable: GifDrawable ): Boolean { // ……

return compressByGifsicleOptimization(gifsicle, sourcePath, resultPath, targetSize, gifDrawable)

}

// 使用 Gifsicle -O3 壓縮 private suspend fun compressByGifsicleOptimization( gifsicle: File, sourcePath: String, resultPath: String, targetSize: Long, gifDrawable: GifDrawable): Boolean {

val cmd = "$gifsicle -i $sourcePath -O3 -o $resultPath"

// …… 執行 gifsickle 命令

if (resultFile.length() < targetSize) {
    log2text("compress success!", "d")
    return true
}

return compressByReduceFrameRate(sourcePath, resultPath, targetSize, gifDrawable, gifsicle)

}

// 使用 FFmpeg 降低幀率 private suspend fun compressByReduceFrameRate( sourcePath: String, resultPath: String, targetSize: Long, gifDrawable: GifDrawable, gifsicle: File): Boolean { // …… while (rate >= CompressGifFrameRateMinValue) { var ffmpegCmd = FFMpegArgumentsBuilder.Builder() .setOverride(true) .setInput(sourcePath) .setFrameRate(currentRate.toString()) .setOutput(resultPath) .build(false) .cmd // …… 執行 FFmpeg 命令 if (resultFile.length() < targetSize) { return true }

    // ……

    rate--
}

return compressByReduceResolution(resultPath, gifDrawable, gifsicle, targetSize)

}

// 使用 Gifsicle 減少解析度 private suspend fun compressByReduceResolution( resultPath: String, gifDrawable: GifDrawable, gifsicle: File, targetSize: Long): Boolean { // ……

while (scale >= minScale) {
    val cmd = "$gifsicle -i $tempOutFile --scale $scale -O3 -o $resultPath"
    // …… 執行 gifsickle 命令
    if (resultFile.length() < targetSize) {
        return true
    }
    // ……
    scale--
}

// ……

return compressByLossy(gifsicle, resultPath, targetSize)

}

// 使用 Gifsicle lossy 壓縮 private suspend fun compressByLossy( gifsicle: File, resultPath: String, targetSize: Long): Boolean { // …… for (i in 20..CompressGifLossyMaxValue step CompressGifLossyStepValue) { val cmd = "$gifsicle -i $resultPath --lossy=$i -O3 -o $resultPath" // …… 執行 gifsickle 命令 if (resultFile.length() < targetSize) { return true } // …… }

// ……

return compressByReduceColorBit(gifsicle, resultPath, targetSize)

}

// 使用 Gifsicle 減少顏色位數 private suspend fun compressByReduceColorBit( gifsicle: File, resultPath: String, targetSize: Long): Boolean { // …… for (i in 256 downTo CompressGifMinColorNum step CompressGifMinColorStepValue) { val cmd = "$gifsicle -i $resultPath -k $i --lossy=$CompressGifLossyMaxValue -O3 -o $resultPath"

    // …… 執行 gifsickle 命令

    if (resultFile.length() < targetSize) {
        return true
    }
}

// ……

// 所有方法都試過後還是無法滿足檔案大小要求則認為壓縮失敗,返回 false
return false

} ```

總結

總的來說,為了降低GIF檔案的大小,我們有以下幾種方法:

s2.jpg

而這些方法都可以使用 Gifsicle 來實現。

其中,除了使用 -Ox 優化外,其他均是有失真壓縮,或多或少會影響壓縮後的動圖質量。

參考

  1. Glide庫裡,藏了一套你心心念唸的GIF壓縮工具集
  2. 如何正確壓縮GIF格式檔案?來看京東設計師的總結!
  3. Lossy Gif Compressor