之前做图片上传工具时,用户上传的照片动辄七八 MB,服务器处理起来很慢。我当时想:要不压缩一下再存?结果直接调了某个库的默认压缩参数,出来的图全是马赛克。运营反馈说"图糊了"我才意识到:JPEG 压缩不是调个参数那么简单,你得理解它到底在干什么。
后来我去研究了 JPEG 的完整流程,才发现它比我想象的要聪明得多——也坑得多。
JPEG 是有损压缩,但有损得有技巧
无损压缩像 ZIP,把文件里重复的数据找出来用更短的方式表示,解压后和原来一模一样。但照片这种东西,相邻像素本来就很接近,纯靠无损压缩,一张照片顶多缩到 60% 就到底了。
JPEG 的做法不一样。它利用人眼的视觉特性:人眼对亮度变化比对颜色变化敏感,对高频细节比对低频轮廓不那么敏感。
这个特性就是 JPEG 的切入点——你眼睛不那么在意的地方,我就扔掉一些信息。扔掉的信息永远找不回来了,但你看不太出来。
听上去有点狡猾,但 JPEG 在这方面做到了极致。一张 10MB 的照片,用 Q=75 的质量压缩,变成 1.5MB 左右,你在手机屏幕上根本看不出区别。只有在 100% 放大的时候才能发现一些纹理没了。
第一步,RGB 到 YCbCr 的转换
JPEG 不直接在 RGB 上做压缩。原因很简单:RGB 的三个通道都是"颜色",每个通道的细节对视觉的贡献不同。
YCbCr 颜色空间把信息拆成三部分:
- Y:亮度(Luma)— 就是黑白图像的信息
- Cb:蓝色色度(Chroma Blue)
- Cr:红色色度(Chroma Red)
转换公式是线性的。从 RGB 到 YCbCr:
Y = 0.299R + 0.587G + 0.114B
Cb = -0.169R - 0.331G + 0.500B + 128
Cr = 0.500R - 0.419G - 0.081B + 128
为什么绿色在亮度公式里权重最高(0.587)?因为人眼对绿色最敏感。这也是为什么拜耳滤镜的传感器里绿色像素是红蓝的两倍。
第二步,色度下采样,体积先砍一半
转换到 YCbCr 之后,JPEG 对色度通道(Cb 和 Cr)做了下采样。
最常见的采样格式是 4:2:0,意思是每 4 个亮度像素共享一组色度值。水平和垂直方向各缩小一半,Cb 和 Cr 的分辨率只有 Y 的四分之一。
这一步直接让数据量减少了 50%。而且你大概率看不出来,因为人眼对色度细节确实不敏感。
但这里有个例外就是文字截图。如果你把一张白底黑字的网页截屏存成 JPEG,文字边缘的色度信息被扔掉之后,会出现明显的彩色晕边。这就是为什么文字截图应该用 PNG 而不是 JPEG。我因此吃过一次亏——对内刊杂志扫描件用了 JPEG 压缩,结果文字边缘全糊了,最后重扫了一遍改用 PNG。
第三步,8×8 分块
接下来 JPEG 把图像切成 8×8 的小块,每个块独立处理。
8×8 这个尺寸不是随便选的。JPEG 标准制定的时候,工程师们试过 4×4、8×8、16×16。4×4 压缩率不够,16×16 的块效应太明显。8×8 是当时硬件能支持而且视觉质量还不错的平衡点。
每个块有 64 个像素值,接下来要对这 64 个值做 DCT 变换。
第四步,DCT 变换,从像素世界到频率世界
DCT(离散余弦变换)是整个 JPEG 的核心。它把 8×8 的像素块从"空间域"变换到"频率域"。
空间域的 64 个值代表"每个位置的亮度"。频率域的 64 个系数代表"不同频率分量的强度"。
具体来说,变换后的 64 个系数里:
- 左上角的系数(DC 系数)代表整个块的平均亮度
- 向右是水平方向的频率逐渐增高
- 向下是垂直方向的频率逐渐增高
- 右下角代表对角方向的高频细节
对于一张普通照片,大部分 8×8 块的像素变化都很平缓,所以 DCT 变换之后,低频系数(左上角)的值很大,高频系数(右下角)的值很小,很多接近 0。
第五步,量化,压缩的核心步骤
量化是 JPEG 唯一的有损步骤。前面的所有操作——色彩空间转换、DCT 变换——都不丢数据。量化这一步才开始丢弃信息。
量化的操作很简单:每个 DCT 系数除以对应的量化表值,然后四舍五入取整。
比如 DCT 系数是 800,量化表值是 16,量化后就是 800÷16 = 50。如果 DCT 系数是 8,量化表值是 16,量化后就是 0。
高频对应的量化表值很大,所以很多高频系数量化后变成 0。这就是 JPEG 压缩率高的根本原因。
JPEG 标准提供了两组量化表——一组给亮度通道,一组给色度通道。色度通道的量化步长更大(因为人眼对色度不敏感)。但标准也允许编码器自定义量化表,这就是"质量参数"的调节原理——质量参数高,量化表值小;质量参数低,量化表值大。
多说一句,这个阶段是"错误"累积的地方。量化后的系数再做反量化(乘以量化表值),原始值 800 变成 50×16=800,没有误差。但如果原始值是 8,量化后变成 0,反量化后还是 0,误差就是 8。这就是 JPEG 压缩不可逆的来源。
第六步,Zigzag 扫描与熵编码
量化后的矩阵有个特点:左下角(低频)有值,越往右下角(高频)越趋向于 0。JPEG 用 Zigzag 扫描把 2D 矩阵变成 1D 序列,让非零系数集中在前面,零集中在后面。
这种排列方式非常适合游程编码(RLE)。对 DC 系数(每个块的第一个值)用差分编码——只存当前块和上一个块的差值,因为相邻块的 DC 值很接近。对 AC 系数用游程编码——记录连续零的个数和非零值。
最后一步是 Huffman 编码。JPEG 标准预定义了两套 Huffman 表(一套用于 DC,一套用于 AC),但也允许编码器根据图像数据统计生成更优的 Huffman 表。算术编码也能用,但在实际应用里用得很少——据说是因为专利问题,而且 Huffman 已经足够好了。
一个奇怪的现象,多次保存为什么越来越糊
有人说 JPEG 图片每保存一次质量就下降一次,所以不能重复保存。这个说法对了一半。
如果你用同样的质量参数打开一张 JPEG 再保存,因为量化表的参数一致,被丢掉的高频信息不会再丢掉更多——理论上是这样。但问题出在两个地方:
第一,解压后的像素值经过逆 DCT 变换会有微小的舍入误差,再次压缩时 DCT 系数会有变化。这个误差很小,但累积多次后会变得可见。
第二,不同的 JPEG 编码器实现方式不同。你用 Photoshop 的 JPEG 编码器处理过的图片,再用浏览器自带的编码器重存一次,两者对同一个 8×8 块的计算可能有细微差异。这种差异会叠加。
我实际测过:一张照片用 Q=80 连续保存了 20 次,在第 5 次左右肉眼还能看出来一些细节丢失,但之后基本稳定了。不是说每次保存都会损失同样的量。
块效应,JPEG 最明显的软肋
JPEG 把图像分成 8×8 的块独立处理,这天然导致了块与块之间的不连续。量化越狠,块边界越明显。这就是我们常说的"马赛克"。
消除块效应有两个思路:
一是"去块效应滤波"。解码时在块边界做平滑处理。很多现代 JPEG 解码器都内置了这个功能。Mozilla 的 mozjpeg 项目在这方面做了大量优化,输出质量比 libjpeg 默认的高不少。
二是改用块之间重叠的变换。JPEG 2000 用小波变换代替了 DCT,彻底消除了块效应。但 JPEG 2000 的编码速度慢,复杂度高,而且专利授权问题让它始终没有普及。Chrome 和 Firefox 在 2017 年左右移除了 JPEG 2000 支持就是一个信号——这个格式已经事实上被放弃了。
WebP 和 AVIF 对 JPEG 做了什么改进
说 JPEG 就不能不提后来的改进者。
WebP 做的主要改动:用 4×4 到 16×16 的自适应块大小代替固定的 8×8 块,色度下采样支持更多选项,而且用了更先进的帧内预测——不是每个块独立编码,而是参考周围已编码的块来预测当前块。这就减少了块效应。
AVIF 更进一步。它用的 AV1 帧内编码比 WebP 的预测模式更丰富,压缩率比 JPEG 高了大约 50%。实测一张 5MB 的 JPEG 照片,用 AVIF 存不到 1MB,视觉质量差别极小。
但 AVIF 有两个问题:编码慢(比 JPEG 慢几十倍),浏览器兼容性还在路上。
实际做图片压缩的几个建议
根据我折腾图片压缩的经验:
什么时候用 JPEG:照片、复杂渐变、色彩丰富的图片。JPEG 在这些场景下压缩率高,肉眼几乎看不出区别。
什么时候避开 JPEG:文字截图、图标、线条图、需要透明背景的图。这些场景用 PNG 或 WebP 无损模式。
质量参数怎么选:对于网页用图,Q=75-85 是最佳区间。低于 60 会开始出现明显块效应,高于 95 文件体积增加很多但视觉提升微乎其微。
不要在图片上加多次 JPEG 压缩:如果你需要裁剪或调整,先在无损格式(PNG/TIFF)上做编辑,最后一次输出成 JPEG。
如果你需要在线压缩图片,也可用一些在线的图片压缩工具,除了支持调节质量参数还能实时预览,能对比不同压缩率下的视觉效果,比凭感觉调参数要靠谱一些。
写在最后
JPEG 能活三十多年,核心原因不是它做了什么"正确"的事,而是它做了一件极其聪明的事:在合适的地方丢掉了人眼不在乎的信息。之后的 WebP、AVIF 都沿着同样的思路在改进——更好的预测、更灵活的块划分、更好的熵编码。但基本框架还是 JPEG 那一套。