我平时主要学习的是大语言模型相关的技术,计算机图像处理对我来说基本是盲区。最近想玩一玩计算机图像处理相关的技术,于是我尝试用像素手动拼了一张图片,一个像素一个像素指定颜色的那种。
我根据numpy和Pillow库的用法,写下了如下代码
import numpy as np
from PIL import Image
output = np.zeros((2, 2, 3), dtype=np.uint8)
output[0, 0] = [0, 0, 255] # 左上:蓝
output[0, 1] = [255, 0, 0] # 右上:红
output[1, 0] = [0, 255, 0] # 左下:绿
output[1, 1] = [255, 255, 255] # 右下:白
Image.fromarray(output).save('test.jpg')
以上代码拼出了一张2×2像素的RGB图片,因为图片的坐标系原点在左上角,所以这是一个左上为蓝色像素,右上为红色像素,左下为绿色像素,右下为白色像素。我对这张图片的每一个像素了如指掌,亲手放进去的,不可能有任何歧义。
然后我打开了保存的文件,颜色不对。

我盯着屏幕看了好几秒,以为是自己眼花。仔细一看,确实不对,蓝色、红色、绿色都变成了不可名状的奇怪颜色,只有白色还像点白色,但也不是纯白。
检查代码,没问题。搜 Pillow 有没有 bug,没有。
然后我在代码中将图片的保存格式从 .jpg 改成 .png
... # 前面代码都一样
Image.fromarray(output).save('test.png')
重新运行,重新打开新的png图片,结果颜色完全正确。
这就不禁让我起了好奇心,JPEG和PNG都是压缩图片的格式,它们分别是如何压缩图片的?为什么最终产生的结果完全不同?
JPEG 是“有损压缩”
一张图片,本质上是一大堆数字。
我们平时看到的是风景、人脸、天空、树叶,但对计算机来说,图片只是一个巨大的像素网格。每个像素通常由三个数字组成:红、绿、蓝。每个数字一般在 0 到 255 之间变化。比如 (255, 0, 0) 代表纯红,(255, 255, 255) 代表白色。
如果一张照片有四千万个像素,那么里面就有上亿个数字。JPEG 的任务,就是想办法用更少的数据来表示这些数字,让最终保存的文件尽量小。
但它和 ZIP 这种压缩方式不一样。ZIP 的要求是“一个字节都不能错”,压缩之后再解压,必须和原文件完全一致。JPEG 则采取了另一种思路:有些信息,即使删掉,人眼也很难察觉。既然如此,那就没必要把所有信息都原封不动地保存下来。
所以 JPEG 的本质,其实是:主动丢弃一部分视觉上不重要的信息。这也是它为什么叫“有损压缩”。
人眼其实不太在乎颜色细节
JPEG 首先利用了人眼的一个特点:
人对明暗变化非常敏感,但对颜色变化没那么敏感。
黑白照片虽然没有颜色,但细节依然清晰;而一张彩色照片,即使颜色稍微变得模糊一点,很多人也察觉不到。
因此 JPEG 会先把图片从 RGB 转换成另一种表示方式,叫 YCbCr。
RGB 很直观,就是红、绿、蓝三个通道;YCbCr 则把信息拆成了“亮度”和“颜色”两部分:
- Y 表示亮度,也就是这个像素有多亮
- Cb 表示颜色偏蓝多少
- Cr 表示颜色偏红多少
那绿色呢?因为 YCbCr 和 RGB 之间有固定的数学转换关系,Y(亮度)本身就是由红、绿、蓝按不同权重混合算出来的,比如 Y ≈ 0.299R + 0.587G + 0.114B,绿色占了大头。知道了 Y、Cb、Cr 之后,绿色自然可以反推出来,所以不需要单独保存。
这个转换本身并不会损失信息,它只是把“明暗”和“颜色”分开,方便后面区别对待。
接下来 JPEG 常会降低颜色信息的分辨率。
比如一个 2×2 的小区域,原本四个像素都拥有各自独立的颜色信息,现在可能只保留一套较粗略的颜色数据,让四个像素共享。
从这个角度看,可以理解为:颜色通道的“采样密度”被降低了。亮度仍然保持精细,但颜色细节被减少了。
这一步已经开始产生损失,但通常不太明显,因为人眼对颜色模糊本来就不敏感。
JPEG 最核心的一步:从“像素”转向“频率”
真正决定 JPEG 压缩效果的,是下一步。
JPEG 会把整张图片切成很多 8×8 的小方块,然后对每个方块做一种叫 DCT 的数学变换(离散余弦变换)。
DCT 的关键在“换坐标系”。
原本图像描述的是每个位置的像素是多少
DCT 会把它改写成:这个区域里,有多少“缓慢变化的成分”,又有多少“快速变化的成分”
这里引入一个核心概念:频率。
这里的频率不是声音的频率,而是图像变化的快慢:如果一个区域的变化很平滑,比如天空、墙面,那么就可以视为低频。如果一个区域变化很剧烈,比如文字边缘、纯色块边缘,那么就可以视为高频
DCT 并不会“识别图像内容”,它只是用一组固定的余弦波去展开图像。可以把它理解成:用 64种不同频率、不同方向的余弦函数,去拼出一块图像
一个 8×8 图像块有 64 个像素,可以视为64维向量。DCT 刚好提供 64 个互相独立的基函数(不同频率、不同方向的余弦波),理论上来说,64个互相独立的基就可以表示64维空间中的任何向量。所以DCT的结果实际上是这 64 个余弦函数对应的系数,可以视为8×8的矩阵:
- 矩阵左上角代表低频余弦波对应的系数
- 矩阵右下角代表高频余弦波对应的系数
自然图像通常有一个特点:相邻像素往往很接近。也就是说图像整体是“平滑”的。在数学上,这意味着:图像几乎没有快速震荡的成分。换句话说:在高频余弦波的方向上,投影很小
所以 DCT 后会出现:
- 低频系数较大
- 高频系数天然很小,比如可能是 1、2、3 这样的小数字,甚至可能出现大量的0
这里需要注意,DCT实际上是无损的。因为 64 个余弦函数基是完备的,那么从像素空间到频率空间的变换就是可逆的——也就是说,如果只做 DCT 和 IDCT(逆变换),不做其他操作,完全可以精确还原出原来的 64 个像素值,不会有任何损失。
量化:真正产生不可逆损失的地方
DCT 之后,JPEG会对每个系数做量化。具体做法是:把每个 DCT 系数,除以量化表中对应位置的一个数,然后四舍五入取整。
量化表是一个 8×8 的表格,它的特点非常关键:
- 左上角(对应低频),表格里的除数很小
- 右下角(对应高频),表格里的除数很大
这其实表达了一个策略:
- 低频(整体结构)要精细保留,所以除数小,精度高
- 高频(细节纹理)可以粗暴舍弃,所以除数大,精度低
举个直觉例子:
低频位置,DCT 系数是 120,量化表给 10:
120 ÷ 10 = 12
取整后是 12,保留了信息,只是去掉了不重要的尾数。
高频位置,DCT 系数本来就只有 3,量化表却给了 25:
3 ÷ 25 = 0.12 → 取整后变成 0
此外,在我们导出JPEG图像格式时,由一个quality(图像品质)参数可以调整,这个参数本质上做的就是缩放量化表的强度:
- quality 高 → 量化表整体缩小,除数变小 → 除得轻 → 高频保留更多
- quality 低 → 量化表整体放大,除数变大 → 除得狠 → 高频大量归零
小知识:“量化表”到底是怎么来的
这张量化表不是直接用数学方法算出来的,是人工调出来的。JPEG 的发明者当年做了大量心理物理学实验——找来很多测试者,给他们看不同压缩程度的图片,问"这两张你能看出区别吗",反复调整每个位置的除数,直到找到一组"压缩效果最大、但人眼刚好看不出损失"的数值组合。
这个过程本质上是在测量人眼的感知边界,不是数学推导出来的结论,而是实验测出来的经验值。
最终得到的那张表被写进了 1992 年的 JPEG 标准里,成了默认量化表。它背后有几条规律:
- 低频除数小,因为人眼对大块颜色变化敏感,这部分不能压太狠,否则图片会明显失真。
- 高频除数大,因为人眼对细节不敏感,这部分可以大量丢弃,人眼察觉不出来。
- 亮度和颜色用不同的表,颜色通道(Cb、Cr)整体比亮度通道(Y)压得更狠,因为人眼对颜色的分辨率本来就比亮度低。
JPEG 标准给出了一套推荐表,但并不强制,不同相机、软件都可以自己设计。
最后一步:Huffman 编码(无损)
当量化完成之后,JPEG 使用 Huffman 编码进行压缩,相信计算机专业的小伙伴对这套编码方式都非常熟悉
简单来说,Huffman编码的原理很简单:
- 出现频率高的值,用短编码
- 出现频率低的值,用长编码
由于高频部分的系数经过量化后0出现的特别多,所以0可以使用极短的编码来存,而其他较少出现的系数数字则会使用较长的编码来存。
这一阶段同样不会损失任何信息,只是用更紧凑的方式重新表示了一遍已有的数据。
PNG是“无损压缩”
换成 PNG 之后颜色完全正确,是因为 PNG 的压缩策略从根上就不一样,PNG 从不扔掉任何数据,只是找更短的方式来表达同样的数据。
这个思路和 ZIP 完全一致:压缩前和解压后,每个比特都必须一模一样。所以 PNG 属于无损压缩。
第一步:不存原值,存“差值”
PNG 在真正压缩之前,先对图像数据做一次预处理,叫预测,也有人叫它差分编码或者滤波器。
核心想法基于一个观察:图像里相邻的像素往往很接近。
既然如此,与其每次都存一个完整的像素值,不如只存“我比预期的差多少”。差值通常比原值小,数字越小、越集中,后续压缩就越容易。
一个具体的例子
假设一行像素的红色通道长这样:
[100, 102, 104, 106, 108]
颜色在缓慢变亮,每次加 2。如果什么都不做,就需要老老实实存这五个数。
PNG 可以选一种最简单的预测方式:每个像素等于它左边那个。从左到右依次处理:
- 第一个像素 100,左边没东西,直接存 100
- 第二个像素 102,预测值 = 100,差值 = 102 - 100 = 2
- 第三个像素 104,预测值 = 102,差值 = 104 - 102 = 2
- 第四个像素 106,预测值 = 104,差值 = 2
- 第五个像素 108,预测值 = 106,差值 = 2
最终存下来的数据变成:
[100, 2, 2, 2, 2]
原来要存五个 100 左右的数,现在变成一个大数加四个很小的数。如果是纯色区域,比如一整行全是 128:
[128, 128, 128, 128, 128]
预测差值就是:
[128, 0, 0, 0, 0]
几乎全是零。而零和很小的重复数字,正是后续压缩最喜欢的东西。
这一步完全可逆——解压时从第一个数出发,一路加回差值,原始像素完整还原,不会有任何损失。
实际预测不止一种
刚才的例子只用了“左边像素”做预测,但真实的图像千奇百怪:水平渐变适合参考左边,垂直渐变适合参考上面,对角纹理则适合参考左上角。PNG 提供了几种不同的预测策略,编码器会逐行尝试,自动选出产生差值最小的那种。但万变不离其宗——核心始终是“用相邻像素猜当前像素,只存差值”。
预处理之后:差值长什么样
不管选了哪种预测模式,经过这一步之后,图像的一部分数值特征发生了明显变化:
- 原先遍布 0 到 255 区间的像素值,被替换成了差值
- 平滑区域差值集中在 0 附近,正负小整数居多
- 纯色区域差值几乎全是 0
- 边缘区域差值较大,但边缘本身只是图像的一小部分
整个数值分布的“峰值”被推向了零,而零越多、重复越多,下一步 DEFLATE 的压缩效果就越好。
第二步:DEFLATE 压缩
预处理之后,PNG 把所有的差值数据(以及预测模式标记等信息)交给一个叫 DEFLATE 的算法来做最终压缩。
DEFLATE 这个名字你可能不熟,但它的应用无处不在——ZIP、gzip、PNG 的核心压缩引擎都是它。
它做了两件事,分两步走:
一、LZ77 —— 消灭重复片段
LZ77 的想法出奇简单:“这一段我之前见过了,不重复存,给你一个坐标,你自己翻回去抄。”
具体做法是:从头到尾扫描数据,如果发现当前这一段在前面已经出现过,就把它替换成一个极短指令,内容是 [往回走多远, 抄多长]。
举个例子。假设差值序列是连续 50 个 0。DEFLATE 不会存 50 个 0,而是存一个指令,翻译过来就是:“往回看 1 个位置,抄 49 次”。这个指令本身可能只占几个字节,比存 50 个 0 小得多。
对于真实图像,纯色区域会产生大段连续零,渐变区域会产生短周期重复的数字模式。LZ77 对这两种情况都能有效压缩。
解压时反着来:遇到指令就向前翻,把那段数据原样抄回来,一个比特都不差。
二、哈夫曼编码 —— 给常见值短编号
LZ77 处理之后的数据,仍然由一个个“值”组成:有的是原始数字(没有被替换掉),有的是 LZ77 的指令码。
DEFLATE 接下来用哈夫曼编码进一步压缩。
原理和 JPEG 最后一步用的完全一样:
- 出现频率高的值,用短编码
- 出现频率低的值,用长编码
还是那个直觉:如果“0”特别多,就给它一个极短的编码,省下来的比特就很可观。同时,DEFLATE 会生成一个编码表,保存在压缩数据里,解压时对照这个表就能还原每一个值。
哈夫曼编码本身是完全可逆的数学操作,不丢弃任何信息。
LZ77 解决了局部重复——大块相同内容被浓缩成指针。但如果数据里重复不那么明显、更多是统计上的不均匀(比如 0 占了 80%,但位置很散),LZ77 效果有限。这时哈夫曼编码接管战场:
- 0 被赋予最短编码
- 其他数字按出现概率依次分配不同长度编码
两者互补,让 DEFLATE 成为一个通用性非常强的无损压缩算法。
PNG 和 JPEG 的本质区别
JPEG 的策略是:先丢弃,再打包——通过量化丢弃人眼不敏感的高频信息,把高频系数强行归零,制造大量 0,再用哈夫曼编码压缩。被丢弃的信息永远找不回来。
PNG 的策略是:无损预处理,再打包——通过预测把像素值转为差值,制造大量接近零的小数字,再用 DEFLATE(LZ77 + 哈夫曼)压缩。每一步都可逆,信息完整保留。
代价是文件大小的差距。JPEG 能把一张照片压到几百 KB,同样的图存成 PNG 可能要几 MB。原因很清楚:PNG 只能利用数据里的重复和统计规律来压缩,不能删除任何信息;JPEG 直接扔掉人眼不敏感的部分,压缩比自然可以高得多。
两种格式分别适合存储什么类型的图片
JPEG 适合照片,照片的特点是大面积平滑渐变,高频细节少,颜色变化柔和。JPEG 的 DCT 变换后高频系数天然很小,量化时大量归零,对视觉影响不大。
PNG 适合截图和图标,截图和图标的特点是大面积纯色和清晰边界,文字区域像素跳变剧烈,高频信息极多。这对 JPEG 是一场灾难——高频信息被量化强行抹掉,导致字迹发糊、边缘出现方块伪影。而 PNG 完全不损害任何高频信息,清晰度原封不动。
下面是同一张截图分别保存为JPG格式和PNG的效果(第一张是JPG,第二张是PNG),可以看到JPG格式的截图中,色块边缘出现伪影,色块内部颜色明显出现不均匀的情况,放大图片看会更明显,而PNG则色块内部颜色依然是完全均匀的


