写一个简易的 JPEG 解码器

最近在学习 JPEG 文件格式,想着解码是一种很好的学习途径。因为相对于编码,解码关注的细节更少;同时有更多的核对手段,比如有现成的 JPEG 分析工具。

在查询了一番资料之后,发现自己过一遍解码流程并不需要太多专业知识:专业性的知识只有一个离散余弦变换,但是就算不了解背后的原理,直接套用公式,解码过程也能顺利进行。

JPEG 相关知识已经相当成熟,资料也特别多,以下是利用到的资料:

[1] ITU-T Recommendation T.81

[2] 吴嘉慧.JPEG图像解码方案[J].现代计算机,2007(03):49-53.

[3] 【GitHub】跟我寫 JPEG 解碼器

[4] 【Wiki】JPEG

尽管如此,在解码的过程中还是遇到了不少问题。本篇文章记录自己的学习过程,当时困扰自己的地方后续会用红框特别标出(因为可能会是易错的地方)。

最后需要说明的地方的是,JPEG 标准针对的情形特别多,本篇文章只考虑降采样为 4:2:0 的 baseline DCT 模式。

一、 JPEG 编码的总体流程

在了解 JPEG 解码的步骤之前,我们先简单了解一下编码需要经历的五个过程:

1. 离散余弦变换

2. 量化

3. ZigZag 之字形编码

4. 游程编码

5. 哈夫曼编码

离散余弦变换

第 1 步离散余弦变换的对象是各个颜色分量 8x8 的矩阵。这个 8x8 的矩阵,文档里称为术语 block。例如以下的一个 block

\(\begin{bmatrix} 52 & 55 & 61 & 66 & 70 & 61 & 64 & 73\\ 63 & 59 & 55 & 90 & 109 & 85 & 69 & 72\\ 62 & 59 & 68 & 113 & 144 & 104 & 66 & 73\\ 63 & 58 & 71 & 122 & 154 & 106 & 70 & 69\\ 67 & 61 & 68 & 104 & 126 & 88 & 68 & 70\\ 79 & 65 & 60 & 70 & 77 & 68 & 58 & 75\\ 85 & 71 & 64 & 59 & 55 & 61 & 65 & 83\\ 87 & 79 & 69 & 68 & 65 & 76 & 78 & 94\\ \end{bmatrix}\)

因为离散余弦变换的输入需要是偶函数,而图像的数据范围在 [0,255],因此将以上原始数据整体减去 128:

\(\begin{bmatrix} -76 & -73 & -67 & -62 & -58 & -67 & -64 & -55\\ -65 & -69 & -73 & -38 & -19 & -43 & -59 & -56\\ -66 & -69 & -60 & -15 & 16 & -24 & -62 & -55\\ -65 & -70 & -57 & -6 & 26 & -22 & -58 & -59\\ -61 & -67 & -60 & -24 & -2 & -40 & -60 & -58\\ -49 & -63 & -68 & -58 & -51 & -60 & -70 & -53\\ -43 & -57 & -64 & -69 & -73 & -67 & -63 & -45\\ -41 & -49 & -59 & -60 & -63 & -52 & -50 & -34\\ \end{bmatrix}\)

接着我们就可以进行离散余弦变换了,按照如下的公式进行计算:

\(G_{u,v}=\frac{1}{4}\alpha(u)\alpha(v)\sum\limits_{x=0}^{7}\sum\limits_{y=0}^{7}g_{x,y}cos[\frac{(2x+1)u\pi}{16}]cos[\frac{(2y+1)v\pi}{16}]\) \(\alpha(u)=\begin{cases} \frac{1}{\sqrt{2}},\ if\ u = 0\\ 1,\ otherwise\\ \end{cases}\)

计算得到的各个 DCT 系数如下:

\(\begin{bmatrix} -415.38 & -30.19 & -61.20 & 27.24 & 56.12 & -20.10 & -2.39 & 0.46\\ 4.47 & -21.86 & -60.76 & 10.25 & 13.15 & -7.09 & -8.54 & 4.88\\ -46.83 & 7.37 & 77.13 & -24.56 & -28.91 & 9.93 & 5.42 & -5.65\\ -48.53 & 12.07 & 34.10 & -14.76 & -10.24 & 6.30 & 1.83 & 1.95\\ 12.12 & -6.55 & -13.20 & -3.95 & -1.87 & 1.75 & -2.79 & 3.14\\ -7.73 & 2.91 & 2.38 & -5.94 & -2.38 & 0.94 & 4.30 & 1.85\\ -1.03 & 0.18 & 0.42 & -2.42 & -0.88 & -3.02 & 4.12 & -0.66\\ -0.17 & -0.14 & -1.07 & -4.19 & -1.17 & -0.10 & -0.50 & 1.68\\ \end{bmatrix}\)

