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#源码解析及调试技巧

 

posted @ 2019-08-04 16:54  程序员小张  阅读(2665)  评论(0编辑  收藏  举报