有损压缩实践

质量参数 80 是怎么来的?

上线初期,我在 generate_thumbnails 函数里硬编码了一个数字:quality=80。

为什么是 80?说实话,我当时在 Google 上搜了一下”WebP 最佳质量参数”,看到一个回答推荐 75~85,就选了中间值。

直到有一天,摄影师老王在群里吐槽:“光影上的照片颜色有点糊,跟我原图比差了不少。”

我下载了老王上传的原图和压缩后的版本,放在一起对比:

原图:         6000×4000, JPEG, 8.7MB, 细节丰富,噪点清晰
压缩后(80):   800×533,   WebP, 45KB,  远看还行,放大后细节丢失明显

远看确实差别不大。但摄影师的眼睛是挑剔的——他们会放大看细节,会注意天空的色带、暗部的噪点、高光的过渡。

我意识到,quality=80 不是一个万能数字。不同类型的图片,需要不同的压缩策略。

第一组实验:JPEG vs WebP 质量扫描

我用小李的 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),二分搜索找到最小文件大小对应的质量参数。

实际上,二分搜索的方案更适合生产环境——不依赖内容分类的准确性,直接以目标质量为准。

最终方案:混合策略

最终的方案结合了两种思路:

  1. 先用简单规则做粗分类(白底 vs 非白底,截图 vs 照片)
  2. 在分类基础上调整目标 SSIM
  3. 用二分搜索找到最佳质量参数

效果对比

用最终方案跑了一遍”光影”上最热门的 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>

三个细节值得单独说:

  1. <picture> 里的顺序就是优先级,浏览器从上往下找第一个能解码的。所以 AVIF 放最前面——它体积最小;JPEG 放最后,作为兜底。<img> 标签是必需的,老浏览器会忽略上面的 <source> 直接用它。
  2. width 和 height 必须写。浏览器在图片加载前就用这两个属性算出占位尺寸,页面不会因为图片到达而跳动。没有这两个属性,图片一加载页面就抖一下,这在列表页会连带影响用户继续滑动的意愿。
  3. 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>,但”哪个对象被缓存”和”哪个对象被请求”是同一件事,排查问题时直观得多。

前端配合压缩策略还有一件小事:不要在上传前做压缩。用户拍的原图是摄影师的作品,前端压过的版本会把后端那套自适应质量算法的输入就毁掉了——它再也判断不出原始的噪点和渐变分布。前端只负责”显示哪个文件”,压缩的判断权交给后端。

发现内容有问题?

告诉我们哪里需要改进。