最左上角的系数术语称作直流系数(DC coefficient),余下的 63 个系数称为交流系数(AC coefficient)。

量化

量化的概念比较简单。人眼对亮度信息敏感,但是对高频的亮度信息不敏感。而各个 DCT 系数记录的就是频率信息,量化的目的就是把高频信息去除掉,从而起到压缩的目的。

去除的程度,即压缩率,是通过量化表控制的。例如,如下是质量因子为 50% 所对应的量化表:

\(Q=\begin{bmatrix} 16 & 11 & 10 & 16 & 24 & 40 & 51 & 61\\ 12 & 12 & 14 & 19 & 26 & 58 & 60 & 55\\ 14 & 13 & 16 & 24 & 40 & 57 & 69 & 56\\ 14 & 17 & 22 & 29 & 51 & 87 & 80 & 62\\ 18 & 22 & 37 & 56 & 68 & 109 & 103 & 77\\ 24 & 35 & 55 & 64 & 81 & 104 & 113 & 92\\ 49 & 64 & 78 & 87 & 103 & 121 & 120 & 101\\ 72 & 92 & 95 & 98 & 112 & 100 & 103 & 99\\ \end{bmatrix}\)

我们将 DCT 的各个系数除以量化表对应的系数,并取四舍五入的结果。最终 DCT 各个系数变为:

\(\begin{bmatrix} -26 & -3 & -6 & 2 & 2 & -1 & 0 & 0\\ 0 & -2 & -4 & 1 & 1 & 0 & 0 & 0\\ -3 & 1 & 5 & -1 & -1 & 0 & 0 & 0\\ -3 & 1 & 2 & -1 & 0 & 0 & 0 & 0\\ 1 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\ 0 & 0 & 0 & 0 & 0 & 0 & 0 & 0\\ \end{bmatrix}\)

ZigZag 之字形编码

我们可以看到量化过后的系数矩阵中包含很多的 "0"。为了让有效的低频信息编码在前头,为了让多数被压缩为 0 的高频信息能够充分利用行程编码,之后需要对量化后的矩阵进行 ZigZag 编码。编码的顺序如下图所示:

图1 ZigZag 编码

压缩编码

在此之后的游程编码和哈夫曼编码是非常通用的压缩手段。游程编码因为过于简单,都没有相应的标准进行描述:比如数据 0x55 前面有连续的 8 个 0,就可以记作 (8,0x55)。

哈夫曼编码则是一种熵编码,将出现频率高的数据重新编码成短的编码,出现频率低的数据重新编码成长的编码,从而起到压缩的效果。

关于游程编码和哈夫曼编码更详细的介绍,我们在第二节(JPEG 文件格式)和第三节(JEPG 解码)中进行说明。

二、 JPEG 文件格式

在第一节中我们知道了 JPEG 是按照离散余弦变换、量化、ZigZag 之字形编码、游程编码和哈夫曼编码的步骤进行编码的。而 JPEG 解码的过程则是对称相反的,我们需要把游程编码和哈夫曼编码恢复成原来的数据、反 ZigZag、反量化和反离散余弦变换。

在知道大致解码流程后,我们还有一个重要问题:压缩数据存在哪里?量化表存在哪里?哈夫曼表存在哪里?游程编码和哈夫曼编码的具体规则是怎么样的?我们可以通过 JPEG 文件格式了解这些内容。这节的详细内容可参照 T.81 标准文档。

相比于 mp4 文件格式,jpeg 文件格式是非常“扁平化”的,没有太多“分支”,就是按照一段一段(术语称为 segment)进行布局的。如下就是一个简单 JPEG 文件按照顺序存在的段:

  • SOI (FFD8)
  • APP0 (FFE0)
  • APP1 (FFE1)
  • APP2 (FFE2)
  • DQT (FFDB)
  • SOF0 (FFC0)
  • DHT (FFC4)
  • SOS (FFDA)
  • EOI (FFD9)

每个段头都有一个标记(术语称为 marker)来指示段的类型。标记占两个字节,第一个字节均为 0xFF,第二个字节指示具体的类型。比如 FFD8 就代表 SOI(Start-Of-Image) 段,它指示图片信息的开始,一般文件开头都会是这个段。同样的,FFD9 就代表 EOI(End-Of-Image) 段,它指示图片信息的结束。解码还需要关注的段为 DQT、SOF0、DHT 和 SOS,其余和解码关联不大的段就暂不介绍了。

