濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

騰訊雲加社群發表於2019-03-03

騰訊雲技術社群-掘金主頁持續為大家呈現雲端計算技術文章,歡迎大家關注!


作者:成文迪, 在Web前端摸爬滾打的碼農一枚,對技術充滿熱情的菜鳥,致力為手Q的建設添磚加瓦。

GIF格式的歷史

GIF(Graphics Interchange Format)原義是“影像互換格式”,是CompuServe公司在1987年開發出的影像檔案格式,可以說是網際網路界的老古董了。

GIF格式可以儲存多幅彩色影像,如果將這些影像連續播放出來,就能夠組成最簡單的動畫。所以常被用來儲存“動態圖片”,通常時間短,體積小,內容簡單,成像相對清晰,適於在早起的慢速網際網路上傳播。

本來,隨著網路頻寬的擴充和視訊技術的進步,這種影像已經漸漸失去了市場。可是,近年來流行的表情包文化,讓老古董GIF圖有了新的用武之地。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

表情包通常來源於手繪影像,或是視訊擷取,目前有很多方便製作表情包的小工具。

這類圖片通常具有檔案體積小,內容簡單,相容性好(無需解碼工具即可在各類平臺上檢視),對畫質要求不高的特點,剛好符合GIF圖的特性。

所以,老古董GIF圖有了新的應用場景。

本文的應用場景

新的應用場景帶來新的需求,本文所探究的問題來自於某個業務場景下——為使用者批量推送GIF表情包。

一批影像大約有200-500張,以縮圖列表的形式展示在客戶端。

根據我們使用測試資料進行的統計GIF圖表情包的尺寸大部分在200k-500k之間,批量推送的一個重要問題就是資料量太大,因此,我們希望能夠在列表裡展示體積較小的縮圖,使用者點選後,再單獨拉取原圖。

傳統的GIF縮圖是靜態的,通常是提取第一幀,但在表情包的情形下,這種方式不足以表達出圖片中資訊。比如下面的例子

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

——第一幀完全看不出重點啊!

所以,我們希望縮圖也是動態的,並儘可能和原圖相似。

對於傳統圖片來說,檔案大小一般和圖片解析度(尺寸)正相關,所以,生成縮圖最直觀的思路就是縮小尺寸,resize大法。

但是在GIF圖的場合,這個方式不再高效,因為GIF圖的檔案大小還受到一個重要的因素制約——幀數

以這張柴犬表情為例,原圖寬度200,尺寸1.44M,等比縮放到150之後,尺寸還是1.37M,等比縮放到100,相當於尺寸變為原來的四分之一,體積還是749K

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

可見,resize大法的壓縮率並不理想,收效甚微。

而且,我們所得到的大部分表情圖素材,解析度已經很小了,為了保證客戶端展示效果,不能夠過度減少尺寸,不然圖片會變得模糊。

所以,想要對GIF圖進行壓縮,只能從別的方向入手。

探尋GIF格式的儲存

想要壓縮一個檔案,首先要了解它是如何儲存的。畢竟,程式設計的事,萬變不離其宗嘛。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

作為一種古老的格式,GIF的儲存規則也相對簡單,容易理解,一個GIF檔案主要由以下幾部分組成。

  • 檔案頭
  • 影像幀資訊
  • 註釋

下面我們來分別探究每個部分。

檔案頭

GIF格式檔案頭和一般檔案頭差別不大,也包含有

  • 格式宣告
  • 邏輯螢幕描述塊
  • 全域性調色盤

格式宣告

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

Signature 為“GIF”3 個字元;Version 為“87a”或“89a”3 個字元。

邏輯螢幕描述塊

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

前兩位元組為畫素單位的寬、高,用以標識圖片的視覺尺寸。

Packet裡是調色盤資訊,分別來看——

Global Color Table Flag為全域性顏色表標誌,即為1時表明全域性顏色表有定義。

Color Resolution 代表顏色表中每種基色位長(需要+1),為111時,每個顏色用8bit表示,即我們熟悉的RGB表示法,一個顏色三位元組。

Sort Flag 表示是否對顏色表裡的顏色進行優先度排序,把常用的排在前面,這個主要是為了適應一些顏色解析度低的早期渲染器,現在已經很少使用了。

Global Color Table 表示顏色表的長度,計算規則是值+1作為2的冪,得到的數字就是顏色表的項數,取最大值111時,項數=256,也就是說GIF格式最多支援256色的點陣圖,再乘以Color Resolution算出的位元組數,就是調色盤的總長度。

這四個欄位一起定義了調色盤的資訊。

Background color Index 定義了影像透明區域的背景色在調色盤裡的索引。

Pixel Aspect Ratio 定義了畫素寬高比,一般為0。

