导航菜单

分辨率与质量:看不见的 trade-off

一个让我纠结的数字

小李上传了一张 6000×4000 的照片,浏览器只需要显示 800px 宽。

# 缩放到 800px 宽
original_width = 6000
target_width = 800
scale_factor = target_width / original_width  # = 0.133

target_height = int(4000 * scale_factor)  # = 533

800×533,看起来很简单。但质量参数设多少?

from PIL import Image

img = Image.open('photo.jpg')
img_resized = img.copy()
img_resized.thumbnail((800, 800), Image.LANCZOS)

# 质量从 50 到 95,哪个合适?
for q in [50, 60, 70, 75, 80, 85, 90, 95]:
    img_resized.save(f'/tmp/q{q}.webp', 'WebP', quality=q)
    size = os.path.getsize(f'/tmp/q{q}.webp') / 1024
    print(f"quality={q}: {size:.0f} KB")

结果:

quality=50:  18 KB   ← 色块明显,像油画
quality=60:  24 KB   ← 细节模糊
quality=70:  34 KB   ← 可接受,但放大能看出差异
quality=75:  42 KB   ← 边缘略有柔和
quality=80:  52 KB   ← 肉眼几乎看不出和原图的区别 ← 我选了这个
quality=85:  68 KB   ← 和 80 几乎一样
quality=90:  92 KB   ← 多出的 40KB 几乎无意义
quality=95:  138 KB  ← 浪费存储

quality=80 是甜蜜点。但”肉眼几乎看不出”是主观判断——我需要一个客观指标。

用 SSIM 量化质量差异

SSIM(结构相似性)是衡量两张图片相似度的标准指标,范围 0~1,1 表示完全相同。

import math
from PIL import Image
import numpy as np

def calculate_ssim(img1_path, img2_path):
    """计算两张图片的 SSIM"""
    img1 = np.array(Image.open(img1_path).convert('RGB')).astype(float)
    img2 = np.array(Image.open(img2_path).convert('RGB')).astype(float)
    
    # 如果尺寸不同,先缩放到相同大小
    if img1.shape != img2.shape:
        img2 = np.array(
            Image.open(img2_path).resize(
                (img1.shape[1], img1.shape[0]), 
                Image.LANCZOS
            ).convert('RGB')
        ).astype(float)
    
    C1 = (0.01 * 255) ** 2
    C2 = (0.03 * 255) ** 2
    
    mu1 = img1.mean()
    mu2 = img2.mean()
    sigma1_sq = img1.var()
    sigma2_sq = img2.var()
    sigma12 = ((img1 - mu1) * (img2 - mu2)).mean()
    
    ssim = ((2 * mu1 * mu2 + C1) * (2 * sigma12 + C2)) / \
           ((mu1**2 + mu2**2 + C1) * (sigma1_sq + sigma2_sq + C2))
    
    return ssim

# 计算不同质量下的 SSIM
original = '/tmp/800_original.png'  # 无损基线
for q in [50, 60, 70, 75, 80, 85, 90, 95]:
    compressed = f'/tmp/q{q}.webp'
    ssim = calculate_ssim(original, compressed)
    size = os.path.getsize(compressed) / 1024
    print(f"quality={q}: SSIM={ssim:.4f}, size={size:.0f} KB")

结果:

quality=50: SSIM=0.8521, size=18 KB   ← 差异明显
quality=60: SSIM=0.8934, size=24 KB   ← 可感知差异
quality=70: SSIM=0.9312, size=34 KB   ← 轻微差异
quality=75: SSIM=0.9523, size=42 KB   ← 接近阈值
quality=80: SSIM=0.9678, size=52 KB   ← 高质量 ← 最佳平衡
quality=85: SSIM=0.9789, size=68 KB   ← 边际收益递减
quality=90: SSIM=0.9876, size=92 KB   ← 几乎无损
quality=95: SSIM=0.9934, size=138 KB  ← 浪费

质量-体积曲线有一个拐点:quality=80 之后,文件大小增长很快,但 SSIM 提升很慢。

          SSIM
         1.00 ┤
              │                        · quality=95
         0.99 ┤                    ·
              │                ·
         0.98 ┤            ·  quality=90
              │        ·
         0.97 ┤    · ← quality=80 (拐点)
              │  ·
         0.96 ┤ ·
              │·
         0.95 ┤ quality=75

         0.90 ┤

         0.85 ┤ quality=50
              └───┬────┬────┬────┬────┬──→ size
                 20   50   70  100  140 KB

不同场景的最佳参数

基于 SSIM 分析和业务需求,我制定了不同场景的质量策略:

