熵編碼這一過程可以算是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 |
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 }
在看一下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 }
最后解釋一下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 /// < 1<<nBits && nBits <= 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 }
3.最后的話
這一章結束了,基本上JPEG的編碼也講解完了,其中用到的霍夫曼編碼、行程編碼等算法知識,本身並不難但在JPEG的用法和我們單獨學習的很不一樣,所以看起來確實不太好理解。能力一般水平有限,在表達當中難免有失妥當,還請各位多加理解。
后續准備嘗試編譯guetzli這個項目,和ImageSharp的編碼解碼做一個對比。
系列目錄:
ImageSharp源碼詳解之JPEG編碼原理(1)JPEG介紹
ImageSharp源碼詳解之JPEG壓縮原理(3)DCT變換
ImageSharp源碼詳解之JPEG壓縮原理(4)量化
ImageSharp源碼詳解之JPEG壓縮原理(6)C#源碼解析及調試技巧