图片压缩这件事,有损和无损的背后

2025-10-25图片工具
分享到

刚做移动端 H5 页面开发经常遇到,里面嵌入 PNG 格式的功能截图场景。设计稿上看着干干净净,结果真机一打开图片加载等了快两秒才渲染出来。用 Chrome DevTools 的 Network 面板一看——那张截图 2.3 MB。

翻了代码才发现图片是从设计稿直接导出的,没做任何处理。开发文档里写了"需压缩图片",但没人意识到 2.3 MB 的 PNG 有什么问题——毕竟截图尺寸也不小。

试了 JPEG,文件是下来了,但截图里需要的透明背景直接变成了白色块。翻了 IndexedDB 的存储实现、研究了一番浏览器图片解码的性能差异之后,所以才决定把图片格式和压缩算法的原理好好梳理一下。

这篇文章就是那次深挖的笔记。

有损压缩 vs 无损压缩对比流程

有损和无损:两种截然不同的哲学

先说一个反直觉的事实:无损压缩的文件并不总是比有损的大。

我做过一个小测试。一张 800×600 的纯蓝色 UI 图标,PNG 存出来只有 1.2 KB。同一张图存成最高质量 JPEG 反而有 4 KB——因为 JPEG 的编码开销在纯色区域反而比 PNG 的 DEFLATE 更浪费。反过来,一张 1920×1080 的风景照用 PNG 存要 1.5 MB,JPEG 质量 85 只要 400 KB,肉眼还看不出区别。

这两种反差来自两种完全不同的取舍逻辑。

有损压缩的做法是舍弃人眼不敏感的信息。人眼对高频视觉细节不敏感,对颜色变化也不够敏感。JPEG 围绕这一点做了大量设计:图像经过 DCT 变换后,高频系数被大幅量化甚至直接归零。信息虽然丢了,但文件体积大幅缩小。

无损压缩的做法是找到数据中的冗余模式。PNG 不管图像内容多复杂,最终都必须逐像素精确还原。它分析像素之间的相关性,把重复的数据用更紧凑的方式重新编码。这个思路和 ZIP 压缩文本文件本质相同——只不过压缩对象是像素字节流而已。

一个丢信息换体积,一个找冗余换体积。路径不同,适用的场景也完全不同。

色彩空间转换:把亮度和颜色分开

JPEG 压缩的第一步不是 DCT,而是把图像从 RGB 转到 YCbCr 色彩空间。这一步本身没有压缩,但它为后面的有损压缩创造了条件。

RGB 的三个通道——红、绿、蓝——相关性非常强。光照变化会在三个通道同时引起变化。而且人眼对 R、G、B 的敏感度也不一样。

YCbCr 把图像信息拆成三部分:Y 是亮度(Luma),Cb 和 Cr 是色度(Chroma)。Y 分量携带了大部分人眼感知的图像信息——边缘、纹理、明暗变化。Cb 和 Cr 只携带颜色偏移信息。

分离之后做色度下采样。最常见的是 4:2:0 模式:每 2×2 的像素块保留 4 个亮度值,但色度值只保留 1 个——四个像素共享一组 CbCr。色度数据被砍掉了 75%,人眼还察觉不到。4:2:2 模式保留的水平分辨率更高,每两个像素共享一组色度,砍掉 50%。4:4:4 不做下采样,保留完整色度信息。

色度下采样本身就是有损的,但大多数人根本看不出来区别,除非在专业显示器上对比原始图像。这个特性被 JPEG 充分利用了。

DCT 变换:从空间域到频率域

色彩空间转换和下采样完成后,图像被分割成互不重叠的 8×8 像素块。每个块独立处理。如果图像的宽或高不是 8 的倍数,边缘需要复制最后一行的像素做填充。

离散余弦变换是 JPEG 最核心的一步。

DCT 的数学本质是把一个 8×8 的空间域像素矩阵,转换成一个 8×8 的频率域系数矩阵。空间域里每个位置代表一个像素的亮度值,频率域里每个位置代表一个特定的频率分量。