一个有趣的细节
两种格式最后都用到了哈夫曼编码,但站在它前面的东西完全不同:PNG 用它压缩无损的差值,JPEG 用它压缩有损量化后的残骸。哈夫曼编码本身只是个打包工具,不判断、不删除、不损失任何东西。有没有损失,取决于它之前的步骤做了什么。
回到我那 4 个像素
说了这么多原理,回头再看我那4个像素,就全说得通了。我的图是蓝、红、绿、白四个像素,每两个相邻像素之间颜色都剧烈突变——全是高频信号,正好是 JPEG 量化步骤专门要扔掉的类型。
更惨的是,JPEG 的处理单元是 8×8,我整张图才 2×2,不够一个处理单元。JPEG 遇到这种情况会先把图填充到 8×8 再处理:
原图 2×2: 填充后 8×8:
蓝 红 蓝 红 红 红 红 红 红 红
绿 白 绿 白 白 白 白 白 白 白
绿 白 白 白 白 白 白 白
...
填充进来的像素是假的,原本只有 4 个像素的信息,要撑起一个 8×8 的块,量化误差分摊回来之后格外大。处理完裁回 2×2,损失已经造成了。
JPEG 赌的是人眼对细节不敏感,在自然照片上它赢了无数次。但在我这个极端案例上——4 个颜色突变的像素,比一个处理单元还小,它赌输了。
番外:反直觉的现象,我这张图片的PNG文件反而比JPEG文件更小
前面一直在说“JPEG 压缩比高,PNG 文件大”。但在我这个 2×2 像素的极端案例里,经过实测,PNG 只有 77 字节,JPEG 却有 662 字节,PNG 小了近九成。

