每次做图片相关的需求评审,只要屏幕上出现“这个图存 JPG 还是 PNG”这句话,会议室里就会冒出三种互相矛盾的意见。有人凭手感说截图必须 PNG,有人凭体积说一律 JPG 错不了,还有人拿出一张边缘发糊的 Logo 图作为反例。这类争论的根源不是谁更懂,而是大部分人只记住了格式后缀,没拆开看过文件里到底装了什么。JPG、PNG、GIF 这三种格式,本质上是三套完全不同的世界观:像素怎么摆、颜色怎么表、数据怎么压、附加信息怎么塞。把这四个维度拆开,很多“玄学”会立刻变成可以计算的工程问题,比如为什么截图存 JPG 会出现彩色噪点,为什么一张纯色图存 PNG 只有几 KB,为什么 GIF 的透明边缘总是锯齿。
这篇文章面向的读者很杂:做前端和客户端的、做图像处理的、做内容运营需要批量处理素材的,以及单纯被“图片体积超限”卡住过的人。像素、颜色、压缩、元数据这四个维度,我会按从业者排查问题的顺序来讲,每个维度都给可复现的验证方法和参数计算过程。不需要你事先懂 DCT 或者 DEFLATE,但需要你愿意打开命令行看一眼文件的十六进制。
1. 先把三种格式放回它们的出生场景
1.1 格式不是后缀,是一份有约束的契约
很多人对图片格式的理解停留在扩展名层面,觉得把.png改名成.jpg就算转换了。实际发生的是:文件头部的魔数字节没变,解码器依然按原格式解析,改后缀只是让操作系统用错误的程序去打开它。三种格式的“契约”差异极大,JPG 的规范里根本没有透明通道这个概念,PNG 的规范里也没有“有损量化”这个开关,GIF 则被死死限制在每帧 256 色。理解格式的第一步,是接受“某些能力在这个格式里压根不存在”,而不是“某个参数没调好”。
我习惯用一个类比:JPG 像是有损音频编码,追求的是在给定体积下让人耳(人眼)察觉不到差异;PNG 像是无损压缩包,追求的是解压后与原始数据逐字节一致;GIF 更像早期的调色板动画,用有限的颜料画连续的帧。它们各自的设计目标决定了后续所有细节,包括为什么 JPG 不能存透明、为什么 PNG 存照片会臃肿、为什么 GIF 的颜色过渡总是一层一层的。
1.2 四个维度为什么必须一起看
单独看压缩率会得出“PNG 不如 JPG”的错误结论,因为忽略了图像内容的熵;单独看颜色深度会得出“PNG 一定比 GIF 好”的结论,但一张只有 8 种颜色的图标,GIF 反而可能更小。四个维度之间存在强耦合:
| 维度 | 核心问题 | 对其他维度的影响 |
|---|---|---|
| 像素 | 画布多大、怎么排、有无透明 | 决定压缩算法的输入块大小(JPG 的 8×8、PNG 的扫描行) |
| 颜色 | 用调色板还是真彩色、色域怎么标定 | 决定量化误差来源(索引色 vs 色度子采样) |
| 压缩 | 有损还是无损、用什么算法 | 决定体积上限和可重复编辑性 |
| 元数据 | 附加了什么、是否随文件传播 | 决定隐私风险和解码兼容性 |
真正做选型时,我一般按“内容类型 → 是否要透明 → 是否需要反复编辑 → 分发渠道限制”这个顺序过一遍,基本不会翻车。
1.3 一张对照表先把结论摆出来
| 特性 | JPG | PNG | GIF |
|---|---|---|---|
| 压缩类型 | 有损(DCT + 量化) | 无损(预测 + DEFLATE) | 无损(LZW) |
| 颜色能力 | 每通道 8 位真彩色 | 1/2/4/8/16 位,灰度/真彩/索引 | 每帧最多 256 色索引 |
| 透明支持 | 不支持 | 8 位 alpha 通道 | 1 位透明(某色透明) |
| 动画支持 | 不支持 | APNG 扩展支持 | 原生支持 |
| 典型体积 | 照片最小 | 图形/截图最小 | 小动画最小 |
| 反复编辑 | 每次保存都劣化 | 无损 | 无损(但受调色板限制) |
| 元数据能力 | EXIF/XMP/ICC,段结构 | 块结构,tEXt/iTXt/eXIf | 注释块、应用扩展块 |
这张表只是地图,不是答案。下面逐个维度拆。
2. 像素维度:画布大小、排列方式与被误解的 PPI
2.1 像素数决定一切,PPI 只是建议值
打开一张图,属性里写着“1920×1080,300 PPI”,很多人以为 PPI 决定了清晰度。实际决定信息量的是像素总数,PPI 只是元数据里的一个建议值,告诉打印设备“每英寸塞多少个像素比较合适”。同一张 1920×1080 的图,标 72 PPI 和标 300 PPI,像素数据完全一样,只是在排版软件里默认插入尺寸不同。真正影响观感的是像素数相对于显示区域的比例,也就是所谓“密度”。
这一点在批量处理时特别容易出事。某次给一个内容团队做图片规范化,他们把“统一改成 300 PPI”当成提升画质的操作,结果所有图被重采样放大,体积翻了三倍,画质反而更糊。正确做法是只调整像素尺寸,PPI 字段要么保留原值,要么按实际排版需求写,不要拿它当质量旋钮。
JPG 还有一个硬性尺寸限制:宽高都不能超过 65535 像素,因为 SOF 段里的尺寸字段是 16 位。超长图(比如一些网页长截图)直接存 JPG 会失败或截断,这种场景只能走 PNG。
2.2 采样方式:4:2:0 到底丢了什么
JPG 的有损不只来自量化,还来自色度子采样。人眼对亮度细节敏感、对颜色细节迟钝,编码器会把 RGB 转成 YCbCr,然后对 Cb、Cr 两个色度平面降采样:
- 4:4:4:不做子采样,每个像素都有独立的 Y、Cb、Cr。
- 4:2:2:水平方向每 2 个像素共用一组色度,色度平面宽度减半。
- 4:2:0:水平垂直各减半,2×2 的像素块共用一组色度。
算一下数据量。假设原始 4:4:4 每个像素 3 个分量,采样总量是 3N。4:2:0 下亮度保持 N,两个色度各 N/4,总计 1.5N,比 4:4:4 少了 50% 的原始数据。这就是为什么同样质量参数下 4:2:0 的文件明显更小。
代价出现在高饱和度的边缘上。截图里的红色文字、纯蓝色按钮、彩色细线条,在 4:2:0 下色度被糊成 2×2 一块,解码后边缘会出现彩色串扰,也就是常说的“文字发虚带彩边”。这类内容如果非要存 JPG,至少用 4:4:4,或者干脆换 PNG。
2.3 渐进式与隔行:老图一行行刷出来的原因
JPG 有两种编码顺序:基线(baseline)从上到下逐行解码,渐进式(progressive)分多次扫描,先出低频轮廓再补细节。渐进式在网络慢的时候体验更好,但解码需要更多内存,在老设备上有额外开销。PNG 的对应概念是 Adam7 隔行,把图像分成 7 遍扫描;GIF 则是 4 遍隔行扫描。
这三种隔行机制都只影响“解码顺序”,不改变最终像素。它们的实际价值在弱网首屏,代价是编码复杂度和轻微体积增加。现在带宽普遍够用,我一般默认关闭隔行,除非明确要给慢速网络做渐进呈现。
2.4 用代码验证像素层面的差异
用 Pillow 读一张图的基本信息,这是排查的第一步:
from PIL import Image im = Image.open("sample.jpg") print("格式:", im.format) print("模式:", im.mode) # RGB / RGBA / P / L / CMYK print("尺寸:", im.size) print("信息键:", list(im.info.keys()))mode是判断能力的关键:P是索引色(GIF 和 PNG-8 常见),RGB是 JPG 的典型模式,RGBA说明带 alpha,CMYK通常来自印刷流程的 JPG,浏览器里往往显示异常。我遇到过好几次“图片在某个客户端打不开”,最后定位到是 CMYK 模式的 JPG,转成 sRGB 的 RGB 模式就好了。
想看透明通道是否存在,可以检查im.mode in ("RGBA", "LA")或者im.info.get("transparency")。注意索引色 PNG 的透明是记录在tRNS块里的,模式仍是P,直接判断RGBA会漏掉。
3. 颜色维度:调色板、真彩色与色彩管理的三层结构
3.1 索引色:GIF 的 256 色是硬约束
GIF 每帧的图像数据是索引值,最大值由“颜色表大小”字段决定,上限 256。文件里可以有一个全局颜色表,也可以每帧带局部颜色表。这意味着同一帧里最多出现 256 种颜色,超出的部分必须通过量化(quantization)映射到最接近的可用颜色。
量化必然带来色带。一张有平滑天空渐变的照片转成 GIF,天空会出现一圈圈的同心色带,这是颜色数量不足的物理结果,不是压缩算法的问题。缓解手段是抖动(dithering),用相邻像素的彩色噪点模拟中间色,视觉上更平滑,但文件会变大,而且放大看全是噪点。
GIF 的透明只有 1 位:颜色表里指定某一个索引为“透明”,该颜色的所有像素都完全透明,没有半透明。所以毛发边缘、羽化阴影这类需要 alpha 过渡的地方,GIF 永远做不干净,边缘会有一圈硬锯齿。
3.2 PNG 的颜色类型矩阵
PNG 的颜色能力比大多数人以为的宽。规范定义了 5 种颜色类型配合 1/2/4/8/16 位深度:
| 颜色类型 | 含义 | 可用位深 | 说明 |
|---|---|---|---|
| 0 | 灰度 | 1/2/4/8/16 | 无 alpha |
| 2 | 真彩色 RGB | 8/16 | 无 alpha |
| 3 | 索引色 | 1/2/4/8 | 配 PLTE 块,可带 tRNS |
| 4 | 灰度 + alpha | 8/16 | 双通道 |
| 6 | 真彩色 + alpha | 8/16 | 四通道,最常见的 RGBA |
所谓“PNG-8”和“PNG-24”是导出工具的叫法,不是规范术语。PNG-8 对应颜色类型 3,PNG-24 对应类型 2 或 6(带 alpha 时实际是 32 位)。理解这一点很重要:把带透明的图导出成“PNG-8”,alpha 会被压成 1 位,边缘立刻出锯齿;导出成 PNG-24/32 才能保留 8 位 alpha。
3.3 JPG 的 YCbCr 转换与色域问题
JPG 内部存的是 YCbCr,不是 RGB。转换按 BT.601 做:
Y = 0.299R + 0.587G + 0.114B Cb = -0.1687R - 0.3313G + 0.5B + 128 Cr = 0.5R - 0.4187G - 0.0813B + 128这一步本身在浮点下是可逆的,损失主要发生在后面的量化和子采样。但还有一个坑:不同色域(sRGB、Display P3、某些广色域)的 RGB 数值含义不同,如果文件里没有 ICC 配置文件,解码器只能按 sRGB 猜,颜色就会偏。带 ICC 的 JPG 会把配置文件塞进 APP2 段,PNG 用 iCCP 块,GIF 基本没有这个能力。
我在处理设计稿导出时踩过一次:某台设备上截的图存成 PNG,颜色比原设计明显更艳,查下来是带了 Display P3 的 ICC,而看图工具忽略了 iCCP 块直接按 sRGB 渲染。批量转 sRGB 之后才一致。
3.4 实测:同一张图三种格式的体积与颜色误差
拿一张 1920×1080 的风景照片、一张同尺寸的 UI 截图、一张 512×512 的纯色 Logo,分别导出三种格式,观察体积(数值为同一台机器上的相对量级,具体数值会随内容浮动):
| 内容类型 | JPG (q85, 4:2:0) | PNG (默认) | GIF (256 色) | 观察 |
|---|---|---|---|---|
| 风景照片 | 约 320 KB | 约 3.1 MB | 约 900 KB | JPG 优势明显,PNG 几乎不压 |
| UI 截图 | 约 210 KB | 约 180 KB | 约 400 KB | PNG 反超,JPG 出现文字彩边 |
| 纯色 Logo | 约 18 KB | 约 3 KB | 约 2 KB | 无损格式碾压,JPG 边缘振铃 |
这组数字解释了为什么“统一转 JPG”这种规范一定会翻车。照片的像素相关性弱、高频细节多,DCT 量化能砍掉大量感知不敏感的系数;而截图的像素大面积重复,DEFLATE 的行内匹配效率极高,反而是 JPG 的量化在平坦区域引入了可见噪点。
4. 压缩维度:三套完全不同的哲学
4.1 JPG 的流水线:分块、变换、量化、熵编码
JPG 编码的主干是:RGB → YCbCr → 色度子采样 → 分成 8×8 块 → 每块做二维 DCT → 除以量化表取整 → zigzag 扫描 → 行程编码 + 霍夫曼编码。整条链里唯一真正不可逆的是量化那一步,DCT 本身是可逆的变换。
量化表决定了“哪些频率被保留”。人眼对低频(大块的颜色和明暗)敏感、对高频(细密纹理和锐利边缘)迟钝,所以标准量化表在高频位置放了大数值,让那些系数被除成 0。质量参数quality本质上就是在缩放这张基础表:
def scaled_quant_table(base_table, quality): q = max(1, min(100, int(quality))) if q < 50: scale = 5000 // q else: scale = 200 - 2 * q out = [] for row in base_table: out.append([ min(255, max(1, (v * scale + 50) // 100)) for v in row ]) return out # 标准亮度量化表的前两行示例 base_luma = [ [16, 11, 10, 16, 24, 40, 51, 61], [12, 12, 14, 19, 26, 58, 60, 55], ] print(scaled_quant_table(base_luma, 90)) print(scaled_quant_table(base_luma, 50)) print(scaled_quant_table(base_luma, 20))算一下就能看懂:质量 90 时scale = 20,基础值 16 变成(16*20+50)//100 = 3,量化很细;质量 20 时scale = 250,16 变成(16*250+50)//100 = 40,高频被砍得只剩粗糙轮廓。这解释了为什么低质量 JPG 会有 8×8 的方块感——每个块的边界处量化误差不连续,肉眼就看到了网格。
还有一个必须知道的点:JPG 是世代有损的。每打开编辑一次再保存,就会重新走一遍 DCT 和量化,误差累积。做过一次测试,同一张图连续保存 10 次质量 90,第八次左右开始出现肉眼可见的涂抹感,文字边缘彻底糊掉。所以工程上有个铁律:JPG 只作为最终交付格式,中间过程一律用 PNG 或 TIFF。
4.2 PNG 的两段式:预测滤波 + DEFLATE
PNG 的无损压缩分两步。第一步是逐行预测滤波,每行可以选择 5 种滤波器之一:None、Sub、Up、Average、Paeth。滤波的目的不是压数据,而是把像素值转成“与左侧/上侧邻居的差值”,让数值分布更集中在 0 附近,方便第二步压缩。第二步是把滤波后的字节流交给 DEFLATE(LZ77 滑动窗口 + 霍夫曼编码)。
滤波器是逐行独立选择的,编码器可以暴力尝试 5 种选熵最小的,这个策略叫自适应滤波。这也是为什么 PNG 的编码速度比解码慢很多。
因为是无损,PNG 的体积几乎完全由内容熵决定:
- 大块纯色、重复图案 → 滤波后全是 0 和小数值 → DEFLATE 压缩率极高。
- 照片 → 相邻像素差值分布宽 → 压缩率低,可能只压到原数据的一半。
压缩级别(zlib 的 0–9)影响的是搜索强度和块大小,越高越慢、体积略小。级别 9 相对级别 6 通常只省几个百分点,但编码时间可能翻倍。批量处理时我一般用 6,服务器端定时任务才考虑 9。
4.3 GIF 的 LZW:字典法与调色板的组合拳
GIF 用的是 LZW(Lempel-Ziv-Welch),一种基于字典的无损算法。编码时从空字典开始,把输入序列里不断出现的子串登记成新词条,用越来越长的码字表示。字典上限 4096 项(12 位码),满了就清空重来。
LZW 对长重复序列非常友好,所以 GIF 存纯色块、线条图、简单图标极其高效。但它对照片这种高熵数据无能为力,加上 256 色的量化损失,照片存 GIF 是双重打击:又大又难看。
有一个常被忽略的细节:GIF 的 LZW 专利早已过期,现在可以放心用,但很多老工具在生成 GIF 时仍然默认用较低的调色板质量。导出时把“颜色数”从自动降到实际的 32 或 64 色,体积能再降不少,因为颜色表本身也占空间。
4.4 参数怎么调:一份可直接抄的配置
命令行工具批量转换时,我常用的组合:
# 照片转 JPG:高质量 + 4:4:4 给需要锐利边缘的场景 magick input.png -strip -quality 88 -sampling-factor 4:4:4 output.jpg # 普通照片,追求体积 magick input.png -strip -quality 82 -sampling-factor 4:2:0 output.jpg # 截图/UI 转 PNG:最高压缩,保留 ICC magick input.png -define png:compression-level=9 output.png # 索引色 PNG(小图标),限制颜色数 magick icon.png -colors 64 -depth 8 PNG8:icon-8.png # GIF 优化:限制调色板 + 减少帧差异 magick -delay 8 frame_*.png -layers OptimizePlus -colors 128 anim.gif-strip会清掉所有元数据,包括 ICC 和 EXIF 方向信息。这在减少体积和隐私泄露时很有用,但对手机竖拍的照片要小心,方向信息没了图会躺倒。这种情况下我改成只删敏感段:-define exif:exclude=配合具体字段,或者转换前先把像素按方向烘焙好。
5. 元数据维度:文件头块结构里的隐藏信息
5.1 JPG 的段结构:从 SOI 到 EOI
JPG 文件是一串标记段,每个段以0xFF加一个标记字节开头。关键段包括:
| 标记 | 名称 | 内容 |
|---|---|---|
| FFD8 | SOI | 文件起始 |
| FFE0 | APP0 | JFIF 信息(版本、密度单位、缩略图) |
| FFE1 | APP1 | EXIF 或 XMP |
| FFE2 | APP2 | ICC 配置文件 |
| FFDB | DQT | 量化表 |
| FFC0/FFC2 | SOF0/SOF2 | 帧头,含尺寸、通道数(C2 表示渐进式) |
| FFC4 | DHT | 霍夫曼表 |
| FFDA | SOS | 扫描开始,后面跟压缩数据 |
| FFD9 | EOI | 文件结束 |
直接看十六进制就能核对:
xxd -l 64 photo.jpg前两字节是ffd8,紧接着通常是ffe0 0010 4a46 4946 00,4a46 4946就是 ASCII 的“JFIF”。如果第三、四字节是ffe1,说明文件头就带着 EXIF,里面可能有设备型号、拍摄时间、甚至 GPS 坐标。
EXIF 的隐私风险被严重低估。一张随手拍的照片发出去,别人用工具就能读出精确位置和拍摄设备。批量对外发布前,我自己的流程里固定有一道exiftool -all=或者保留必要字段的过滤:
# 全部清除 exiftool -all= -overwrite_original photo.jpg # 只保留方向和 ICC 相关,其余删除 exiftool -all= -tagsfromfile @ -Orientation -ICC_Profile -overwrite_original photo.jpg # 先看看里面到底有什么 exiftool -a -G1 photo.jpg5.2 PNG 的块结构:IHDR 必须第一,IEND 必须最后
PNG 的文件签名是固定的 8 字节89 50 4E 47 0D 0A 1A 0A,后面跟着一串 chunk。每个 chunk 的结构是:4 字节长度 + 4 字节类型 + 数据 + 4 字节 CRC。关键块:
| 块类型 | 作用 |
|---|---|
| IHDR | 宽高、位深、颜色类型、压缩/滤波/隔行方法,必须第一个 |
| PLTE | 索引色调色板 |
| tRNS | 透明度信息(索引色时为单色透明表) |
| iCCP | ICC 配置文件(压缩存储) |
| gAMA / sRGB | 伽马与色彩空间标记 |
| pHYs | 物理像素密度(对应 PPI) |
| tEXt / iTXt / zTXt | 文本元数据,iTXt 支持 UTF-8 |
| eXIf | 新版规范里的 EXIF 承载块 |
| IDAT | 图像数据,可以有多块 |
| IEND | 结束块 |
这里有个实用技巧:tEXt块的键值对经常被工具塞进软件名、作者名、甚至生成参数。设计工具导出时可能写入几十条文本块,体积不大但信息全在。清理方式:
from PIL import Image im = Image.open("in.png") print("文本块:", {k: v for k, v in im.info.items() if isinstance(v, (str, bytes))}) # 重新保存且不传 pnginfo,文本块会被丢弃 im.save("clean.png", optimize=True)注意 Pillow 保存 PNG 时默认不写文本块,但会保留 ICC 吗?不会自动保留,需要显式传icc_profile=im.info.get("icc_profile")。很多“转完之后颜色变了”的问题就出在这里。
5.3 GIF 的扩展块:动画参数都在这
GIF89a 的扩展块以0x21开头,后跟标签:
0xF9图形控制扩展:帧延时(单位 1/100 秒)、透明色索引、处置方式。0xFE注释扩展:任意文本,常被塞入生成软件信息。0xFF应用扩展:NETSCAPE2.0里记录循环次数,这是动图无限循环的来源。
帧延时字段是 16 位,但很多播放器会把小于 2(即 20ms)的值当成 10 处理,导致“明明设了 8ms 帧间隔,播放却快得离谱”。做动图时我一般把延时设成 4 以上(40ms,25fps),兼容性最稳。透明色的处置方式也要注意,disposal method设错会导致帧与帧之间残影叠加,这是 GIF 动画最常见的翻车点之一。
5.4 元数据对体积的影响不能忽视
一张手机照片的 EXIF 里可能带一张缩略图,几百 KB 的图能塞进几十 KB 的缩略数据。某些相机还会写入 MakerNote 私有段,动辄几百 KB。批量为网页优化图片时,光清元数据就能省下 5%–15% 的体积,而且没有任何画质损失,这是性价比最高的一步。
但也有必须保留的元数据:ICC 配置文件(色彩一致性)、EXIF 里的 Orientation(旋转)、以及某些平台要求保留的版权信息。所以清理策略应该是白名单,不是一刀切。
6. 选型与排查:把四个维度合起来做决策
6.1 一张决策表覆盖八成场景
| 场景 | 推荐格式 | 关键参数 | 理由 |
|---|---|---|---|
| 摄影照片、电商大图 | JPG | q80–88,4:2:0 | 有损压缩在高频细节上收益最大 |
| 含锐利文字的截图 | PNG | 压缩级别 6–9 | 避免色度子采样导致的彩边 |
| 带透明边缘的素材 | PNG | 颜色类型 6,8 位 alpha | GIF 只有 1 位透明,JPG 无透明 |
| 纯色图标、小 Logo | PNG-8 或 GIF | 颜色数 16–64 | 索引色体积最小 |
| 短视频片段动图 | GIF 或 APNG | 控制颜色数和帧数 | 兼容性优先选 GIF,画质优先选 APNG |
| 需要反复编辑的中间文件 | PNG | 无损 | 避免世代损失 |
| 印刷交付 | JPG/TIFF | 保留 ICC,必要时 CMYK | 色彩管理必须完整 |
6.2 常见问题排查速查表
| 现象 | 最可能原因 | 排查动作 | 处理方式 |
|---|---|---|---|
| 截图存 JPG 后文字发虚带彩边 | 4:2:0 色度子采样 + 量化 | 用 4:4:4 重新导出对比 | 改存 PNG,或至少 4:4:4、q90 以上 |
| 透明背景转 JPG 变成黑底/白底 | JPG 无 alpha 通道 | 检查im.mode | 先合成到目标底色再导出 |
| 手机竖拍图在网页上躺倒 | EXIF Orientation 丢失或未应用 | 读exif里的 0x0112 | 转码时按方向烘焙像素,或保留该字段 |
| PNG 转 GIF 出现明显色带 | 颜色量化到 256 色 | 统计原图颜色数 | 开启抖动,或改用 APNG |
| GIF 动图播放速度异常 | 帧延时小于 2 被播放器修正 | 读图形控制扩展的延时字段 | 延时统一设 4 以上 |
| 图片在某个客户端打不开 | CMYK 模式 JPG 或 ICC 异常 | 检查模式和 ICC | 转 sRGB 的 RGB 模式 |
| 同尺寸图体积差十倍 | 内容熵差异(照片 vs 截图) | 看内容类型 | 按内容选格式,别统一规范 |
| 转码后颜色明显偏艳/偏灰 | ICC 未被保留或未被识别 | 检查 iCCP/APP2 是否存在 | 显式保留 ICC,或统一转 sRGB |
| 长截图存 JPG 失败 | 高度超过 65535 | 检查尺寸 | 切分或改用 PNG |
6.3 踩过的坑与实操心得
第一个坑是**“统一转 JPG”的批量脚本**。早年做过一个图片规范化任务,规则是所有上传图统一转 JPG 质量 85。上线后收到大量反馈说图标糊了、界面截图看不清。复盘发现那批素材里超过六成是 UI 截图和带透明的图标,全部被错误处理。后来改成按内容分流:先检测是否含 alpha、再估算颜色数量、最后看边缘锐度,分别走不同管线,问题才消失。
第二个坑是PNG 的“优化”反而变大。有些工具会默认把 PNG 转成 8 位索引色来“减小体积”,结果一张带渐变阴影的图被量化后出现严重色带,体积虽然降了,但视觉质量崩了。判断标准很简单:如果原图颜色数超过 256,就不要轻易量化。可以先统计唯一颜色数:
from PIL import Image im = Image.open("in.png").convert("RGBA") colors = len(im.getcolors(maxcolors=1 << 24) or []) print("唯一颜色数:", colors)第三个坑是元数据清除过猛。有一次批量-strip之后,所有带 ICC 的图在某个浏览器里颜色都偏了,因为没有 ICC 时按 sRGB 解释,而原图其实是广色域。正确的顺序是:先把像素转换到目标色彩空间,再清除 ICC,这样即使没有标记,渲染结果也和预期一致。
第四个坑是反复转码。同一张图在多个系统之间来回转 JPG,质量参数每次都是 85,看似没问题,但每次都在做一次完整的 DCT 量化。做过对比,五次转码之后细节损失已经接近一次性用 q60 的水平。所以管线里应该标记“这张图已经是有损格式”,避免二次有损压缩。
6.4 顺手加一条自动化校验
批量处理后加一步校验,能省掉大量返工。核心是三个断言:格式与预期一致、尺寸未被意外改变、关键元数据(ICC、方向)是否保留。
from PIL import Image def check_image(path, expect_format="JPEG", expect_size=None, need_icc=False): im = Image.open(path) issues = [] if im.format != expect_format: issues.append(f"格式不符: {im.format}") if expect_size and im.size != expect_size: issues.append(f"尺寸不符: {im.size}") if need_icc and "icc_profile" not in im.info: issues.append("缺失 ICC") if im.mode == "CMYK": issues.append("CMYK 模式,浏览器可能异常") return issues这套校验跑在 CI 里,比人工抽查靠谱得多。尤其是“尺寸不符”这一项,我遇到过好几次因为某张原图带了非方形像素比(某些扫描设备会写),重采样后宽高被算错,肉眼一时看不出来,但布局全乱了。
回到最初那个会议室争论。JPG、PNG、GIF 的差异从来不是“哪个更好”,而是三套设计目标在像素、颜色、压缩、元数据四个维度上做出的不同取舍。把内容类型、透明需求、编辑链路、分发渠道这四件事先确定下来,格式选择基本只剩下一个答案。真正需要花心思的地方,是那些边界情况:带 ICC 的广色域图、带方向信息的手机照片、需要量化又要保住观感的图标。这些地方没有标准答案,只能靠一次次实测把参数调出来。