无损压缩
“光影”上线的第三周,一个设计师用户上传了一套 UI 设计稿——全是 PNG 格式的截图和矢量图。
她很快在群里反馈:“我的图上传后变糊了。”
我一看,果然——她上传的是一张 1920×1080 的 UI 截图,PNG 格式,原始大小 95 KB。我的系统检测到 PNG,走的是有损压缩流程,转成 WebP quality=80 后只有 28 KB。
问题是,UI 截图里有大量文字,文字被有损压缩后边缘发虚,肉眼可见。
95 KB 的 PNG → 28 KB 的有损 WebP,看起来省了很多,但 UI 截图的文字糊了。而如果用无损压缩,95 KB 的 PNG 可以转成 58 KB 的无损 WebP——文字完全清晰,体积还小了 39%。
我意识到:不是所有图片都应该有损压缩。
先搞清楚哪些类型的图片需要无损压缩:
必须无损的图片类型:
1. UI 截图 → 文字必须清晰,一个像素都不能差
2. 产品白底图 → 白色背景不允许有色块
3. Logo / 图标 → 硬边缘,有损会产生毛刺
4. 文字截图 → 同 UI 截图
5. 线条图/图表 → 硬边缘 + 少量颜色
可以有损的图片类型:
1. 摄影作品 → 噪点天然掩盖压缩失真
2. 风景照 → 渐变丰富,适合有损
3. 人像照 → 肤色过渡,有损压缩效果好
4. 动物/植物 → 自然纹理,失真不明显
关键区别:人眼对”人造内容”(文字、线条、纯色)的失真极度敏感,对”自然内容”(噪点、渐变、纹理)的失真相对迟钝。
对于需要无损压缩的图片,我研究了 PNG 优化的三个层次:
层次一:标准 PNG 优化(OptiPNG)
PNG 文件内部有很多可以无损去除的冗余数据:不必要的 metadata、非最优的过滤策略、未压缩的文本块等。
结果:
PNG OptiPNG 优化效果(-o5 级别)
图片类型 原始大小 优化后 节省
─────────────────────────────────────────────────
UI 截图 95 KB 72 KB 24.2%
白底产品图 320 KB 245 KB 23.4%
Logo 图标 12 KB 8 KB 33.3%
数据图表 45 KB 32 KB 28.9%
照片(被存为PNG) 2.1 MB 1.8 MB 14.3%
OptiPNG 对所有类型都有 14%~33% 的优化。但这还不够——无损压缩的天花板在哪里?
层次二:有损化 PNG(pngquant)
pngquant 做了一件看似矛盾的事:把 PNG 变成有损的,但仍然是 PNG 格式。 它通过减少颜色数量(从 24 位真彩色降到 8 位调色板)来大幅减小文件体积,同时尽量保持视觉效果。
结果:
PNG pngquant 有损化效果(quality 65-80)
图片类型 原始大小 有损化后 节省
──────────────────────────────────────────────────
UI 截图 95 KB 28 KB 70.5% ← 文字开始轻微发虚!
白底产品图 320 KB 68 KB 78.8% ← 背景出现轻微色带
Logo 图标 12 KB 4 KB 66.7% ← 颜色减少,渐变不平滑
数据图表 45 KB 12 KB 73.3% ← 细线条变粗
照片(被存为PNG) 2.1 MB 380 KB 81.9% ← 看起来还行
pngquant 的压缩率非常惊人,但对 UI 截图和 Logo 来说,有损化的代价太大了——文字和线条的质量明显下降。
结论:pngquant 适合”照片被存为 PNG”的情况,不适合真正的 UI 截图和 Logo。
层次三:无损 WebP 转换
这是最终的答案:把 PNG 转成无损 WebP。
结果:
PNG → 无损 WebP 转换效果
图片类型 PNG 大小 无损 WebP 节省 像素差异
──────────────────────────────────────────────────────────────
UI 截图 95 KB 52 KB 45.3% 0(完全一致)
白底产品图 320 KB 180 KB 43.8% 0(完全一致)
Logo 图标 12 KB 7 KB 41.7% 0(完全一致)
数据图表 45 KB 24 KB 46.7% 0(完全一致)
照片(被存为PNG) 2.1 MB 1.2 MB 42.9% 0(完全一致)
完美!无损 WebP 在不丢失任何像素信息的前提下,平均减少了 44% 的体积。
PNG 优化方法对比(UI 截图 95KB 为例)
方法 输出大小 节省 像素损失 适用场景
─────────────────────────────────────────────────────────────
OptiPNG (-o5) 72 KB 24% 无 PNG 格式内优化
pngquant 28 KB 70% 有 照片误存为 PNG 时
无损 WebP 52 KB 45% 无 ✅ 最佳方案
有损 WebP (q=80) 28 KB 70% 有 不适合文字/UI
决策树:
图片内容是什么?
├── 照片/自然图像 → 有损 WebP(用上一节的智能压缩)
└── UI/文字/Logo/产品图
├── 需要 PNG 格式(兼容性要求)
│ └── OptiPNG 优化
└── 可以用 WebP
└── 无损 WebP ✅ 最佳方案
前面三层是”用什么工具”,这一节是”怎么串成一条能上线的流水线”。无损压缩的完整实现要处理四件事:识别输入、选择路线、执行工具、记录结果。
处理链路大致是这样:
原图(任意格式)
│
▼
格式识别:是不是 PNG?
│否 → 走有损压缩流程(见上一节)
▼是
属性探测:有无 Alpha 通道?是否 APNG 动画?
│
├─ APNG 动画 → 跳过重编码,动画语义容易被工具悄悄丢掉
│
├─ 有 Alpha → 无损 WebP(保留透明,且通常比 PNG 更小)
│
└─ 无 Alpha → 先试无损 WebP,失败则回退 OptiPNG 保持 PNG 格式
对应的实现:
import subprocess
from pathlib import Path
from PIL import Image
# 无损模式:-lossless 等价于 lossless=1,-q 100 表示不引入有损量化
CWEBP_ARGS = ["cwebp", "-lossless", "-q", "100", "-m", "6", "-quiet"]
class LosslessCompressError(RuntimeError):
"""压缩失败时抛出,由调用方保留原图并记录失败原因。"""
def _run(cmd: list[str]) -> None:
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
raise LosslessCompressError(f"{cmd[0]} 执行失败: {result.stderr.strip()}")
def inspect_png(path: Path) -> dict:
"""读取 PNG 的关键属性,用来决定走哪条无损路线。"""
with Image.open(path) as img:
return {
"format": img.format,
"mode": img.mode,
"has_alpha": img.mode in {"RGBA", "LA", "PA"} or "transparency" in img.info,
"is_animated": getattr(img, "is_animated", False),
"width": img.width,
"height": img.height,
}
def compress_png_losslessly(src: Path, dst_dir: Path) -> dict:
"""PNG 无损压缩主流程:优先输出无损 WebP,失败时回退 OptiPNG。
无论走哪条路线,输出的像素信息都与原图一致;体积下降来自编码效率,
而不是丢弃信息。
"""
meta = inspect_png(src)
if meta["format"] != "PNG":
raise LosslessCompressError(f"不是 PNG 文件: {src}")
if meta["is_animated"]:
raise LosslessCompressError("APNG 动画不参与无损重编码")
dst_dir.mkdir(parents=True, exist_ok=True)
webp_path = dst_dir / f"{src.stem}.webp"
try:
_run([*CWEBP_ARGS, str(src), "-o", str(webp_path)])
output = webp_path
mode = "lossless_webp"
except LosslessCompressError:
png_path = dst_dir / f"{src.stem}.png"
_run(["optipng", "-o5", "-strip", "all", str(src), "-out", str(png_path)])
output = png_path
mode = "optipng"
return {
"source_path": str(src),
"source_size": src.stat().st_size,
"output_path": str(output),
"output_size": output.stat().st_size,
"mode": mode,
"has_alpha": meta["has_alpha"],
"width": meta["width"],
"height": meta["height"],
}
四个上线前必须确认的点:
- 顺序:先优化 PNG,再转 WebP。
optipng负责剥掉元数据、选择更优的过滤策略,cwebp再基于干净数据编码。直接拿原始 PNG 转 WebP,产物会偏大。 - 保留 ICC 色彩描述文件。带
-strip all会把色彩配置一起删掉,色差大的照片会偏色。是否剥离要按业务决定,UI 截图和产品图通常要保留。 - 16 位深和灰度+调色板要单独验证。这些变体的编码行为和普通 RGB PNG 不完全一致,别用同一组期望值断言。
- 幂等。已经是无损 WebP 的对象不要再喂进来,否则每跑一次任务就重编一次,CPU 白烧。
产出的元数据要回写到处理记录里——原图体积、输出体积、走的哪条路线、有没有 Alpha、耗时,这些字段是后面做”按内容类型调参”和”清理无用派生图”的唯一依据。
我在测试中发现一个很有意思的现象:同样是 1920×1080 分辨率的图片,线条图的压缩率远低于照片。
结果:
线条图 风景照
唯一颜色 1,247 482,351
颜色比率 0.06% 23.3%
相邻像素差异 8.7 28.4
信息熵 3.2 bits 16.8 bits
原因:
线条图:少量颜色 + 硬边缘 + 低熵
- 颜色少但位置精确 → 需要精确编码每个像素位置
- 硬边缘 → 相邻像素差异大但规律性强
- 低熵 → 无损压缩效果好(Run-Length + 字典编码效率高)
- 但有损压缩会破坏硬边缘的精确性
风景照:海量颜色 + 柔和过渡 + 高熵
- 颜色丰富且连续 → 相邻像素差异小且平滑
- 柔和过渡 → 频域中高频分量少
- 高熵 → 无损压缩效果差(信息量大,很难找到规律)
- 但有损压缩效果极好(可以丢弃大量高频分量)
结论:线条图天生适合无损压缩(信息熵低),照片天生适合有损压缩(高频分量可丢弃)。
前端需要知道图片是否需要无损压缩。我在上传时加了一个判断:
后端根据 hint 做最终决定:
发现内容有问题?
告诉我们哪里需要改进。
