ImageSharp源碼詳解之JPEG壓縮原理(4)熵編碼


熵編碼這一過程可以算是JPEG過程中最為復雜一部分,本身的數學難度並不大,但是概念太多很容易搞混。比如很多博客直接將這部分省略成霍夫曼編碼,我認為這種說法很不准確,因為這里的熵編碼是多種編碼技術綜合運用的。

1.編碼過程

上一章,我們將原始圖像數據進行量化,得到一個8*8的數據塊,這個數據塊還要經歷下面這些步驟才最終轉換為JPEG格式數據:

(1)將8*8的數據塊分成兩個分量直流系數(DC)和交流系數進(AC)

(2)對AC分量進行之字排列,然后使用行程長度編碼(RLE)進行編碼,然后查詢兩張表(標准分類表和霍夫曼標准表)將數據編碼寫入數據流。

(3)使用差分編碼(DPCM)對DC進行編碼,然后查詢兩張表(標准分類表和霍夫曼標准表)將數據編碼寫入數據流。

注意:不同的代碼會有不同的實現,ImageSharp編碼使用標准霍夫曼表,我看了一眼谷歌的guetzli,里面就有構建霍夫曼樹的過程。

下面結合上文的例子,詳細介紹這3個過程。

1.1 DC和AC分量解釋

我們在經過DCT變換的時候說過一句,就是DCT變化使得主要的能量都集中在左上角,所以我們可以做個試驗,就只保留左上角的幾個數據,其余全部設為0,可以看到生成JPEG的圖像清晰度如下:

      

    原圖(173k)                                      保留AC分量3個數據(100k)

              

  保留AC分量2個數據(92.3k)        不保留AC分量(70.1k)

可以看到保留的數據越多,圖像就越清晰,但是數據量也越大,所謂DC分量可以看做這個8*8數據塊的平均值倍數,而AC分量就是這個8*8數據中的差異值了。

1.2 AC分量“之”字掃描(Zig-Zag)及行程編碼

好了,終於到這一步了,一般資料都是先描述DC分量如何編碼,然后是AC分量,但是我先寫AC分量的行程編碼是因為這一步是整個JPEG編碼過程中真正實現壓縮的一個步驟,前面的數據進過DCT、量化后原始數據只是“有損”、並沒有“壓縮”,比如有一個數據塊內容如下:

量化后的8*8數據塊

 我們把這個數據塊寫入數據流中,還是一樣的占用8*8*8bit(忽略負號)並沒有因為量化而減少需要存儲的字節,但是大家看到右下角有這么多重復的0,肯定很容易就想可以使用一些方法將多余的0給省略掉,JPEG使用的是行程編碼的方法,為了保證低頻分量先出現,高頻分量后出現,這里就使用“之”字型(Zig-Zag)的排列方法,如下圖所示。

8*8變換“之”字掃描

在進過zig變換后,上面數據塊中AC分量變成以下序列:

1 -9 3 0 0 0 ....0              bit數=63*8bit(請暫時忽略-9的負號吧)

然后進行行程編碼(RLE),行程編碼原理非常簡單,我們使用字符串舉例,如果待壓縮串為"AAABBBBCBB",則壓縮的結果是(A,3)(B,4)(C,1)(B,2),可以看出,如果相鄰字符重復情況越高,則壓縮效率就較高。經過行程編碼,JPEG圖像被大幅度的壓縮了,如上面序列變成下面序列:

(1,1) (-9,1) (3,1) (0,60)    bit數≈8*8bit 

看到沒有數據壓縮了將近8倍。如果到這里就結束,JPEG的壓縮率已經很高了,但其實並沒完,真正的AC分量在這里的是行程編碼,查詢分類列表和霍夫曼編碼的綜合運用,在這一塊各種概念錯綜復雜,看的我很頭疼,對於如何將AC分量的編碼寫入數據流中,這里先按下不表,正所謂花開兩朵各表一枝,我們再說DC分量的情況。

1.3 DC分量的差分編碼及霍夫曼編碼

我之前在網上搜關於差分編碼DPCM的相關信息,坦白說完全看不懂,但是在JPEG這里的實現非常簡單,無非就是記錄兩個DC分量的差值,所以略過DPCM的復雜背景,我們直接看DC分量如何編碼的就行:

首先我們要知道,將比如-6這個數值寫入數據流中,需要兩次編碼,第一次先查詢一個標准分類表,該表在AC分量中也會使用,該表如下:

                  實際數值

類別

編碼

0

-

-1,1

1

0,1

-3,-2,2,3 

2

00,01,10,11

-7,-6,-5,-4,4,5,6,7 

3

000,001,010,011,100,101,110,111

-15,……,-8,8,……,15

4

0000,……,0111,1000,……,1111

-31,……,-16,16,……,31

5

00000,……,01111,10000,……,11111

-63,……,-32,32,……,63

6

……

-127,……,-64,64,……,127 

7

……

-255,……,-128,128,……,255

8

……

-511,……,-256,256,……,511

