在安卓中壓縮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