原因在于:JPEG 有沉重的固定开销,而 PNG 几乎没有什么包袱。
JPEG 那 662 字节里,绝大多数不是图像数据,而是“基础设施”。即使只存 4 个像素,JPEG 也要在文件里老老实实写下:
- 文件头和元数据标记
- 两张完整的 8×8 量化表——一张给亮度,一张给颜色,每张 64 个值
- 哈夫曼编码表——告诉解码器哪个码字对应哪个数值
- 以及图像数据本身:2×2 不够一个 8×8 处理单元,JPEG 先填充再压缩,填充进来的假像素在量化后大多不是零,反而产生出一堆杂乱的系数需要存
就像一个快递,里面只装了一张小纸条,但外包装是一个巨大的标准纸箱,还要贴上好几页说明文档。包装比内容还沉。
PNG 那 77 字节则精简得多:
- PNG 文件头固定 8 字节
- 图像数据块:4 个像素 × 3 通道 = 12 字节原始像素值。这么小的图,预测模式选“None”(不预测,直接存原值)反而是最省事的,DEFLATE 拿到
[0,0,255, 255,0,0, 0,255,0, 255,255,255]这串数据,一秒压完 - 末尾一个 12 字节的 CRC 校验块,确保数据完整
- 其他零碎开销加一加,总共 77 字节
PNG 没有量化表,没有必须塞满的 8×8 块,数据有多长就处理多长。
所以,那条规律——“JPEG 压缩比更高”——需要一个重要前提:图片足够大、内容足够复杂。 在自然照片上,JPEG 的压缩收益远远盖过了固定开销,几百 KB 对几 MB,优势明显。但在极小图片的极端场景,固定开销反而成了主角,PNG 的按需结构直接躺赢。
这也解释了为什么会出现这个有趣的反转:77 字节的 PNG,662 字节的 JPEG。JPEG 不是在任何情况下都更小,它只是一种为“大图、复杂图”高度优化的方案。当图小到只有几个像素时,没有固定开销包袱的 PNG,反而比"高压缩比"的 JPEG 更轻。
标题:手动拼了张4像素的图,JPEG颜色错了,PNG却对了——为什么?
作者:aopstudio
地址:https://neusoftware.top/articles/2026/05/07/1778159037743.html