2.1 DQT - Define Quantization Table(s)

DQT 段中定义了量化表信息,各个参数布局如下:

所有参数长度:2 字节。长度不包括头部的两字节标记,但包括此参数字段。

表元素精度:4 比特。定义量化表中元素的精度,0 代表元素为 8 比特,1 代表元素为 16 比特。这篇文章只考虑 Baseline 模式,其值固定为 0,即元素只能为 8 比特。

表号:4 比特。表的 id,用于标识量化表,范围为 0~3,即最多只能有 4 张量化表。一般情况下都只有两张量化表,一张用于 Y 分量,一张用于 U、V 分量。

量化表:64 个元素。参数最后就是 64 个元素的量化表,Baseline 模式下占 64 字节。需要注意的是元素是按照 ZigZag 顺序排列的,所以在解码反 ZigZag 操作之前,就可以直接进行量化了。

DQT 中的量化表元素是 ZigZag 顺序。

通过 DQT 段,我们就能获取到各张量化表了。JPEG 文件中可以有多个 DQT 段包含各个量化表,也可以只有一个 DQT 段包含所有的量化表。

2.2 SOF0 - Baseline DCT Start Of Frame

SOFn 段定义了各种帧参数。之前说过这篇文章是针对 Baseline 模式的,而 SOF0 段就是用于 Baseline 模式的,这也是最常用的一种模式。如果你手头的 JPEG 文件中没有这个段,就代表它不是基于 Baseline 模式。SOF0 的各个参数布局如下:

帧头部长度:2 字节。长度不包括两字节标记,但包括此参数字段。

采样精度:1 字节。定义分量中的采样精度,单位为比特。一般值为 8,即 1 个字节。

行数:2 字节。图片的行数,可以简单理解为图片的高。

每行采样数:2 字节。可以简单理解为图片的宽。

分量格式:1 字节。图片中分量的个数。一般为 3 个,分别为 Y、U、V 分量。这个参数后面紧跟分量个数个分量信息。

分量水平采样因子:4 比特。概念同 YUV 采样因子。

分量垂直采样因子:4 比特。概念同 YUV 采样因子。

量化表号:1 字节。此分量所使用的量化表 id,这在之前的 DQT 中已经定义。

通过 SOF0 我们能知道很多信息,比如图片的宽高以及分量的精度。还可以知道每个分量使用哪张量化表。在文章开头,还提及只针对降采样为 4:2:0 的情况,这也是最常见的情况。我们可以通过此段中各分量的采样因子,对手头的 JPEG 文件进行核对一下,如果不是 4:1:1 就表示不满足条件。

2.3 DHT - Define Huffman Table

DHT 段中定义了哈夫曼表的信息,各个参数布局如下:

所有参数长度:2 字节。长度不包括头部的两字节标记,但包括此参数字段。

表类型:4 比特。0 代表此表用于 DC 分量;1 代表用于 AC 分量。

表号:4 比特。表 id,Baseline 模式范围为 0 ~ 1。

长度为 i 的哈夫曼码个数:i 为 1~16,每个元素 1 字节。标准文档规定哈夫曼码最长为 16 比特。

各哈夫曼码对应的值:每个元素 1 字节。长度为“长度为 i 的哈夫曼码个数”各项个数的和。

以上描述还是有些晦涩,举一个例子就会很清楚,以下数据:

0x00 0x01 0x05 0x01 0x01 0x01 0x01 0x01 0x01 0x00 0x00 0x00 0x00 0x00 0x00 0x00

0x00 0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09 0x0A 0x0B

首先第一行是 16 字节的数据,代表各长度哈夫曼码的数量。如果有的话,就依次从第二行取值。第一行的数值都加起来就是第二行的长度。以上数据按标准解析如下:

JPEGsnoop DHT 解析输出
  • Codes of length 01 bits (000 total):
  • Codes of length 02 bits (001 total): 00
  • Codes of length 03 bits (005 total): 01 02 03 04 05
  • Codes of length 04 bits (001 total): 06
  • Codes of length 05 bits (001 total): 07
  • Codes of length 06 bits (001 total): 08
  • Codes of length 07 bits (001 total): 09
  • Codes of length 08 bits (001 total): 0A
  • Codes of length 09 bits (001 total): 0B
  • Codes of length 10 bits (000 total):
  • Codes of length 11 bits (000 total):
  • Codes of length 12 bits (000 total):
  • Codes of length 13 bits (000 total):
  • Codes of length 14 bits (000 total):
  • Codes of length 15 bits (000 total):
  • Codes of length 16 bits (000 total):
  • Total number of codes: 012

