1. 为什么要手动解析JPG一次原生读图引发的思考在日常开发里“读一张图片”几乎是所有语言里最简单的事情。Python里用PIL或者OpenCV一行代码就能把JPG变成ndarray再喂给matplotlib的imshow两秒钟出一张图。但真到了某些限制环境——比如内网离线服务器、纯标准库环境、或者你想在单片机级别的设备上解析图片——你会发现整个链路的黑盒瞬间变成了绊脚石。PIL装不上、OpenCV更是无从谈起而图片数据就在那里你睁眼看着它却读不出来。我这次做“手动解析jpg文件用matplotlib显示JPG”的起因就是朋友丢给我一批没有软件依赖的嵌入式截图要求在不安装任何第三方图像库的前提下用matplotlib把JPG显示出来。听到需求的第一反应是“这有什么难的”但很快意识到matplotlib只是个绘图库它并不解析JPG。它能显示的只有普通数组——RGB或RGBA矩阵。所以问题的本质变成了我需要自己写完JPG的解码链路把二进制文件里的压缩数据还原成一张像素矩阵再交给matplotlib去渲染。整个链路拆开大概是四步第一步解析JPEG文件容器把各个标记段切开第二步对熵编码数据做Huffman解码还原出8x8块的DCT系数第三步做反量化、逆离散余弦变换把频域数据变回空间域的Y分量和色度分量第四步做YCbCr到RGB的转换组合成最终像素矩阵。这四步每一步都有非常明确的输入输出非常适合拆开来讲。写这篇文章的核心目的不是劝你扔掉PIL而是希望你把“读图”从魔法变成知识。你不需要把每个字节都背下来但你应该理解JPG后缀只是一个壳里面存放的是一套基于DCT变换、量化、Huffman编码的复杂压缩方案。只有当你理解了这个方案遇到类似“微信dat文件怎么转成jpg”“怎么离线显示图片”“怎么在没有库的服务器上做图像预处理”这类问题时才会有真正的解决思路而不是四处找现成工具碰运气。这篇文章里我会给出可复现的手动解析过程包含关键代码片段、我在实现过程中踩过的坑、以及如何用matplotlib正确显示手动解码出来的RGB数组。无论你是图像处理初学者还是已经写了多年Python但从未拆过图片格式的工程师这个动手实验都会让眼前一亮。2. JPEG文件的字节级结构FFD8到FFD9的分段协议2.1 先从文件头开始标记段与“段”的概念JPEG文件本质上是一串按段Segment组织的二进制流。每个段以0xFF开头后面跟一个字节的标记码再后面跟两字节的段长度大端序长度包含自身两字节。文件最开始的两个字节固定是0xFF 0xD8称为SOIStart of Image文件结束是两个字节0xFF 0xD9称为EOIEnd of Image。中间的每一段都由“FF 标记 长度”开头一个紧挨一个排列。手动解析的第一步就是扫描这些标记段把我们需要的数据段提取出来。我写了一个最简单的段扫描器只用Python标准库的struct就能工作import struct def parse_jpeg_segments(data): segments [] pos 0 assert data[:2] b\xff\xd8, 不是有效的JPEG文件 pos 2 while pos len(data): if data[pos] ! 0xFF: pos 1 continue marker data[pos1] if marker 0xD9: # EOI segments.append((marker, None)) break seg_len struct.unpack(H, data[pos2:pos4])[0] segments.append((marker, data[pos4:pos2seg_len])) pos 2 seg_len return segments这段代码虽然简单但它体现了手动解析的核心理念不能假设文件里只有一段图像数据而是要按照协议一条一条地跳过非目标段直到找到图像数据流。2.2 常见段的认识SOF0、DQT、DHT、SOS分别存了什么东西不同标记段存着完全不同的信息我在这张表里总结了手动解码最常用到的几类标记名称关键内容解析后的用途FFE0APP0JFIF标识、版本、像素密度检查是否标准JFIF格式FFDBDQT量化表ID和64字节量化值反量化时逐系数使用FFC0SOF0图像宽高、分量数、各分量采样因子和量化表ID决定MCU布局和块的排列FFC4DHTHuffman表的位长分布和符号值构建解码用的Huffman树FFDASOS分量选择、每个分量使用的DC/AC表编号定位熵编码数据的开始位置FFD9EOI文件结束终止条件其中SOF0基线JPEGBaseline是最常见的帧头它里面记录了图像宽度、高度、颜色分量数量通常是3个Y、Cb、Cr以及每个分量的水平和垂直采样因子。这一小段二进制信息直接决定了后面MCU最小编码单元的排列规则。拿一个具体例子说明我用十六进制编辑器随便打开一张手机拍的照片在SOF0段能看到类似这样的字节精度0x088位、高度两个字节、宽度两个字节、分量数0x03。随后三个分量的描述每个占3字节分量ID、水平/垂直采样因子一个字节的高4位和低4位、以及使用的量化表ID。比如常见4:2:0采样时Y分量的采样因子是0x22表示水平和垂直都是2Cb和Cr的采样因子是0x11表示都是1。2.3 从SOS段开始后面的数据就不再是“段”了这里有一个非常关键的细节也是新手最容易踩的坑SOS段FFDA之后紧跟的就是真正的压缩图像数据流这段数据没有长度字段它一直持续到遇到FFD9标记才结束。但问题在于熵编码数据里完全可能自然出现0xFF字节JPEG规范的处理办法是字节填充Byte Stuffing在编码数据中凡是0xFF后面跟0x00那个0x00是填充字节需要丢弃只有0xFF后面跟D9才是真正的结束。所以在扫描熵编码数据时解码逻辑是每次读一个字节如果读到0xFF再读下一个字节如果下一个是0x00就丢掉继续如果是0xD9就表示图像结束如果是其他值比如D8那就要特别小心它可能是标记段混进来了。我在实现时写了一个单独的迭代器来处理这种填充规则def get_entropy_data(data, start_pos): out bytearray() i start_pos while i len(data): b data[i] i 1 if b 0xFF: b2 data[i] i 1 if b2 0x00: out.append(b) # 丢弃填充的0x00 continue elif b2 0xD9: break # 到达EOI else: # 其他标记一般不会出现在基线JPEG熵编码流中 continue out.append(b) return bytes(out)这一步如果没做好后面的Huffman解码会莫名其妙多出或丢失bit导致整个图像花掉我在调试阶段就被这个字节填充规则坑了好几个小时。3. 熵编码解码器Huffman树的构建与DC/AC系数恢复3.1 Huffman表的读取方式BITS和VALS的关系JPEG里定义了两类Huffman表DC表和AC表各可以有两张。DHT段的payload里分为两部分前半部分是16个字节的BITS数组表示码长为1到16位分别对应多少个符号后半部分是VALS数组按码长从小到大排列存放实际的符号值。读取规则不复杂但有细微之处。我用代码来说明这部分是我手动解码过程中第一个真正需要仔细对待的环节def parse_dht(data): tables {} pos 0 while pos len(data): info data[pos] table_class (info 4) 0xF # 0DC, 1AC table_id info 0xF pos 1 bits list(data[pos:pos16]) pos 16 vals_count sum(bits) vals list(data[pos:posvals_count]) pos vals_count tables[(table_class, table_id)] build_huff_lut(bits, vals) return tablesbuild_huff_lut的作用是把这个表转换成解码时能直接查表的映射关系。这里我选择了最直白的“逐位查找法”从1位开始逐个尝试增加码长判断当前读到的比特前缀是否已经落在某个码字区间内。3.2 解码DC系数差分编码的预测恢复JPEG对每个8x8块的DC系数做的不是直接编码而是做了差分编码——当前块的DC值减去上一个块的DC值得到的差值再编码。所以解码时不能一个个块独立恢复必须维护一个上一块的DC预测值每解完一个块就更新。DC差值的编码结构是先通过Huffman表解码出一个“长度值”比如可能是1到11然后接着从比特流里读取这么多位组成一个带符号的幅值。这里带符号的恢复规则是如果读到的值的高位是1直接作为正数如果高位是0则要减去2^length - 1得到一个负数。这个过程我建议写成独立函数因为它是整个JPG解压中边界条件最多的地方def decode_dc(reader, dc_huff_lut): length read_huff_value(reader, dc_huff_lut) if length 0: return 0 bits reader.read_bits(length) if length 0 and (bits (1 (length - 1))) 0: bits - (1 length) - 1 return bits3.3 解码AC系数零游程长度与幅值的组合AC系数是8x8块里除去左上角DC系数外其余63个系数的统称。JPEG的AC编码用了“游程长度长度”的组合方式一个Huffman码字解码出一个字节高4位表示这个块中连续零的个数低4位表示后面跟着的幅值的比特长度。如果低4位为0且高4位为0那么这个块剩下的所有AC系数全部为0用EOBEnd of Block表示如果高4位为15且低4位为0表示出现了16个连续零用ZRLZero Run Length表示块还需要继续解码。我用一个长度为64的数组来承接每个块的63个AC系数按Zig-Zag顺序填充。Zig-Zag顺序表是从JPEG规范里抄下来的经典63元素索引表它的作用是让低频系数排在前面、高频系数排在后面这也是压缩率的来源之一。AC解码的伪代码大致如下def decode_block_ac(reader, ac_huff_lut): coeffs [0] * 63 idx 0 while idx 63: rs read_huff_value(reader, ac_huff_lut) run rs 4 size rs 0xF if size 0 and run 0: break # EOB elif size 0 and run 15: idx 16 # ZRL continue else: idx run if idx 63: break bits reader.read_bits(size) if size 0 and (bits (1 (size - 1))) 0: bits - (1 size) - 1 coeffs[idx] bits idx 1 return coeffs这里要注意ZRL之后可能紧接着又是ZRL多个连续零游程可以跨过整段而EOB只出现在当前块剩余系数全部为零的时候。3.4 比特读取器的实现与小端序陷阱由于JPEG的熵编码流是按bit排列的我需要维护一个字节缓冲区和当前bit偏移。读取n位时从左往右取字节的高位到低位这是JPEG规范明确规定的MSB-first顺序。我在实现中发现最容易出错的地方是字节边界处的跨字节读如果当前偏移是5要读4位那么这4位分布在当前字节低位3位和下一个字节高位1位处理时必须小心翼翼。class BitReader: def __init__(self, data): self.data data self.pos 0 self.bitbuf 0 self.bitcnt 0 def read_bits(self, n): while self.bitcnt n: self.bitbuf (self.bitbuf 8) | self.data[self.pos] self.pos 1 self.bitcnt 8 val (self.bitbuf (self.bitcnt - n)) ((1 n) - 1) self.bitcnt - n return val这个实现用了一个简单的技巧把读进来的字节先塞进一个大的bitbuf然后用右移提取需要的n位。因为bitbuf累积可能会超过Python整数范围但Python对大整数天然支持所以代码上反而比C轻松很多但性能上要注意内部大整数位移的开销。对一张普通的1000x1000像素照片熵解码阶段要处理几十万个块性能依然可控几百毫秒就能完成。4. 从频域回到像素域反量化、IDCT与YUV转RGB4.1 反量化质量因子影响的是除法里的除数每解出一个块的64个DCT系数接下来要对照着之前从DQT段读出的量化表逐位置做除法。量化表里每个位置的值就是压缩过程中舍去的除数解码端只需要把系数乘回去就完成了反量化。注意量化表是8x8的矩阵按Zig-Zag顺序存放需要先把这64个值排回8x8网格。这里有一个很有意思的观察同一个JPEG文件如果质量因子Q值越大量化表里的除数越小系数保留得越精确Q值越小除数越大很多高频系数反量化后依然是0。这也是为什么低质量JPEG图片会出现“块状效应”的根本原因——高频细节已经被除没了。4.2 IDCT的工程实现别再手写双重循环直接用一维变换加转置8x8的逆离散余弦变换公式看起来是两个嵌套的求和F(x, y) (1/4) * Σu Σv C(u)C(v) f(u,v) * cos((2x1)uπ/16) * cos((2y1)vπ/16)其中C(0)1/√2其他C(u)1。地基原理上可以直接对64个点做双重循环但那种写法效率太低。更标准的做法是分离变换法先对每一行做一维IDCT再对每一列做一维IDCT。这样复杂度从O(n^4)降到了O(n^3)对于8x8的块来说循环次数减少了很多。我参考了经典的AANArai, Agui, Nakajima快速算法虽然它优化了乘法次数但在Python里节省的那点时间远不如代码清晰度重要。我用最直接的一维IDCT实现import math def idct_1d(coeffs): N 8 out [0.0] * N for x in range(N): s 0.0 for u in range(N): cu 1.0 / math.sqrt(2) if u 0 else 1.0 s cu * coeffs[u] * math.cos((2 * x 1) * u * math.pi / (2 * N)) out[x] s * math.sqrt(2.0 / N) return out def idct_2d(block): tmp [idct_1d(row) for row in block] tmp [list(col) for col in zip(*tmp)] tmp [idct_1d(row) for row in tmp] return [list(col) for col in zip(*tmp)]这里先做行变换再做转置目的是复用同一套一维变换函数。每做完一次IDCT需要加上128的偏移量因为JPEG的DCT系数是以128为中心的带符号整数再做饱和处理把结果夹在0到255之间。4.3 采样因子与MCU拼装4:2:0下如何把多个块拼成一个完整图像JPEG并不一定是逐像素地编码整幅图而是把图像切割成MCUMinimum Coded Unit逐个编码。MCU的大小取决于三个分量的采样因子。最常见的是4:2:0采样Y分量水平采样2、垂直采样2Cb和Cr水平采样1、垂直采样1。这意味着每个MCU里包含4个Y块、1个Cb块、1个Cr块总共6个8x8块。解码的时候我需要按MCU的行列顺序解出所有块然后把它们拼接回整幅图像。拼装时Y分量是满分辨率Cb和Cr分量是1/2分辨率宽高各减半。为了最终显示必须把Cb和Cr上采样到和Y一样的大小常见做法是最近邻或双线性插值。我在工程实现里用了最简单的最近邻放大每个色度像素对应2x2的亮度像素区域。这部分的代码框架是这样的def decode_mcu(y_data, cb_data, cr_data, mcu_row, mcu_col, sampling): # y_data 有4个块cb/cr各有1个块 # 每个块是8x8排列好后放入大的画布 y_block merge_blocks(y_data, 2, 2) # 水平2块垂直2块 - 16x16 cb_block cb_data[0] # 8x8 cr_block cr_data[0] # 上采样色度块到16x16 cb_big upsample_nearest(cb_block, 2) cr_big upsample_nearest(cr_block, 2) return y_block, cb_big, cr_big4.4 YCbCr转RGB官方公式和整数近似最后一步颜色转换把Y、Cb、Cr三个通道合并成RGB图像。最常用的是BT.601标准转换矩阵R Y 1.402 * (Cr - 128) G Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128) B Y 1.772 * (Cb - 128)为了方便计算我在代码里直接用浮点并且最后做clip0到255。如果你关心性能可以预先计算查表但在这个实验项目里浮点完全够用。做完这一步你已经拿到一个形状为(height, width, 3)的RGB数组了——这正是matplotlib想要的东西。5. matplotlib的最后一公里像素数组的正确投喂方式5.1 imshow的隐藏要求数组的shape、dtype和值域控制手动解码完成后最激动人心的时刻就是调matplotlib去显示。但这里有好几个坑不提前注意很容易显示成一片乱花或者直接报错。第一数组shape必须是(H, W, 3)对应RGB三个通道。如果你手滑把通道放在第一维3, H, Wimshow不会报错但显示出来的图像颜色完全错乱你会看到一幅被揉碎的三层重影图。第二dtype必须匹配。如果把像素值表示为0-255的整数那最好用uint8或int如果你做了浮点处理并且像素值在0-255范围外matplotlib会自动把小于0当0、大于1当1导致画面过曝或者一片漆黑。我建议在解码完成后统一用numpy的clip和astypergb np.clip(rgb_array, 0, 255).astype(np.uint8) plt.imshow(rgb) plt.axis(off) plt.show()第三值域的理解。matplotlib的imshow对float类型的数组默认把0映射为黑色、1映射为白色对uint8类型则默认0到255。如果你把float数组的值域放在0到255但忘了除以255画面就会过曝。这个错误很隐蔽尤其是在验证“解码后数组看起来一切正常”的时候。5.2 先转成RGB数组再显示绕开色彩映射的暗坑另一个常见错误是拿单通道灰度直接丢给imshow。如果你把Y通道作为二维数组传进去matplotlib会默认使用一种颜色映射通常是viridis出来的图像是一幅绿色渐变的伪彩色图而不是灰度照片。所以我总是习惯在解码阶段就完成YCbCr到RGB的合并而不是先显示各分量。不过如果你想验证解码器是否正确单独显示Y通道或者色度通道其实非常有帮助。调试的时候我会用plt.imshow(y_channel, cmapgray, vmin0, vmax255)这样能快速判断采样因子、IDCT这些环节有没有出问题。Y通道看起来是干净的黑白图像说明前面的熵解码和IDCT基本正确如果Y通道花掉那问题一定出在Huffman或反量化环节而不是最后颜色转换。5.3 离线环境下的依赖问题没有PIL时matplotlib自己就能喂数据很多读者会问matplotlib不是依赖numpy吗那我不用PIL但装numpy和matplotlib可行吗答案是可行的。这正是这个标题最有价值的地方——你不需要PIL、不需要OpenCV只需要matplotlib自带依赖的numpy就足够完成显示。在离线环境安装matplotlib时常见做法是把wheel包下载到本机再pip install。我在实际项目中验证过实现“手动解析JPG并显示”只需要Python标准库里的struct、math加上numpy和matplotlib一半的依赖是标准库自带另一半是matplotlib运行的基本环境。所以如果你能跑起来matplotlib你就能跑起来这套手动解码器。这里顺带分享一个经验在离线环境装包时先把matplotlib的wheel包放到本地目录然后执行pip install --no-index --find-links/your/path matplotlib这个命令不会去碰网络只从本地目录找包。装上之后手动解码器只需要标准库即可工作。6. 完整解码示例与排错实践那些让我踩了几个小时的坑6.1 一个可复现的最小解码示例从读文件到imshow上屏为了让你能直接照做我整理了一个最小可运行的示例框架。这个示例假设图片是基线JPEGBaseline JPEG这是最常见的JPG类型手机照片和网页图片基本都是它足够覆盖绝大多数场景。import struct import math import numpy as np import matplotlib.pyplot as plt def read_jpeg(path): with open(path, rb) as f: return f.read() def decode_jpeg_baseline(data): segments parse_jpeg_segments(data) dqt_tables {} sof None dht_tables {} entropy_start None for marker, payload in segments: if marker 0xDB: # 解析量化表 pid, qtab parse_dqt(payload) dqt_tables[pid] qtab elif marker 0xC0: sof parse_sof(payload) elif marker 0xC4: parse_dht_into_dict(payload, dht_tables) elif marker 0xDA: entropy_start_of_sos find_entropy_start(data, payload, marker) break # 拿到熵编码数据 entropy_data get_entropy_data(data, entropy_start) # 构造块解码器按MCU循环 h, w sof[height], sof[width] mcu_h 16 if sof[sampling] (2, 2) else 8 mcu_w mcu_h y_full np.zeros((h, w), dtypenp.float64) cb_up np.zeros((h, w), dtypenp.float64) cr_up np.zeros((h, w), dtypenp.float64) read_cursor BitReader(entropy_data) # 这里需要把上一块的DC差分预测值记录下来 prev_dc [0, 0, 0] for mcu_r in range(math.ceil(h / mcu_h)): for mcu_c in range(math.ceil(w / mcu_w)): y_blocks, cb_block, cr_block decode_mcu_coeffs(read_cursor, dht_tables, dqt_tables, prev_dc) y16, cb16, cr16 reconstruct_mcu_pixels(y_blocks, cb_block, cr_block, dqt_tables) # 粘贴到全图画布 put_mcu_into_canvas(y_full, y16, mcu_r, mcu_c, mcu_h, mcu_w, h, w) put_mcu_into_canvas(cb_up, cb16, mcu_r, mcu_c, mcu_h, mcu_w, h, w) put_mcu_into_canvas(cr_up, cr16, mcu_r, mcu_c, mcu_h, mcu_w, h, w) rgb ycbcr_to_rgb(y_full, cb_up, cr_up) return np.clip(rgb, 0, 255).astype(np.uint8) if __name__ __main__: raw read_jpeg(test.jpg) rgb decode_jpeg_baseline(raw) plt.imshow(rgb) plt.axis(off) plt.show()上面这段代码是一个高层次的框架每个函数里面都有很多细节。实际跑通时你还需要补齐parse_dqt、parse_sof、decode_mcu_coeffs、reconstruct_mcu_pixels这些函数。为了不让篇幅失控我这里不再逐行贴全量代码但逻辑顺序是完全可复现的。6.2 解码过程中常见的报错与根因分析我在手写解码器的过程中遇到的最典型的错误有三个。第一个是Huffman解码循环卡死或者返回明显的乱码块。这种情况八成是DHT表解析错了或者BitReader跨字节读取的实现有问题。排查办法是在解码前先打印MCU索引和当前比特读取器的pos值看看是不是提前进入了EOF。第二个是图像整体花掉但大致轮廓还能看出来。这种情况通常是采样因子和MCU拼装没对齐。比如你把宽度方向上的MCU数量算错了导致每个MCU粘贴时错位一行。排查的方法是打印y_full数组在某个MCU边界附近的值对比一下预期。第三个是颜色不对劲比如天空变成了紫色、草地变成了洋红。这种情况通常是YCbCr到RGB的转换公式里的色度符号写错了或者色度上采样用错了插值方式。你可以在转换前单通道显示Cb和Cr看看它们的均值和标准差是否合理。正常自然图片的Cb、Cr应该集中在128附近如果明显偏移或者出现大面积像素值极端的区域就会在RGB转换后产生严重的色偏。6.3 性能优化的小技巧numpy向量化代替逐块循环虽然手动解码器的目标是“能跑通”但如果你要把几百张图片都过一遍逐块循环的性能就不能忽视了。我在性能优化上做的主要工作是把8x8块的IDCT改造成了numpy批处理一次处理多个块而不是一个块一个块地循环。具体来说可以把整个图像所有块的DCT系数堆叠成一个(batch, 8, 8)的大数组然后对最后一维做一维IDCT再转置再对最后一维做一维IDCT。这样numpy的底层BLAS能大幅加速矩阵运算。实测下来一张1200x900的照片在纯Python逐块循环下需要大约3到5秒改成批处理numpy后能压到1秒以内。如果你只是实验逐块循环就够了如果要处理真实数据集强烈建议上numpy批处理。7. 一个意外收获微信dat文件转JPG的本质就是文件头识别7.1 微信dat文件为什么可以被“转换”成图片在做完手动解析之后我顺便研究了一个经常在搜索热词里出现的问题微信接收到的图片文件往往不是jpg后缀而是.dat后缀。很多人拿到这种文件第一反应是“坏了文件损坏了”。其实不是微信只是把原始图片文件的第一个字节做了一次异或加密整个文件内容其实还是完整的JPG结构。微信dat文件转JPG的思路非常简单一个正常的JPEG文件头必须是0xFF 0xD8而dat文件里对应位置是两个未知字节。由于微信使用的是一个固定的异或密钥对每个字节进行加密所以你只需要取出dat文件第一个字节和0xFF做异或就能算出密钥然后对文件的每个字节再做一次异或就能还原出原始JPEG。比如第一个字节是0x9E和0xFF异或得到0x61那密钥就是0x61。还原时对每个字节都异或0x61写出去的文件就是一个合法的JPG。这个过程可以用python写一个三行的脚本完成with open(image.dat, rb) as f: data f.read() key data[0] ^ 0xFF restored bytes(b ^ key for b in data) with open(restored.jpg, wb) as f: f.write(restored)7.2 文件格式识别的通用方法论签名、结构、内容三层递进微信dat文件转换的本质让我联想到一个更通用的方法论文件格式识别分为三层。第一层是签名识别Magic Number比如JPG的FFD8、PNG的89504E47只要文件头匹配就能识别第二层是结构识别比如JPEG的段结构、PNG的chunk结构即使文件头被加密只要结构还在就能通过已知结构尝试还原第三层是内容识别比如对一段没有头信息的裸码流你只能通过统计特征判断它可能是什么格式。手动解析JPG这个项目让你一次性经历了签名、结构、内容三层分析。当你对文件格式的“字节感”建立起来之后下次再遇到“dat转jpg”这类问题就会第一时间想到文件头异或这类处理方式而不是去网上搜某个专有工具。这种能力的提升是手动解码练习带给你最大的回报。7.3 我建议你进一步做的三个练习如果你想把手动解析JPG这个技能真正内化我建议你在跑通基线JPEG之后再尝试三个进阶练习第一个是解码灰度JPEG。灰度图只有一个Y分量没有Cb和Cr采样因子天然是1x1MCU就是一个8x8块。代码上几乎砍掉一半适合用来单独验证Huffman和IDCT的正确性。第二个是支持4:1:1或4:4:4采样。很多扫描仪或者摄影设备导出的JPEG并不只有4:2:0一种采样格式。你要学会从SOF0的采样因子动态计算MCU的块排列而不是写死。第三个是自己实现一个最简单的JPEG编码器。编码是解码的逆过程就是DCT、量化、Huffman编码、写入段。做一次编码器你对整个JPEG压缩原理的理解会再上一个台阶。在我把手动解析JPG和matplotlib显示完整跑通之后再回头看那些“PIL一行搞定”的方案感觉完全不同了。依赖库给你的是速度和便利但自己解析给你的是确定性和控制力。当你面对一个奇奇怪怪的图片流或者离线环境时这种控制力会让你非常从容。如果你也打算走一遍这条路建议从一张小尺寸、高画质的JPG开始逐步加大难度你会发现自己在图像格式上的理解比读十篇博客都管用。