什麼是調色盤?我們先考慮最直觀的影像儲存方式,一張解析度M×N的影像,本質是一張點陣,如果採用Web最常見的RGB三色方式儲存,每個顏色用8bit表示,那麼一個點就可以由三個位元組(3BYTE = 24bit)表達,比如0xFFFFFF可以表示一個白色畫素點,0x000000表示一個黑色畫素點。

如果我們採用最原始的儲存方式,把每個點的顏色值寫進檔案,那麼我們的影像資訊就要佔據就是3×M×N位元組,這是靜態圖的情況,如果一張GIF圖裡有K幀,點陣資訊就是3×M×N×K。

下面這張兔子snowball的表情有18幀,解析度是200×196,如果用上述方式計算,檔案尺寸至少要689K。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

但實際檔案尺寸只有192K,它一定經歷過什麼……

我們可以使用命令列圖片處理工具gifsicle來看看它的資訊。

gifsicle -I snowball.gif > snowball.txt複製程式碼

我們得到下面的文字

5.gif 19 images
logical screen 200x196
global color table [128]
background 93
loop forever
extensions 1
+ image #0 200x196 transparent 93
disposal asis delay 0.04s
+ image #1 200x188 transparent 93
disposal asis delay 0.04s
........複製程式碼

可以看到,global color table [128]就是它的調色盤,長度128。

為了確認,我們再用二進位制檢視器檢視一下它的檔案頭

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

可以看到Packet裡的欄位的確符合我們的描述。

在實際情況中,GIF圖具有下面的特徵

(1)一張影像最多隻會包含256個RGB值。

(2)在一張連續動態GIF裡,每一幀之間資訊差異不大,顏色是被大量重複使用的。

在儲存時,我們用一個公共的索引表,把圖片中用到的顏色提取出來,組成一個調色盤,這樣,在儲存真正的圖片點陣時,只需要儲存每個點在調色盤裡的索引值。

如果調色盤放在檔案頭,作為所有幀公用的資訊,就是公共(全域性)調色盤,如果放在每一幀的幀資訊中,就是區域性調色盤。GIF格式允許兩種調色盤同時存在,在沒有區域性調色盤的情況下,使用公共調色盤來渲染。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

這樣,我們可以用調色盤裡的索引來代表實際的顏色值。

一個256色的調色盤,24bit的顏色只需要用9bit就可以表達了。

調色盤還可以進一步減少,128色,64色,etc,相應的壓縮率就會越來越大……

還是以兔子為例,我們還可以嘗試指定它的調色盤大小,對它進行重壓縮

gifsicle --colors=64 5.gif > 5-64.gif
gifsicle --colors=32 5.gif > 5-32.gif
gifsicle --colors=16 5.gif > 5-16.gif
gifsicle --colors=2 5.gif > 5-2.gif
......複製程式碼

依然使用gifsicle工具,colors引數就是調色盤的長度,得到的結果

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

注意到了2的時候,影像已經變成了黑白二值圖。

居然還能看出是個兔子……

所以我們得出結論——如果可以接受犧牲影像的部分視覺效果,就可以通過減色來對影像做進一步壓縮。

檔案頭所包含的對我們有用的資訊就是這些了,我們繼續往後看。

幀資訊描述

幀資訊描述就是每一幀的影像資訊和相關標誌位,在逐項瞭解它之前,我們首先探究一下幀的儲存方式。

我們已經知道調色盤相關的定義,除了全域性調色盤,每一幀可以擁有自己的區域性調色盤,渲染順序更優先,它的定義方式和全域性調色盤一致,只是作用範圍不同

直觀地說,幀資訊應該由一系列的點陣資料組成,點陣中儲存著一系列的顏色值。點陣資料本身的儲存也是可以進行壓縮的,GIF圖所採用的是LZW壓縮演算法。
這樣的壓縮和影像本身性質無關,是位元組層面的,文字資訊也可以採用(比如常見的gzip,就是LZW和哈夫曼樹的一個實現)

基於表查詢的無失真壓縮是如何進行的?基本思路是,對於原始資料,將每個第一次出現的串放在一個串表中,用索引來表示串,後續遇到同樣的串,簡化為索引來儲存(串表壓縮法)

舉一個簡單的例子來說明LZW演算法的核心思路。

有原始資料:ABCCAABCDDAACCDB

可以看出,原始資料裡只包括4個字元A,B,C,D,四個字元可以用2bit的索引來表示,0-A,1-B,2-C,3-D。

原始字串存在重複字元,比如AB,CC,都重複出現過。用4代表AB,5代表CC,上面的字串可以替代表示為45A4CDDAA5DB

這樣就完成了壓縮,串長度從16縮減到12。對原始資訊來說,LZW壓縮是無損的。

除了採用LZW之外,幀資訊儲存過程中還採取了一些和影像相關的優化手段,以減小檔案的體積,直觀表述就是——公共區域排除、透明區域疊加

這是ImageMagick官方範例裡的一張GIF圖。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

根據直觀感受,這張圖片的每一幀應該是這樣的。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

但實際上,進行過壓縮優化的圖片,每一幀是這樣的。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

首先,對於各幀之間沒有變化的區域進行了排除,避免儲存重複的資訊。
其次,對於需要儲存的區域做了透明化處理,只儲存有變化的畫素,沒變化的畫素只儲存一個透明值。

這樣的優化在表情包中也是很常見的,舉個例子

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

上面這個表情的檔案大小是278KB,幀數是14
我們試著用工具將它逐幀拆開,這裡使用另一個命令列影像處理工具ImageMagick

gm convert source.gif target_%d.gif

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

可以看出,除了第一幀之外,後面的幀都做了不同程度的處理,檔案體積也比第一幀小。

這樣的壓縮處理也是無損的,帶來的壓縮比和原始影像的具體情況有關,重複區域越多,壓縮效果越好,但相應地,也需要儲存一些額外的資訊,來告訴引擎如何渲染,具體包括

幀資料長寬解析度,相對整圖的偏移位置

透明彩色索引——填充透明點所用的顏色

Disposal Method——定義該幀對於上一幀的疊加方式

Delay Time——定義該幀播放時的停留時間

其中值得額外說明的是Disposal Method,它定義的是幀之間的疊加關係,給定一個幀序列,我們用怎樣的方式把它們渲染成起來。

詳細引數定義,可以參考該網站的範例
www.theimage.com/animation/p…

Disposal Method和透明顏色一起,定義了幀之間的疊加關係。在實際使用中,我們通常把第一幀當做基幀(background),其餘幀向前一幀對齊的方式來渲染,這裡不再贅述。

理解了上面的內容,我們再來看幀資訊的具體定義,主要包括

  • 幀分隔符
  • 幀資料說明
  • 點陣資料(它儲存的不是顏色值,而是顏色索引)
  • 幀資料擴充套件(只有89a標準支援)

1和3比較直觀,第二部分和第四部分則是一系列的標誌位,定義了對於“幀”需要說明的內容。

幀資料說明。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

除了上面說過的欄位之外,還多了一個Interlace Flag,表示幀點陣的儲存方式,有兩種,順序和隔行交錯,為 1 時表示影像資料是以隔行方式存放的。最初 GIF 標準設定此標誌的目的是考慮到通訊裝置間傳輸速度不理想情況下,用這種方式存放和顯示影像,就可以在影像顯示完成之前看到這幅影像的概貌,慢慢的變清晰,而不覺得顯示時間過長。

幀資料擴充套件是89a標準增加的,主要包括四個部分。

1、程式擴充套件結構(Application Extension)主要定義了生成該gif的程式相關資訊

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

2、註釋擴充套件結構(Comment Extension)一般用來儲存圖片作者的簽名資訊

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

3、圖形控制擴充套件結構(Graphic Control Extension)這部分對圖片的渲染比較重要

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

除了前面說過的Dispose Method、Delay、Background Color之外,User Input用來定義是否接受使用者輸入後再播放下一幀,需要影像解碼器對應api的配合,可以用來實現一些特殊的互動效果。

4、平滑文字擴充套件結構(Plain Text Control Extension)

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

89a標準允許我們將圖片上的文字資訊額外儲存在擴充套件區域裡,但實際渲染時依賴解碼器的字型環境,所以實際情況中很少使用。

以上擴充套件塊都是可選的,只有Label置位的情況下,解碼器才會去渲染

需求場景——給表情包減負

說完了基本原理,來分析一下我們的實際問題。

給大量表情包生成縮圖,在不損耗原畫質的前提下,儘可能減少圖片體積,節省使用者流量。

之前說過,單純依靠resize大法不能滿足我們的要求,沒辦法,只能損耗畫質了,主要有兩個思路,減少顏色和減少幀數。

減少顏色——圖片情況各異,標準難以控制,而且會造成縮圖和原圖視覺差異比較明顯

減少幀數——通過提取一些間隔幀,比如對於一張10幀的動畫,提取其中的提取1,3,5,7,9幀。來減少圖片的整體體積,似乎更可行。

先看一個成果,就拿文章開頭的圖做栗子吧

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

看上去連貫性不如以前,但是差別不大,作為縮圖的視覺效果可以接受,由於幀數減小,體積也可以得到明顯的優化。體積從428K縮到了140K

但是,在開發初期,我們嘗試暴力間隔提取幀,把幀重新連線壓成新的GIF圖,這時,會得到這樣的圖片。

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

主要有兩個問題。

1、幀數過快

2、能看到明顯的殘留噪點