还有一个问题,具体的哈夫曼码是什么?图 2 是 JPEG 中哈夫曼编码的例子,我们用这张图说明哈夫曼码如何按顺序生成的。首先二进制的哈夫曼树就是一根二叉树,只有叶子节点才有哈夫曼码,DHT 中定义的码长度其实就是树的高度。码的生成顺序是按照左子树优先编码的原则(即树会尽量靠右边生长),所以第一个码总是 0,即 0 或 00 或 000 ……。如果前一个码和后一个码是同一高度的话,那么后一个码就是前一个码 +1。如果是跨层的话,那么下一层的第一个码就是上层最后一个码 +1,再乘以 2 的高度差次方(即往后补零直到满足长度)。

图2 哈夫曼编码

按照以上编码方式,我们可以建立最终的哈夫曼表,即每个哈夫曼码对应一个值:

JPEGsnoop 哈夫曼表建立
  • Expanded Form of Codes:
  • Codes of length 02 bits:
  •   00 = 00                            (Total Len =  2)
  • Codes of length 03 bits:
  •   010 = 01                           (Total Len =  4)
  •   011 = 02                           (Total Len =  5)
  •   100 = 03                           (Total Len =  6)
  •   101 = 04                           (Total Len =  7)
  •   110 = 05                           (Total Len =  8)
  • Codes of length 04 bits:
  •   1110 = 06                          (Total Len = 10)
  • Codes of length 05 bits:
  •   11110 = 07                         (Total Len = 12)
  • Codes of length 06 bits:
  •   111110 = 08                        (Total Len = 14)
  • Codes of length 07 bits:
  •   1111110 = 09                       (Total Len = 16)
  • Codes of length 08 bits:
  •   11111110 = 0A                      (Total Len = 18)
  • Codes of length 09 bits:
  •   111111110 = 0B                     (Total Len = 20)

至此,我们就获取到了哈夫曼表。一般会有 4 个哈夫曼表,用于 Y 分量的 DCAC 的两个表;以及用于 U、V 分量的 DCAC 的两个表。还剩下一个问题是,哈夫曼码对应的值是什么含义?这个在第三节 JPEG 解码中再进行介绍。

2.4 SOS - Start Of Scan

SOS 段存储了压缩的数据,各个参数布局如下:

所有参数长度:2 字节。长度不包括头部的两字节标记,但包括此参数字段。

分量个数:1 字节。一般为 Y、U、V 3 个分量。后面紧跟分量个数个分量信息。

分量选择子:1 字节。这个就是对应在之前 SOF 段中的分量信息。

DC 表号:4 比特。这个就是对应在之前 DHT 段中的表 id。

AC 表号:4 比特。这个就是对应在之前 DHT 段中的表 id。

还有余下 SsSeAhAl 共计 3 个字节的字段,含义未知。

再往后就是需要解码的数据了,直到遇到 EOI 标记,解码数据结束。

三、 JPEG 解码

在第二节中我们已经可以获取到量化表、哈夫曼表和编码数据等信息,现在可以开始解码了。

首先要说明一下最小编码单元的概念,术语为 minimum coded unit(MCU)。第一节中已经说明了 block 的概念,它是最小的数据单元。因为 block 的大小为 8x8,但当降采样为 4:2:0 时,一组数据必须要有一组 U、V 分量,所以 mcu 需要是 16x16。此时 Y 分量是全采样,需要 4 个 block,而 U、V 分量正好需要一个 block,即此时一个 mcu 中有 6 个 block

编码总体顺序为 mcu 从左到右、从上到下依次编码;各个 mcu 中的 block 编码顺序为 blockY、blockY、blockY、blockY、blockU、blockV,即从左到右、从上到小的 4 个 Y 分量 block,接着是 1 个 U 分量 block,最后是一个 V 分量 block

以下依次说明解码的各个流程:哈夫曼和游程解码、反量化、反 ZigZag 和反离散余弦变换。

3.1 哈夫曼和游程解码

解码数据需要按照比特流处理,我们以下面数据说明哈夫曼解码和游程解码的过程:

0xFC 0x0C 0xBF 0xD1 0xEE 0xB4

对应的比特流为:

111111000000110010111111110100011110111010110100

一开始是 Y 分量的第一个 block,而 block 矩阵的第一个系数是 DC 系数。DC 系数和 AC 系数对应不同的哈夫曼表,所以我们首先使用 Y 分量的 DC 哈夫曼表。在表中依次匹配查找对应的哈夫曼码,依次查找即可,哈夫曼编码的性质保证前缀子码是不会冲突的。

DC 系数首先查表匹配到码 1111110,对应的值为 0x09。这里的值指明后续几比特是相应的数据,0x09 指明我们需要往后读取 9 比特的内容,此时我们读到 000001100。

111111000000110010111111110100011110111010110100

数据的编码也有讲究,编码规则如下,正数是负数的取反,负数首位为 0。因此 000001100 实际的值为 -499,即 block[0][0]=-499。

数据编码
长度(bit) 编码
-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
…… …… ……

注意,负数首位为 0,跟平常的编码不同。

紧接着需要读取 63 个 AC 系数,所以现在我们切换到 Y 分量的 AC 哈夫曼表。此时匹配到 1011,对应的值为 0x04。这边的值低 4 位指明后续几比特是相应的数据,高 4 位指明数据前有几个 0,这就是游程编码的表示。

111111000000110010111111110100011110111010110100

此时数据为 1111,即 15,0x04 高 4 位指明前面没有 0,即 block[0][1]=15。

至此,DC 系数和 AC 系数的解码过程都已经说明,后续的操作都是一样的。但是还有三处需要特别注意的地方:

1. AC 哈夫曼表值的如果为 0x00,不代表值 0 前面有 0 个 0,而是 EOB(End-Of-Block) 标记,它指示此时 block 的后续全部都是 0。

2. 解码数据是循环到 EOI 标记就结束的,同时还可以穿插一些额外的标记。这时候标记的 0xFF 和真实数据的 0xFF 会产生冲突,标准规定使用 0xFF00 来“转义”数据 0xFF。

读取编码数据的各处都要小心 0xFF00 的情况。

3. 标准中相邻 blockDC 系数变化不大,所以采用差分编码,即实际解码得到的 DC 系数是差值。各个分量的差分编码是独立,解码的时候需要注意恢复。如果各个 DC 系数组成的数组下标从 1 开始,那么 DC[0] = 0

\(DC[n] = DC[n-1] + diff\)

3.2 反量化

3.1 中得到的第一个 block 如下:

\(\begin{bmatrix} -499 & 0015 & -024 & 0010 & -019 & 0009 & 0002 & -006\\ 0008 & -006 & 0004 & -006 & 0006 & -002 & 0001 & 0000\\ -001 & 0001 & -003 & 0003 & -002 & 0000 & -001 & 0002\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & -001 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ \end{bmatrix}\)

使用 Y 分量对应的量化表进行量化,内容如下:

\(Q = \begin{bmatrix} 2 & 1 & 1 & 2 & 1 & 1 & 2 & 2\\ 2 & 2 & 2 & 2 & 2 & 2 & 3 & 5\\ 3 & 3 & 3 & 3 & 3 & 6 & 4 & 4\\ 3 & 5 & 7 & 6 & 7 & 7 & 7 & 6\\ 7 & 7 & 8 & 9 & 11 & 9 & 8 & 8\\ 10 & 8 & 7 & 7 & 10 & 13 & 10 & 10\\ 11 & 12 & 12 & 12 & 12 & 7 & 9 & 14\\ 15 & 13 & 12 & 14 & 11 & 12 & 12 & 12\\ \end{bmatrix}\)

因为量化表已经是 ZigZag 顺序了,所以我们可以直接将对应系数相乘,进行反量化,最终为:

\(\begin{bmatrix} -998 & 0015 & -024 & 0020 & -019 & 0009 & 0004 & -012\\ 0016 & -012 & 0008 & -012 & 0012 & -004 & 0003 & 0000\\ -003 & 0003 & -009 & 0009 & -006 & 0000 & -004 & 0008\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & -007 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ \end{bmatrix}\)

3.3 反 ZigZag

继续 3.2 节的内容,我们将这个矩阵反 ZigZag:

\(\begin{bmatrix} -998 & 0015 & 0009 & 0004 & 0003 & 0000 & 0000 & 0000\\ -024 & -019 & -012 & -004 & -003 & 0000 & 0000 & 0000\\ 0020 & 0016 & 0012 & 0003 & 0000 & 0000 & 0000 & 0000\\ -012 & -012 & -009 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0008 & 0009 & 0008 & 0000 & 0000 & 0000 & 0000 & 0000\\ -006 & -004 & -007 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000 & 0000\\ \end{bmatrix}\)