# 不同场景的质量参数
QUALITY_PRESETS = {
    # 列表缩略图:加载速度优先
    'thumbnail': {
        'max_width': 200,
        'quality': 70,
        'format': 'WebP',
        'expected_ssim': 0.93,
        'expected_size_kb': 15,
        'reason': '列表页图片小,用户不会仔细看细节',
    },
    
    # 卡片预览:平衡
    'card': {
        'max_width': 400,
        'quality': 75,
        'format': 'WebP',
        'expected_ssim': 0.95,
        'expected_size_kb': 30,
        'reason': '卡片大一些,需要稍好的质量',
    },
    
    # 详情页展示:质量优先
    'detail': {
        'max_width': 800,
        'quality': 80,
        'format': 'WebP',
        'expected_ssim': 0.97,
        'expected_size_kb': 52,
        'reason': '用户会仔细看,质量不能打折扣',
    },
    
    # 大图预览:高质量
    'preview': {
        'max_width': 1200,
        'quality': 82,
        'format': 'WebP',
        'expected_ssim': 0.98,
        'expected_size_kb': 90,
        'reason': '全屏查看,不能有任何可见瑕疵',
    },
    
    # 下载原图:最高质量
    'download': {
        'max_width': None,  # 保持原始尺寸
        'quality': 90,
        'format': 'original',  # 保持原始格式
        'expected_ssim': 0.99,
        'reason': '摄影师下载自己的作品,必须接近无损',
    },
}

分辨率与设备适配

不同设备的屏幕分辨率差异巨大:

# 常见设备的逻辑像素和物理像素
DEVICE_RESOLUTIONS = {
    'iPhone SE': {'logical': (375, 667), 'dpr': 2, 'physical': (750, 1334)},
    'iPhone 15': {'logical': (393, 852), 'dpr': 3, 'physical': (1179, 2556)},
    'iPad': {'logical': (810, 1080), 'dpr': 2, 'physical': (1620, 2160)},
    'MacBook Air': {'logical': (1470, 956), 'dpr': 2, 'physical': (2940, 1912)},
    '4K 显示器': {'logical': (3840, 2160), 'dpr': 1, 'physical': (3840, 2160)},
}

# 问题:图片应该按逻辑像素还是物理像素来适配?
# 
# 如果按物理像素(DPR=3 的 iPhone 需要 1179px 宽的图片):
# - 移动端图片会很大
# - 但 Retina 屏幕上会很清晰
#
# 如果按逻辑像素(iPhone 只需要 393px 宽的图片):
# - 图片更小
# - 但 Retina 屏幕上会略显模糊

最佳实践:按 逻辑像素 × 2 来生成图片——在绝大多数设备上够清晰,体积也不会太大。

<!-- 响应式图片:浏览器自动选择合适的尺寸 -->
<img
  src="photo_800.webp"
  srcset="
    photo_400.webp 400w,
    photo_800.webp 800w,
    photo_1200.webp 1200w
  "
  sizes="(max-width: 600px) 400px,
         (max-width: 1200px) 800px,
         1200px"
  alt="响应式图片"
/>

我的思考

思考 1

如果你发现 quality=75 和 quality=80 的图片在文件大小上差了 10KB,但 SSIM 只差 0.015。在什么情况下应该选择 75?什么情况下选择 80?

参考答案

选 75 的场景

  1. 列表页/缩略图:用户快速滑动浏览,不会停留看细节。10KB × 20 张图 = 200KB 的差异,直接影响首屏加载速度。

  2. 移动端弱网环境:3G 网络下 200KB 需要约 0.3 秒加载。节省 200KB = 快 0.3 秒。

  3. 高流量页面:首页每天 100 万 PV,每张图节省 10KB = 每天节省 10GB 流量 = 每月节省 2,400 元 CDN 成本。

选 80 的场景

  1. 详情页/全屏查看:用户会仔细看图片内容,质量差异会被注意到。

  2. 付费内容/专业用户:摄影师的作品展示,质量是核心价值。

  3. 低流量页面:用户相册页每天只有 1000 PV,10KB 的差异微不足道。

决策框架

是"扫一眼就走"还是"仔细看"?
├─ 扫一眼(列表/卡片) → quality=70~75
└─ 仔细看(详情/全屏) → quality=80~85

用户付费了吗?
├─ 免费/广告支持 → 可以适当降低质量节省成本
└─ 付费/专业用户 → 质量优先,不惜多花存储和带宽

流量级别?
├─ 高流量(日 PV > 10 万) → 每KB都值得优化
└─ 低流量(日 PV < 1 万) → 质量优先

搜索