分析我們上面的原理,不難找到原因,正是因為大部分GIF儲存時採用了公共區域排除和透明區域疊加的優化,如果我們直接間隔抽幀,再拼起來,就破壞了原來的疊加規則,不該露出來的幀露出來了,所以才會產生噪點。

所以,我們首先要把原始資訊恢復出來。

兩個命令列工具,gifsicle和ImageMagick都提供這樣的命令。

gm convert -coalesce source.gif target_%d.gif
gifsicle --unoptimize source.gif > target.gif複製程式碼

濃縮的才是精華:淺析GIF格式圖片的儲存和壓縮

還原之後抽幀,重建新的GIF,就可以解決問題2了。

注意重建的時候,可以應用工具再進行對透明度和公共區域的優化壓縮。

至於問題1,也是因為我們沒有對幀延遲引數Delay Time做處理,直接取原幀的引數,幀數減少了,速度一定會加快。

所以,我們需要把抽去的連續幀的總延時加起來,作為新的延遲資料,這樣可以保持縮圖和原圖頻率一致,看起來不會太過鬼畜,也不會太過遲緩。

提取出每一幀的delay資訊,也可以通過工具提供的命令來提取。

gm identify -verbose source.gif
gifsicle -I source.gif複製程式碼

在實際應用中,抽幀的間隔gap是根據總幀數frame求出的

frame<8 gap=1
9<frame<20 gap=2
21<frame<30 gap=3
31<frame<40 gap=4
frame>40 gap=5複製程式碼

delay值的計算還做了歸一化處理,如果新生成縮圖的幀間隔平均值大於200ms,則統一加速到均值200ms,同時保持原有節奏,這樣可以避免極端情況下,縮圖過於遲緩。

具體實現

本文介紹的演算法主要應用於手Q熱圖功能的後臺管理系統,使用Nodejs編寫。
ImageMagick是一個較為常用的影像處理工具,除了gif還可以處理各類影像檔案,有node封裝的版本可以使用。
gifsicle只有可執行版本,在伺服器上重新編譯原始碼後,採用spawn調起子程式的方式實現。

ImageMagick對於圖片資訊的解析較為方便,可以直接得到結構化資訊。
gifsicle支援命令管道級聯,處理圖片速度較快。
實際生產過程中,同時採用了兩個工具。

const {spawn} = require(`child_process`);

const image = gm("src2/"+file)
  image.identify((err, val) => {
    if(!val.Scene){
          console.log(file+" has err:"+err)
          return
    }
    let frames_count = val.Scene[0].replace(/d* of /, ``) * 1
    let gap = countGap(frames_count)

    let delayList = [];
    let totaldelay = 0
    if(val.Delay!=undefined){
          let i
          for (i = 0; i < val.Delay.length; i ++) {
            delayList[i] = val.Delay[i].replace(/xd*/, ``) * 1
            totaldelay+=delayList[i]
          }
          for (; i < val.Scene.length; i ++) {
            delayList[i] = 8
            totaldelay+=delayList[i]
          }
    }else{
          for (let i = 0; i < val.Scene.length; i ++) {
            delayList[i] = 8
            totaldelay+=delayList[i]
          }
    }
    let totalFrame = parseInt(frames_count/gap)
    //判斷是否速度過慢,需要進行歸一加速處理
    if(totaldelay/totalFrame>20){
          let scale =(totalFrame*1.0*20)/totaldelay
          for (let i = 0; i < delayList.length; i ++) {
            delayList[i] = parseInt(delayList[i] * scale)
          }
    }

    let params=[]
    params.push("--colors=255")
    params.push("--unoptimize")
    params.push("src2/"+file)

    let tempdelay = delayList[0]
    for (let i = 1; i < frames_count; i ++) {
          if(i%gap==0){
            params.push("-d"+tempdelay)
            params.push("#"+(i-gap))
            tempdelay=0
          }
      tempdelay += delayList[i]
    }
    params.push("--optimize=3")
    params.push("-o")
    params.push("src2/"+file+"gap-keepdelay.gif")
    spawn("gifsicle", params, { stdio: `inherit` })
})複製程式碼

測試時,採用該演算法隨機選擇50張gif圖進行壓縮,原尺寸15.5M被壓縮到6.0M,壓縮比38%,不過由於該演算法的壓縮比率和具體圖片質量、幀數、影像特徵有關,測試資料僅供參考。

本文到這裡就結束了,原來看似簡單的表情包,也有不少文章可做。

謝謝觀看,希望文中介紹的知識和研究方法對你有所啟發。


相關推薦

iOS高效能圖片架構與設計
谷歌開源圖片壓縮演算法Guetzli實測體驗報告
關於Android圖片資源瘦身的奇思妙想


此文已由作者授權騰訊雲技術社群釋出,轉載請註明文章出處;獲取更多雲端計算技術乾貨,可請前往騰訊雲技術社群

相關文章