JPEG 编码器(JPEG Encoder)

Rust 手写的 JPEG 编码器编译成 WebAssembly,拖动质量滑块看 DCT、量化和 Huffman 编码如何换来更小的文件

原图(点击查看局部)
JPEG

拖动「质量」看文件大小和画质的变化  |  🖱️ 点击 / 👆 轻点 图片,下方显示那个 8×8 块从像素到 DCT 系数、量化、还原的全过程

📖 原理说明

JPEG 能把照片压到原来的十分之一甚至更小,靠的是「丢掉人眼不太在意的信息」:人眼对亮度比对颜色敏感,对平缓的变化比对细碎的纹理敏感。编码器先把颜色分离出来并降低分辨率,再把每个 8×8 的小块换成「频率」表示,粗略地保存高频成分,最后用 Huffman 编码把大量的 0 压缩掉。这个页面的编码器完全手写,生成的文件由浏览器自己的解码器显示。

🧮算法原理

F(u,v) = ¼ C(u)C(v) Σx,y f(x,y) cos((2x+1)uπ/16) cos((2y+1)vπ/16)二维离散余弦变换:把 8×8 像素换成 64 个频率分量
q(u,v) = round(F(u,v) / Q(u,v))量化:除以量化表再取整,JPEG 唯一丢失信息的一步
① 颜色空间与色度采样
RGB 先换成 YCbCr:Y 是亮度,Cb、Cr 是蓝、红色差。4:2:0 模式把两个色差通道的分辨率各减半,数据量直接少一半,照片里几乎看不出区别;但蓝底上的红字、彩色噪点这种颜色剧烈变化的地方,颜色会「渗」出去,放大图里看得很清楚。
② DCT
每个 8×8 块做一次离散余弦变换,得到 64 个系数:左上角是平均亮度(直流分量 DC),越往右下频率越高。平滑的区域能量几乎全集中在左上角的几个系数里。
③ 量化
每个系数除以量化表里对应的数再四舍五入。量化表是 JPEG 标准附录 K 给出的,高频位置的数更大,所以高频细节被粗略保存甚至直接变成 0;「质量」按 libjpeg 的公式整体缩放这张表。质量越低,0 越多,文件越小,块状和振铃(边缘附近的波纹)越明显。
④ 熵编码
系数按 Z 字形从低频到高频排列,让 0 集中到后面。DC 只记录与上一块的差值;交流系数(AC)记成「前面有几个 0 + 这个数有几位」再附上数值,最后一串 0 用一个「块结束」符号代替,这些符号再用标准 Huffman 表编码。压缩数据里出现的 0xFF 字节后面要补一个 0x00,以免被误认成文件标记。
文件格式
输出是标准的 JFIF 文件:SOI、APP0(JFIF 标识)、DQT(量化表)、SOF0(尺寸和采样方式)、DHT(Huffman 表)、SOS(扫描开始)、压缩数据、EOI。所以任何看图软件都能打开「下载」得到的文件。

🔄Rust 与 JavaScript 的分工

  1. JS拖动滑块时,JS 调用 jpeg_encode(像素, 宽, 高, 质量, 是否 4:2:0)。
  2. RustRust 完成颜色转换、色度采样、逐块 DCT、量化、Huffman 编码,返回完整的 .jpg 文件字节。点击图片时 jpeg_inspect 返回那个 8×8 块在每一步的数值。
  3. JSJS 把字节包装成 Blob 交给浏览器解码显示,再把解码结果与原图逐像素比较算出 PSNR。解码不经过我们的代码,所以能看的图片本身就证明编码是正确的。

⚡性能要点

  • 实测(桌面 Chromium):880×560 的示例图编码约 17~30ms(4:4:4 比 4:2:0 多处理一倍的色度数据)。
  • PSNR(峰值信噪比)越高越接近原图,30 dB 以上一般就不容易看出差别。示例图在 4:2:0 下 PSNR 偏低,主要是左下角那块彩色随机噪点:色度减半后它的颜色误差很大;换成 4:4:4,质量 90 时 PSNR 从 21.6 dB 升到 37.5 dB。
  • 验证:生成的文件用 Pillow(libjpeg)解码,在同样的质量和采样方式下,文件大小和 PSNR 与 libjpeg 自己编码的结果几乎相同(例如质量 50、4:4:4:11.0 KB / 29.33 dB 对 11.1 KB / 29.32 dB)。

源码crates/media/jpeg/src/encoder.rscrates/media/jpeg/src/huffman.rscrates/media/jpeg/src/tables.rswww/jpeg/index.js