DCT-II 的公式长这样(理解意思就行,不需要记住):

F(u,v) = (1/4) * C(u) * C(v) * Σx Σy f(x,y) * cos[...] * cos[...]

其中 u 和 v 是频率坐标,x 和 y 是像素坐标。C(u) 在 u=0 时是 1/√2,否则是 1。

直观来说,这个公式算的是:图像信号和每个频率分量的"相似度"。如果图像某个区域包含和某频率分量相似的图案,对应的系数就大。

变换结果里,左上角的系数 F(0,0) 叫 DC 系数,代表 8×8 块里 64 个像素的平均亮度值。其余的 63 个叫 AC 系数,代表不同方向和频率的细节。

低频分量对应图像中大面积的平滑区域——天空、墙壁、皮肤。高频分量对应边缘、纹理、噪点。典型图像的能量集中在低频区域,所以 DCT 系数矩阵的左上角数值大,右下角数值小。这就是 DCT 的"能量集中"特性——大部分图像信息压缩到少数低频系数里,高频系数接近零。后面的量化步骤正是利用了这个特性来压缩数据。

DCT 本身不丢失任何信息。它是一个可逆的正交变换——把 DCT 系数做逆变换(IDCT),能完美还原原始像素值。JPEG 的"有损"不是 DCT 导致的,是后面的量化步骤造成的。

JPEG 压缩流程

量化:有损压缩唯一的信息损失点

量化是 JPEG 压缩链条里唯一真正丢弃信息的环节。前面的色彩空间转换和 DCT 都没有丢数据,量化才是砍信息的地方。

JPEG 标准定义的亮度量化矩阵(8×8 完整版):

 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

量化操作是把 DCT 变换后的每个系数除以量化矩阵中对应位置的值,然后四舍五入取整。

看一个具体例子。假设 DCT 后 8×8 块左上角四个系数是:

原始 DCT 系数:    量化矩阵:     量化结果:
150   20           16   11        9   1
-30   5            10   12       -3   0

右下角的 5 除以 12 之后变成了 0。绝对值小的系数在量化后直接归零——这就是"有损"在具体数字层面的表现。

左上角的量化步长小,低频系数被保留得精细。右下角的量化步长大,高频系数大部分变成零。量化矩阵的设计参考了人眼视觉系统(HVS)的对比度敏感函数——人眼对高频细节的失真不敏感,所以允许更大的量化步长。

JPEG 的"质量参数"本质上就是量化矩阵的缩放因子。质量 100 时所有步长变成 1(理论上无损,但色度下采样造成的损失无法恢复)。质量 50 用默认量化表。质量 10 时步长放大到约 5-10 倍,大量系数归零,文件极小但块效应严重到不忍直视。

区分一下:Q=95 适合摄影作品存档,肉眼和原始图像几乎无法区分。Q=80 适合 Web 照片,文件小效果好。Q=50 适合缩略图或网速有限的场景。Q 低于 20 时图像质量已经明显劣化,屏幕上的块效应清晰可见。

量化后的系数矩阵有一个非常鲜明的特征:左上角聚集了少量非零值,右下角几乎全是零。这种结构对后续的熵编码极其有利。

之字形扫描和熵编码

量化之后需要把 8×8 的二维系数矩阵转成一维序列,然后做无损压缩。

读取顺序不按行也不按列,用的是之字形(Zigzag)扫描。从 DC 系数出发,沿着对角线方向扫完整张矩阵。这样做的目的是把大概率非零的低频系数排在前面,把大概率为零的高频系数集中在后面。

扫描结果类似这样:

9, 1, -3, 0, 1, 0, 0, 0, 2, 0, 0, 0, 0, 0, 0, 0, ...

后面跟了一大串连续零值。这种序列用行程编码(RLE)压缩非常高效。RLE 把序列编码成(零的个数, 下一个非零值)配对:

(0, 9) (0, 1) (0, -3) (1, 1) (3, 2) (0, 0)

最后一个 (0, 0) 是块结束标记(EOB),告诉解码器剩余系数全是零,不用继续处理了。