9

……

-1023,……,-512,512,……,1023

10

……

-2047,……,-1024,1024,……,2047

11

……

-4095,……,-2048,2048,……,4095

12

……

-8191,……,-4096,4096,……,8191

13

……

-16383,……,-8192,8192,……,16383

14

……

-32767,……,-16384,16384,……,32767 

15

……

標准分類表

注意這種表的輸入和輸出,輸入是實際的數值比如-5、23等,輸出是這個數值的類別和它在類別中的編碼,比如輸入-5,輸出就是(3,010),你也可以這么理解這張表,就是一個數值如果是正數,直接轉換成二進制就是它的編碼,類別就是二進制編碼的長度;如果是負數,取反碼,類別同樣是反碼的長度,在程序中就是這么求出標准分類表的。

 回到編碼上來,我們先將數據塊中的DC分量與上一個數據塊中的DC分量做差,比如得出一個值為-6,然后我們查詢標准該表,在類別為3的數據中用“001”表示,於是我們往數據流中寫入“huftab(3),001”。huftab(3)表示類別為3的霍夫曼編碼,001就是表中的編碼。問題來了,3的霍夫曼編碼是多少呢?這就看不同的代碼實現了,如果我們使用標准的霍夫曼DC編碼表,參考JPEG國際標准可以看到光亮度DC差值霍夫曼表如下(色度表略過):

光亮度DC差值霍夫曼表

然后我們就知道類別3的代碼字為“100”,所以-6這個字我們會寫成“100001”,現在編碼成了6bit,所以這個步驟中也有壓縮,但是如果數字過大比方說32767 ,那么不僅不會減小比特數,還會比原來更大,但是根據統計,一般不會出現超過長度為10的情況。

另外有一個問題就是我們使用的是標准霍夫曼編碼表,它是根據統計制作的,從解碼的角度考慮,存儲的時候哈夫曼編碼條目不一定能夠還原回去,所以有的編碼器使用標准霍夫曼表編碼,而有的編碼器則要生成自己的霍夫曼編碼表。

1.4 AC分量的霍夫曼編碼

 我們單獨再舉個例子來解釋,有一個zig變換后的序列:

1 -9 3 0 0 0 5 5....0 

我們通過查詢分類表和行程編碼可以編碼如下(注意這里的行程編碼是記錄前面有多少位0值):

(huftab(0/1),1)  (huftab(0/4),0110) (hufftab(0/2),11) (hufftab(3/3),101) (hufftab(0/3),101) (huf(EOB))

我們先從第一個值1開始說明,1在分類表中表里面屬於類別1,編碼1,前面有0個零值所以編碼是huftab(0/1),1 。huftab(0/1)代表的是0/1在霍夫曼表中的編碼。

第二個值是-9在分類表中屬於類別4,編碼0110,前面有0個零值所以編碼為 huftab(0/4),0110

同理3在分類表中屬於類別2,編碼11,前面有0個零值所以編碼為 hufftab(0/2),11

然后我們看下一個值是5,類別3,編碼101,前面有3個零值,所以編碼為hufftab(3/3),101

下一個5的類別3,編碼101,前面0個零值,所以編碼為hufftab(0/3),101

在此之后標記都為0,就發生EOB的霍夫曼編碼。

這里還有個問題,huftab(0/1),huftab(0/4),huf(EOB)等等這些編碼到底是什么,這里依然看各自代碼實現,大部分選中的是查詢標准霍夫曼AC編碼表,

我這里是部分光亮度AC系數的霍夫曼表,全部的表很長,這里只是一部分:

光亮度AC系數霍夫曼表

所以上面序列可以完全編碼為:001 10110110 0111 111111110101101 100101 1010  (空格是我自己添加的,方便閱讀,實際沒有),大家可以數一下不到30個bit,比起63*8bit少了太多了。

2.代碼實現

這里的代碼寫的非常簡單了,先創建了一個標准分類表:

     /// <summary>
        /// Gets the counts the number of bits needed to hold an integer.
        /// </summary>
        // The C# compiler emits this as a compile-time constant embedded in the PE file.
        // This is effectively compiled down to: return new ReadOnlySpan<byte>(&data, length)
        // More details can be found: https://github.com/dotnet/roslyn/pull/24621
        private static ReadOnlySpan<byte> BitCountLut => new byte[]
            {
                0, 1, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 4, 4, 4, 4, 5, 5, 5, 5, 5, 5,
                5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
                6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6, 6,
                7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
                7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
                7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7,
                7, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
                8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
                8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
                8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
                8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
                8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8, 8,
                8, 8, 8,
            };