可以再将此矩阵按照 ZigZag 的顺序转回去,以此确定反 ZigZag 的过程是否正确。最好是手动检查,因为如果反 ZigZag 过程写错了,可能 ZigZag 过程也会写错。

3.4 反离散余弦变换

反离散余弦变换的操作和离散余弦变换的操作是对称的,公式如下:

\(f_{x,y}=\frac{1}{4}\sum\limits_{x=0}^{7}\sum\limits_{y=0}^{7}\alpha(u)\alpha(v)F_{u,v}cos[\frac{(2x+1)u\pi}{16}]cos[\frac{(2y+1)v\pi}{16}]\)

继续 3.3 的矩阵内容,我们在上面进行反离散余弦变换,得到:

\(\begin{bmatrix} -126 & -126 & -127 & -127 & -126 & -126 & -126 & -127\\ -125 & -125 & -126 & -127 & -127 & -126 & -126 & -125\\ -128 & -128 & -127 & -126 & -126 & -127 & -127 & -127\\ -127 & -127 & -127 & -126 & -126 & -128 & -128 & -128\\ -124 & -126 & -127 & -126 & -126 & -127 & -127 & -125\\ -126 & -128 & -128 & -125 & -124 & -125 & -127 & -127\\ -113 & -118 & -122 & -123 & -123 & -124 & -126 & -126\\ -085 & -097 & -112 & -122 & -125 & -125 & -122 & -119\\ \end{bmatrix}\)

接着需要 +128,使数据重新变回 [0,255]:

\(\begin{bmatrix} 0002 & 0002 & 0001 & 0001 & 0002 & 0002 & 0002 & 0001\\ 0003 & 0003 & 0002 & 0001 & 0001 & 0002 & 0002 & 0003\\ 0000 & 0000 & 0001 & 0002 & 0002 & 0001 & 0001 & 0001\\ 0001 & 0001 & 0001 & 0002 & 0002 & 0000 & 0000 & 0000\\ 0004 & 0002 & 0001 & 0002 & 0002 & 0001 & 0001 & 0003\\ 0002 & 0000 & 0000 & 0003 & 0004 & 0003 & 0001 & 0001\\ 0015 & 0010 & 0006 & 0005 & 0005 & 0004 & 0002 & 0002\\ 0043 & 0031 & 0016 & 0006 & 0003 & 0003 & 0006 & 0009\\ \end{bmatrix}\)

这时候需要注意溢出的情况,可能会遇到 +128 后不在 [0,255] 的情况,需要将其置为 0 或 255。

务必注意下溢和上溢的情况,否则图片会有明显的“坏点”。

3.5 重新组装

至此,各个 mcu 就可以解码出来了,最后我们需要将各个 mcu 重新组装成一张图片,比如一步到位直接转成 RGB 格式的图片。但是原始数据是 YUV 格式的,并且平常跟 YUV 格式打的交道更多,所以此处我直接将其提取组装成 NV12 格式的图片。

组装成 YUV 格式的数据还有一个好处是,能加深理解 YUV 格式:U、V 分量是如何共用的。NV12 格式如图 3 所示,其中背景相同颜色的 Y 分量共有相同颜色的 U、V 分量。从 16x16 的 mcu 进入到 8x8 的 block,最后是 2x2 的 Y 分量,各个分量的关系就是按照图 3 这样。

图3 NV12 格式

但是最小单元一下子变成了 16x16,就要务必注意二维图像布局转为一维数组的对应关系,稍有些差错,图片就不能正确显示了。

四、 总结

可以看出 JPEG 编码最重要的一步就是离散余弦变换,后续的一些操作都是通用的压缩技术,而不是和图像相关。

解码的过程细节其实可以不必特别关注,走一遍解码流程主要是了解一下相关段的定义和作用。

实验下来,感觉最难的一个过程是哈夫曼解码:编码数据是比特流,而且非常紧凑,即使是一比特的处理出错,后续就可能引起错误。而且错误大多数是滞后的,当发现某个 block 解码失败了,压根就不知道具体是从哪个 block 开始出错的。好在 JPEGsnoop 工具有正确的数据提供参照,可以打印自己程序的每步细节和 JPEGsnoop 的输出进行对比,判断是哪个 block 处理出错了。这也让我意识到一个问题,JPEG 貌似没有纠错和检查的机制,如果信道不好,即使一比特的出错也会让图片失效。