最后一层是熵编码。JPEG 支持哈夫曼编码和算术编码两种方式。绝大多数编码器用哈夫曼编码,因为算术编码虽然压缩率高一点,但当年有专利问题,速度也慢。

哈夫曼编码的原理:统计所有符号的出现频率,频率高的用短编码字,频率低的用长编码字。给上面的每个配对分配一个唯一的二进制编码,最终的平均码长接近信息熵的理论下界。

解码过程就是编码的完整逆操作:解析哈夫曼编码 → 反行程编码 → 反量化 → IDCT → YCbCr 转 RGB。每一步都是可逆的,但量化阶段丢失的信息永远找不回来。

渐进式 JPEG 和基线 JPEG 的区别

JPEG 还支持一种渐进模式(Progressive JPEG),和默认的基线模式不同。渐进 JPEG 将 DCT 系数分批编码——先编码 DC 系数和少量低频 AC 系数,生成一个模糊的预览图,再逐步传输高频系数,使图像逐渐清晰。

渐进 JPEG 的文件体积通常比基线模式略大 5-10%,但用户体验更好:浏览器可以在数据加载到一半时就渲染出可识别的图像轮廓,而不是从上到下逐行刷新。网速慢的时候尤其明显。

PNG 的行过滤:差分的艺术

PNG 的压缩哲学和 JPEG 完全不同。它不放弃任何信息,解码后的数据必须和编码前逐字节一致。但未经处理的像素数据冗余度不高,直接用 DEFLATE 压缩效果很差。所以 PNG 在 DEFLATE 之前加了一个行过滤步骤作为预处理。

PNG 的文件结构也值得了解:一个 PNG 文件由多个数据块(chunk)组成。IHDR 块存放图像元数据——宽、高、位深度、颜色类型。IDAT 块存放压缩后的像素数据,可以有多个 IDAT 块。IEND 块标记文件结束。这种分块设计让解码器可以流式处理数据,不需要一次性加载整个文件到内存。

过滤的本质是对每一行像素做差分变换,把像素值的分布变得更紧凑。目标是让数值集中在零附近,减小动态范围,提高后续压缩的效率。

PNG 压缩流程

PNG 定义了 5 种过滤模式,作用于像素的原始字节流。

类型 0 — None:不做任何变换,原始字节直接交给 DEFLATE。很少用,只有在图像内容几乎随机时才有一点优势。

类型 1 — Sub:对每个字节,存储它和同行前一个字节的差值。假设一行像素值是 [100, 102, 101, 99, 100, ...],经过 Sub 后变成 [100, 2, -1, -2, 1, ...]。大多数值变得很小,集中在零附近。适合水平渐变的图像。

类型 2 — Up:存储当前字节和上一行对应位置字节的差值。利用行间的垂直相关性。书页扫描件里每行像素高度相似,Up 的效果就很好。

类型 3 — Average:用左侧和上方两个字节的平均值作为预测值,存实际值和预测值的差值。水平和垂直方向都利用,效果更均衡。

类型 4 — Paeth:用左侧(L)、上方(A)和左上角(LA)三个字节做 Paeth 预测器计算预测值。算法先算出三个候选预测值——L、A、L+A-LA 的某种组合——然后选择与图像局部梯度方向最接近的那个值。Paeth 在大多数自然图像上表现最稳定,是目前 PNG 编码器中默认使用或首选的模式。

来看一个简化的例子。假设图像有一行像素值 [120, 121, 119, 122, 120],Sub 过滤后变成 [120, 1, -2, 3, -2]。这些值的绝对值从原始的 120-122 范围缩小到 0-3 范围。DEFLATE 处理小数字效率高得多,因为小数字可以用更少的比特表示。

每行图像数据只能用一种过滤模式。编码器需要选择最优方案——但"最优"需要经过 DEFLATE 压缩才能准确知道。所以编码器通常用启发式规则或快速估算来做决策。常见的策略是:对同一行试用所有 5 种模式,选择输出数据中"和最小"的那个模式作为候选。

网上讨论"PNG 压缩优化"时,说的就是两件事:选什么过滤策略最合适,DEFLATE 的参数怎么调最优。