這個數組大家仔細看是不是和我們上面的標准分類的排列非常類似,程序使用查表的方法,將輸入的數字轉換為類別,然后再根據不同的霍夫曼表類型,查詢不同的霍夫曼表,霍夫曼標准表定義在HuffmanSpec這個結構體重,最后寫入outputStream中。

 1         /// <summary>
 2         /// Emits a run of runLength copies of value encoded with the given Huffman encoder.
 3      /// 將值寫入霍夫曼行程編碼中,這里將DC的也強行寫到這個方法里面來有點不妥吧,然后用HuffIndex來區分是哪種表
 4      /// </summary>
 5         /// <param name="index">The index of the Huffman encoder(枚舉類型:DC亮度,AC亮度,DC色度,AC色度)</param>
 6         /// <param name="runLength">The number of copies to encode(前面有多少個零值)</param>
 7         /// <param name="value">The value to encode(這個系數實際值)</param>
 8         [MethodImpl(MethodImplOptions.AggressiveInlining)]
 9         private void EmitHuffRLE(HuffIndex index, int runLength, int value)
10         {
11             int a = value;
12             int b = value;
13             if (a < 0)
14             {
15                 a = -value;
16                 b = value - 1;
17             }
18        //value輸入的值,bt輸出類別
19             uint bt;
20             if (a < 0x100)
21             {
22                 bt = BitCountLut[a];
23             }
24             else
25             {
26                 bt = 8 + (uint)BitCountLut[a >> 8];
27             }
28        //如果是AC,將行程長度與類別合成霍夫曼編碼,寫入數據流
29        //如果是DC,行程長度為0,與類別合成霍夫曼編碼,寫入數據流
30             this.EmitHuff(index, (int)((uint)(runLength << 4) | bt));
31             if (bt > 0)
32             {
33           //如果類別不是0,那么將這個標准分類code寫入數據流
34                 this.Emit((uint)b & (uint)((1 << ((int)bt)) - 1), bt);
35             }
36         }    
View Code

在看一下EmitHuff這個方法,這個方法就是根據DC亮度、AC亮度、DC色度、AC色度等查詢不同的霍夫曼表,最后寫入數據流:

 1  /// <summary>
 2         /// Emits the given value with the given Huffman encoder.
 3         /// </summary>
 4         /// <param name="index">The index of the Huffman encoder</param>
 5         /// <param name="value">The value to encode.</param>
 6         [MethodImpl(MethodImplOptions.AggressiveInlining)]
 7         private void EmitHuff(HuffIndex index, int value)
 8         {
 9             uint x = HuffmanLut.TheHuffmanLut[(int)index].Values[value];
10             this.Emit(x & ((1 << 24) - 1), x >> 24);
11         }
View Code

最后解釋一下Emit這個方法,我們都知道JPEG格式是按照二進制來存儲數據的,但我們都知道,向數據流中寫入最小單位必須是byte也就是8個bit,所以Emit這個方法最主要作用就是讀取當前要存數據的長度,如果不足8bit,就將剩余數據放進accumulatedBits字段中,等待下次執行Emit方法時,拼湊成8bit再寫入。

 1    /// <summary>
 2         /// Emits the least significant count of bits of bits to the bit-stream.
 3         /// The precondition is bits
 4         /// <example>
 5         /// &lt; 1&lt;&lt;nBits &amp;&amp; nBits &lt;= 16
 6         /// </example>
 7         /// .
 8         /// </summary>
 9         /// <param name="bits">The packed bits.</param>
10         /// <param name="count">The number of bits</param>
11         private void Emit(uint bits, uint count)
12         {
13             count += this.bitCount;
14             bits <<= (int)(32 - count);
15             bits |= this.accumulatedBits;
16 
17             // Only write if more than 8 bits.
18             if (count >= 8)
19             {
20                 // Track length
21                 int len = 0;
22                 while (count >= 8)
23                 {
24                     byte b = (byte)(bits >> 24);
25                     this.emitBuffer[len++] = b;
26                     if (b == 0xff)
27                     {
28                         this.emitBuffer[len++] = 0x00;
29                     }
30 
31                     bits <<= 8;
32                     count -= 8;
33                 }
34 
35                 if (len > 0)
36                 {
37                     this.outputStream.Write(this.emitBuffer, 0, len);
38                 }
39             }
40 
41             this.accumulatedBits = bits;
42             this.bitCount = count;
43         }
View Code

3.最后的話

這一章結束了,基本上JPEG的編碼也講解完了,其中用到的霍夫曼編碼、行程編碼等算法知識,本身並不難但在JPEG的用法和我們單獨學習的很不一樣,所以看起來確實不太好理解。能力一般水平有限,在表達當中難免有失妥當,還請各位多加理解。

后續准備嘗試編譯guetzli這個項目,和ImageSharp的編碼解碼做一個對比。

系列目錄:

ImageSharp源碼詳解之JPEG編碼原理(1)JPEG介紹

ImageSharp源碼詳解之JPEG編碼原理(2)采樣

ImageSharp源碼詳解之JPEG壓縮原理(3)DCT變換

ImageSharp源碼詳解之JPEG壓縮原理(4)量化

ImageSharp源碼詳解之JPEG壓縮原理(5)熵編碼

ImageSharp源碼詳解之JPEG壓縮原理(6)C#源碼解析及調試技巧

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM