有损压缩实践
上线初期,我在 generate_thumbnails 函数里硬编码了一个数字:quality=80。
为什么是 80?说实话,我当时在 Google 上搜了一下”WebP 最佳质量参数”,看到一个回答推荐 75~85,就选了中间值。
直到有一天,摄影师老王在群里吐槽:“光影上的照片颜色有点糊,跟我原图比差了不少。”
我下载了老王上传的原图和压缩后的版本,放在一起对比:
原图: 6000×4000, JPEG, 8.7MB, 细节丰富,噪点清晰
压缩后(80): 800×533, WebP, 45KB, 远看还行,放大后细节丢失明显
远看确实差别不大。但摄影师的眼睛是挑剔的——他们会放大看细节,会注意天空的色带、暗部的噪点、高光的过渡。
我意识到,quality=80 不是一个万能数字。不同类型的图片,需要不同的压缩策略。
我用小李的 5 张夜景照片做了第一组实验,测试不同质量参数下的文件大小和主观质量:
结果出来了:
质量参数对比(1200×800 夜景照片)
JPEG:
q30: 18 KB ← 严重色块,不可用
q50: 35 KB ← 可见色块,勉强
q60: 48 KB ← 轻微色块
q70: 68 KB ← 大部分场景可接受
q75: 82 KB ← 摄影师可接受的底线
q80: 105 KB ← 肉眼几乎看不出差异
q85: 135 KB ← 细节保留很好
q90: 185 KB ← 接近无损
q95: 290 KB ← 体积增大明显,收益递减
WebP:
q30: 12 KB ← 同样不可用
q50: 24 KB ← 比 JPEG q50 好一些
q60: 32 KB ← 可接受的起点
q70: 46 KB ← 与 JPEG q75 接近
q75: 56 KB ← 摄影师可接受
q80: 71 KB ← 肉眼几乎看不出差异
q85: 92 KB ← 细节保留很好
q90: 128 KB ← 接近无损
q95: 198 KB ← 收益递减
关键发现:
同等主观质量下,WebP 比 JPEG 小约 30%~40%
WebP q75 ≈ JPEG q80(主观质量相当)
JPEG q80: 105 KB
WebP q75: 56 KB ← 小 47%!
也就是说,我一直用的 quality=80,在 WebP 下实际上”质量过剩”了——WebP 的 80 对应的是 JPEG 的 85+。
夜景照片只是其中一种。我又测了 4 种典型内容:
结果让我很意外:
不同内容类型在 800px 宽度下的最佳质量参数(SSIM ≥ 0.95)
内容类型 最佳质量参数 文件大小 说明
───────────────────────────────────────────────────
夜景照片 q78 62 KB 噪点被压缩算法"利用",天然适合有损压缩
人像照片 q75 48 KB 肤色过渡区域需要更多比特,但焦外可压缩
风景照片 q72 38 KB 大面积渐变(天空)压缩效果极好
白底产品 q82 28 KB 纯白背景压缩比极高,但产品边缘需要清晰
UI 截图 q90 85 KB 文字和线条对压缩极敏感,质量参数低了糊
结论:最佳质量参数从 72 到 90,跨度很大!
一句话总结:用固定的 quality=80,有的图片过度压缩(UI 截图),有的图片浪费带宽(风景照)。
我决定实现一个智能压缩策略:根据图片的内容特征,自动选择最佳质量参数。
但问题来了——这个分类逻辑太粗糙了。analyze_content 用的是简单的阈值判断,分类准确率大概只有 70%。有些照片被误判了,比如一张暗色人像被分到了”夜景”。
与其费劲做内容分类,不如换一个思路:给定目标质量(SSIM),二分搜索找到最小文件大小对应的质量参数。
实际上,二分搜索的方案更适合生产环境——不依赖内容分类的准确性,直接以目标质量为准。
最终的方案结合了两种思路:
- 先用简单规则做粗分类(白底 vs 非白底,截图 vs 照片)
- 在分类基础上调整目标 SSIM
- 用二分搜索找到最佳质量参数
用最终方案跑了一遍”光影”上最热门的 100 张图:
优化前后对比(800px 宽,WebP 格式)
指标 固定 q=80 智能压缩 改善
─────────────────────────────────────────────────
平均文件大小 78 KB 52 KB ↓ 33%
最大文件大小 210 KB 120 KB ↓ 43%
最小文件大小 25 KB 15 KB ↓ 40%
SSIM ≥ 0.95 98% 100% ↑ 2%
SSIM < 0.90 2% 0% ↑ 消除
带宽节省:每月约 35% 的 CDN 流量费
摄影师老王也没再吐槽了。
智能压缩是后端的事,但前端也需要配合——告诉浏览器自己支持什么格式:
浏览器支持度
格式 支持率(主流浏览器) 相对同画质 JPEG 的体积
──────────────────────────────────────────────────
AVIF 六成以上,版本差异较大 小 50%
WebP 约 97% 小 30%~40%
JPEG 100% 基准
前端要做的是:按浏览器能力选格式,而不是让浏览器猜。
用 <picture> 元素把候选格式排好序,浏览器从上往下挑第一个它认识的:
<picture>
<source
type="image/avif"
srcset="/i/a3f8c2e1_800.avif 800w, /i/a3f8c2e1_1600.avif 1600w"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<source
type="image/webp"
srcset="/i/a3f8c2e1_800.webp 800w, /i/a3f8c2e1_1600.webp 1600w"
sizes="(max-width: 768px) 100vw, 50vw"
/>
<img
src="/i/a3f8c2e1_800.jpg"
srcset="/i/a3f8c2e1_800.jpg 800w, /i/a3f8c2e1_1600.jpg 1600w"
sizes="(max-width: 768px) 100vw, 50vw"
width="800"
height="533"
loading="lazy"
decoding="async"
alt="傍晚的街道"
/>
</picture>
三个细节值得单独说:
<picture>里的顺序就是优先级,浏览器从上往下找第一个能解码的。所以 AVIF 放最前面——它体积最小;JPEG 放最后,作为兜底。<img>标签是必需的,老浏览器会忽略上面的<source>直接用它。width和height必须写。浏览器在图片加载前就用这两个属性算出占位尺寸,页面不会因为图片到达而跳动。没有这两个属性,图片一加载页面就抖一下,这在列表页会连带影响用户继续滑动的意愿。srcset里带w描述符,让浏览器自己挑尺寸。配合sizes,手机和桌面各取各的,不用写两套<img>。
再往前一步:格式协商其实可以交给服务端。服务端读 Accept 头,直接返回对应格式的 URL:
const ACCEPT_AVIF = "image/avif";
const ACCEPT_WEBP = "image/webp";
/** 根据请求头挑一个最优格式,顺序即优先级。 */
export function negotiateFormat(accept: string | null): "avif" | "webp" | "jpeg" {
const header = accept ?? "";
if (header.includes(ACCEPT_AVIF)) return "avif";
if (header.includes(ACCEPT_WEBP)) return "webp";
return "jpeg";
}
但我最终没有这么做,原因是前面讲过的:Vary: Accept 会把缓存按浏览器能力分裂开,命中率会掉。格式写在 URL 里(xxx_800.webp)虽然前端要多写几个 <source>,但”哪个对象被缓存”和”哪个对象被请求”是同一件事,排查问题时直观得多。
前端配合压缩策略还有一件小事:不要在上传前做压缩。用户拍的原图是摄影师的作品,前端压过的版本会把后端那套自适应质量算法的输入就毁掉了——它再也判断不出原始的噪点和渐变分布。前端只负责”显示哪个文件”,压缩的判断权交给后端。
发现内容有问题?
告诉我们哪里需要改进。