DEFLATE:两阶段压缩引擎

过滤后的数据进入 DEFLATE 压缩引擎。DEFLATE 是 Phil Katz 在 90 年代设计的算法,也是 ZIP 和 gzip 的底层。PNG 选择它原因很实际——它当时是最先进的无损压缩算法,而且是免专利费的。

DEFLATE 分两阶段工作。

第一阶段:LZ77 算法。

LZ77 维护一个滑动窗口,窗口大小通常是 32 KB。编码器扫描数据时,在窗口中查找当前待编码数据是否和之前出现过的数据匹配。匹配上了就输出(偏移量, 匹配长度)对,而不是输出原始数据。

看一个具体例子。假设窗口里已经有 ABCDEFG,当前要编码的是 EFGH。EFG 在窗口里距离当前位置 3 个字节的位置出现过,匹配长度是 3。所以 EFG 编码成 (3, 3),这个对只占 2 个整数的空间,而 EFG 占 3 个字节,省了 1 个字节。如果匹配长度更长——比如匹配了 20 个字节——用两个整数编码就能省下 18 个字节,压缩效果非常明显。

窗口大小 32 KB 意味着编码器只能在最近 32 KB 的历史数据里找匹配。超出这个范围的数据不参与匹配。这就是为什么 PNG 编码大图像时通常把数据分块处理——让窗口能覆盖到块内足够多的重复数据。如果窗口太小,远处的重复模式就找不到了。

过滤步骤对 LZ77 的帮助非常大。Sub 过滤让行内数值变化平缓,Up 过滤让行间差异变小。经过过滤后,相邻行的数据大概率高度相似,LZ77 找到长匹配的概率大幅提高。

DEFLATE 对匹配长度还有要求——最短匹配通常是 3 个字节。小于 3 字节的匹配不值得用(偏移, 长度)代替,因为这对本身的编码开销反而更大。

第二阶段:哈夫曼编码。

对 LZ77 输出的一串字面量值和(距离, 长度)对进行哈夫曼编码。和 JPEG 的哈夫曼编码本质相同——统计频率,变长编码。

DEFLATE 的压缩效果和图像内容强相关。一张由大面积纯色块组成的 UI 截图,经过过滤加 DEFLATE 可以压缩到原始体积的 10% 以下。一张颗粒感强、满是噪点的照片,DEFLATE 能做的非常有限——相邻像素之间没有相关性,过滤和 LZ77 都找不到足够多的重复模式,压缩比可能只有 1.2:1。

压缩率对比:量化的数据

为了给你一个量化的概念,我用几张典型图像做了测试:

图像类型原始 BMPPNGJPEG Q=85JPEG Q=50WebP(有损)
纯色渐变方块6 MB8 KB32 KB18 KB6 KB
1920×1080 风景照6 MB1.5 MB380 KB185 KB160 KB
UI 界面截图6 MB32 KB98 KB42 KB22 KB
文字扫描件6 MB52 KB120 KB58 KB35 KB

(实际数值因编码器实现和图像内容差异会有浮动,重点看量级对比。)

几个值得留意的点:

PNG 在"结构性"强的图像——纯色方块、UI 截图——上表现极好,压缩比甚至超过有损 JPEG。因为大面积重复的模式被过滤和 DEFLATE 有效利用了。同类颜色的连续像素经过 Sub 过滤变成接近零的差值,LZ77 可以高效地编码这些重复的接近零的值。

但在连续色调的照片上,PNG 远不如 JPEG。照片相邻像素的细微变化太多,PNG 找不到足够的冗余,过滤后的数据仍然保持了较大的动态范围。

WebP 在几乎所有场景都能达到最佳压缩比。代价是编码速度比 JPEG 慢 5-10 倍。如果图片不需要频繁重新编码,这个代价可以接受。

什么场景用哪个格式

我自己的选择策略,个人经验而言:

照片、艺术图、人物摄影 → JPEG 质量 80-85。文件小,画质损失肉眼不可见。Q=85 和 Q=100 在普通屏幕上根本看不出区别,但文件体积差了一倍。

需要透明背景的任何图像 → PNG 或 WebP 无损。JPEG 不支持 alpha 通道,透明区域在 JPEG 里会变成黑色或者白色,取决于背景色设置。

UI 界面截图、按钮图标 → PNG。有损压缩在纯色边缘会产生振铃效应——像素边界出现不该有的亮暗条纹——看起来非常廉价,一下子暴露了压缩痕迹。

Logo、含文字的截图 → PNG 无损。文字边缘的压缩伪影很刺眼,文字笔画的锐利边缘是 DCT 的高频成分,正好是量化阶段砍得最狠的部分。

Web 生产环境 → WebP 有损模式,备选 JPEG 做降级。用 <picture> 标签写两套资源,浏览器自动选择支持的格式。

动画图标 → WebP 动画。比 GIF 小 70% 以上,质量也更好。GIF 只有 8 位色深(256 种颜色),颜色平滑过渡会出现色带,WebP 动画支持真彩色。

格式转换在开发中经常发生。网上很多图片格式转换工具,常见格式互转,省去装各种软件的时间。

如果你是后端或者运维,给 CDN 配图片时要注意:WebP 的编码器质量参数和 JPEG 不完全对应。WebP 的 Q=80 效果通常等同于 JPEG 的 Q=90,所以迁移到 WebP 时可以适当降低质量参数来进一步减小文件体积。

WebP 和现代格式的演进

WebP 的压缩策略混合了 JPEG 和 PNG 的思路。有损模式下它用了基于 VP8 帧内预测的编码方法——把图像分成 16×16 的宏块,对每个宏块从多种预测模式中选择最优的(比如从上方像素预测、从左方像素预测),然后对预测残差做变换和量化。这种预测编码的思路和视频编码非常相似——实际上 VP8 本来就是视频编码标准。这也是 WebP 比 JPEG 压缩率高 25-35% 的根本原因。

无损模式下 WebP 用了一种叫"空间预测"的变换预处理,然后接 DEFLATE 压缩,但它加入了显式颜色缓存等扩展机制,让 LZ77 的匹配效率更高。无损 WebP 通常比 PNG 小 20% 左右。

AVIF 又往前走了一大步。它基于 AV1 视频编码的帧内帧技术,用更大的编码单元(最大 64×64)、更灵活的块划分方式——可以把一个大块递归划分成不同大小的小块,每个块选择独立的预测模式和变换方式。同等画质下 AVIF 比 WebP 再小 30% 左右。代价是编码速度慢得离谱——编码一张 AVIF 的时间可以编 3 到 5 张 WebP。

JPEG XL 是个人比较看好的方案。它支持无损和有损双模式,编码解码速度快得惊人,支持 HDR 和宽色域,压缩率接近 AVIF。它还有一个非常有用的特性——无损重构 JPEG 文件:你把 JPEG 转成 JPEG XL 后,可以再从 JPEG XL 还原出完全一样的 JPEG 文件,不会像其他格式转换那样引入额外的质量损失。可惜目前浏览器的原生支持还在推广中。

还有一个格式值得提:HEIF。它是苹果生态的主力格式,iPhone 拍的照片默认存成 HEIF。它基于 HEVC(H.265)视频编码的帧内预测,压缩效率比 JPEG 高一倍——同样画质下文件只有 JPEG 的一半。但 HEIF 的专利授权问题比较复杂,在 Web 上的普及一直不顺利。

实用主义的选择:现在做项目我把 WebP 当主力,JPEG 和 PNG 做 fallback。如果需要对图片压缩,一个简约的图片压缩工具还是很有必要,可以质量参数调节和实时预览,处理完可以直接导出。

小结

理解有损和无损压缩的区别之后,图片格式的选择就不再是一门玄学了。照片用 JPEG,截图用 PNG,Web 上线用 WebP,透明动画用 WebP 替代 GIF——每个选择的背后都有明确的算法依据。

踩过那一次加载 2.3 MB 截图的坑之后,在图片压缩这件事上就游刃有余了。