导航菜单

有损压缩实践

质量参数 80 是怎么来的?

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

img_resized.save(output_path, 'WebP', quality=80)

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

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

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

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

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

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

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

我用小李的 5 张夜景照片做了第一组实验,测试不同质量参数下的文件大小和主观质量:

from PIL import Image
import os
import json

def quality_scan(image_path, output_dir):
    """对一张图片做全质量参数扫描"""
    img = Image.open(image_path)
    
    # 先缩放到常用展示尺寸
    img_resized = img.copy()
    img_resized.thumbnail((1200, 900), Image.LANCZOS)
    
    results = {'jpeg': [], 'webp': []}
    
    for quality in [30, 40, 50, 60, 65, 70, 75, 80, 85, 90, 95]:
        # JPEG 压缩
        jpeg_path = os.path.join(output_dir, f'q{quality}.jpg')
        img_resized.save(jpeg_path, 'JPEG', quality=quality)
        jpeg_size = os.path.getsize(jpeg_path)
        
        # WebP 压缩
        webp_path = os.path.join(output_dir, f'q{quality}.webp')
        img_resized.save(webp_path, 'WebP', quality=quality)
        webp_size = os.path.getsize(webp_path)
        
        results['jpeg'].append({
            'quality': quality,
            'size_kb': round(jpeg_size / 1024, 1),
        })
        results['webp'].append({
            'quality': quality,
            'size_kb': round(webp_size / 1024, 1),
        })
    
    return results

# 小李的夜景照片(高细节)
night_result = quality_scan('night_photo.jpg', '/tmp/scan_night/')

结果出来了:

质量参数对比(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 种典型内容:

# 5 种典型摄影内容
TEST_IMAGES = {
    'night_scene': '夜景.jpg',       # 高噪点、高细节、暗色调
    'portrait':    '人像.jpg',       # 肤色过渡、焦外虚化
    'landscape':   '风景.jpg',       # 天空渐变、绿色植物
    'product':     '白底产品.jpg',    # 纯白背景、硬边缘
    'screenshot':  'UI截图.png',     # 文字、直线、纯色块
}

def batch_quality_scan():
    """对所有类型做质量扫描,找到每种类型的最佳质量参数"""
    optimal_quality = {}
    
    for content_type, filepath in TEST_IMAGES.items():
        img = Image.open(filepath)
        img.thumbnail((800, 600), Image.LANCZOS)
        
        best_q = None
        for q in range(50, 95, 5):
            webp_path = f'/tmp/{content_type}_q{q}.webp'
            img.save(webp_path, 'WebP', quality=q)
            size_kb = os.path.getsize(webp_path) / 1024
            
            # 用 SSIM 评估质量(结构相似性)
            ssim_score = calculate_ssim(filepath, webp_path)
            
            # 找到 SSIM > 0.95 的最低质量参数
            if ssim_score >= 0.95 and best_q is None:
                best_q = q
                optimal_quality[content_type] = {
                    'quality': q,
                    'size_kb': round(size_kb, 1),
                    'ssim': round(ssim_score, 3),
                }
                break
    
    return optimal_quality

结果让我很意外:

不同内容类型在 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 截图),有的图片浪费带宽(风景照)。

第三步:基于内容类型的自适应压缩

我决定实现一个智能压缩策略:根据图片的内容特征,自动选择最佳质量参数。

from PIL import Image
import numpy as np

class SmartCompressor:
    """基于内容特征的智能压缩器"""
    
    # 每种内容类型的最佳质量参数
    QUALITY_MAP = {
        'photo_night':    {'quality': 78, 'max_size_kb': 80},
        'photo_portrait': {'quality': 75, 'max_size_kb': 60},
        'photo_landscape':{'quality': 72, 'max_size_kb': 50},
        'product':        {'quality': 82, 'max_size_kb': 40},
        'screenshot':     {'quality': 90, 'max_size_kb': 100},
        'graphic':        {'quality': 88, 'max_size_kb': 70},
        'unknown':        {'quality': 78, 'max_size_kb': 80},  # 默认
    }
    
    def analyze_content(self, image: Image.Image) -> str:
        """分析图片内容类型"""
        img_array = np.array(image.convert('RGB'))
        
        # 特征 1:亮度分布
        brightness = img_array.mean(axis=2)
        avg_brightness = brightness.mean()
        brightness_std = brightness.std()
        
        # 特征 2:饱和度
        from PIL import ImageFilter
        hsv_image = image.convert('HSV')
        h, s, v = hsv_image.split()
        avg_saturation = np.array(s).mean() / 255
        
        # 特征 3:边缘密度(细节丰富程度)
        gray = image.convert('L')
        edges = gray.filter(ImageFilter.FIND_EDGES)
        edge_density = np.array(edges).mean() / 255
        
        # 特征 4:颜色数量
        colors = image.convert('P', palette=Image.ADAPTIVE, colors=256)
        unique_colors = len(set(colors.getdata()))
        
        # 特征 5:是否有大面积纯色(白底产品 / 截图)
        most_common_color_ratio = self._most_common_color_ratio(img_array)
        
        # 分类逻辑
        if most_common_color_ratio > 0.5:
            if avg_brightness > 200:
                return 'product'         # 白底产品图
            else:
                return 'screenshot'      # UI 截图
        elif avg_brightness < 80 and brightness_std > 50:
            return 'photo_night'         # 暗色高对比 = 夜景
        elif avg_saturation > 0.4 and edge_density < 0.1:
            return 'photo_portrait'      # 高饱和低边缘 = 人像(焦外虚化)
        elif avg_saturation > 0.3 and edge_density > 0.15:
            return 'photo_landscape'     # 高饱和高边缘 = 风景
        elif unique_colors < 64 and edge_density > 0.2:
            return 'graphic'             # 少色高边缘 = 图形设计
        else:
            return 'unknown'
    
    def compress(self, image: Image.Image, target_width: int = 800) -> dict:
        """智能压缩:分析内容 → 选择参数 → 压缩 → 验证质量"""
        # 缩放到目标尺寸
        img = image.copy()
        img.thumbnail((target_width, target_width), Image.LANCZOS)
        
        # 分析内容类型
        content_type = self.analyze_content(image)
        params = self.QUALITY_MAP[content_type]
        
        # 使用 Pillow 的两遍压缩(改进)
        import io
        
        # 第一遍:用推荐质量压缩
        buffer = io.BytesIO()
        img.save(buffer, 'WebP', quality=params['quality'], method=6)
        size_kb = buffer.tell() / 1024
        
        # 如果超过最大限制,降低质量重试
        quality = params['quality']
        while size_kb > params['max_size_kb'] and quality > 30:
            quality -= 5
            buffer = io.BytesIO()
            img.save(buffer, 'WebP', quality=quality, method=6)
            size_kb = buffer.tell() / 1024
        
        return {
            'content_type': content_type,
            'quality': quality,
            'size_kb': round(size_kb, 1),
            'dimensions': f'{img.width}x{img.height}',
            'data': buffer.getvalue(),
        }
    
    def _most_common_color_ratio(self, img_array):
        """计算最常见颜色占比"""
        from collections import Counter
        # 降采样加速
        sampled = img_array[::4, ::4].reshape(-1, 3)
        # 量化到 8 级
        quantized = (sampled // 32) * 32
        colors = [tuple(c) for c in quantized]
        most_common = Counter(colors).most_common(1)[0]
        return most_common[1] / len(colors)


# 使用
compressor = SmartCompressor()

img = Image.open('night_photo.jpg')
result = compressor.compress(img, target_width=800)
print(f"类型: {result['content_type']}")   # photo_night
print(f"质量: {result['quality']}")        # 78
print(f"大小: {result['size_kb']} KB")     # 62 KB

但问题来了——这个分类逻辑太粗糙了。analyze_content 用的是简单的阈值判断,分类准确率大概只有 70%。有些照片被误判了,比如一张暗色人像被分到了”夜景”。

第四步:更务实的方案——二分搜索找最佳质量

与其费劲做内容分类,不如换一个思路:给定目标质量(SSIM),二分搜索找到最小文件大小对应的质量参数。

from PIL import Image
import io
import subprocess
import tempfile

class BinarySearchCompressor:
    """二分搜索压缩:找到满足质量要求的最小文件大小"""
    
    def __init__(self, target_ssim=0.95, target_width=800):
        self.target_ssim = target_ssim
        self.target_width = target_width
    
    def compress(self, image_path: str) -> dict:
        """二分搜索最优质量参数"""
        img = Image.open(image_path)
        img.thumbnail((self.target_width, self.target_width), Image.LANCZOS)
        
        low, high = 20, 95
        best_result = None
        
        while low <= high:
            mid = (low + high) // 2
            
            # 压缩
            buffer = io.BytesIO()
            img.save(buffer, 'WebP', quality=mid, method=6)
            size_kb = buffer.tell() / 1024
            
            # 评估质量(使用简单的 PSNR 近似,避免依赖 skimage)
            # 生产环境建议用 ssim 库
            quality_score = self._estimate_quality(img, mid)
            
            if quality_score >= self.target_ssim:
                # 质量达标,尝试更低质量
                best_result = {
                    'quality': mid,
                    'size_kb': round(size_kb, 1),
                    'estimated_ssim': quality_score,
                    'data': buffer.getvalue(),
                }
                high = mid - 1  # 尝试更低的参数
            else:
                # 质量不够,提高质量
                low = mid + 1
        
        return best_result
    
    def _estimate_quality(self, original_img, quality):
        """用经验公式估算 SSIM(快速,不需要实际计算)"""
        # 经验值:WebP quality 参数与 SSIM 的大致关系
        # 这是一个粗略估算,生产环境应使用实际 SSIM 计算
        width, height = original_img.size
        pixels = width * height
        
        # 复杂度因子(基于图像方差)
        import numpy as np
        arr = np.array(original_img.convert('L'))
        complexity = arr.std() / 128.0  # 0~1 范围
        
        # 经验公式
        base_ssim = 1.0 - (100 - quality) / 100 * 0.15
        adjusted_ssim = base_ssim - complexity * 0.03
        
        return min(adjusted_ssim, 0.99)


# 使用
compressor = BinarySearchCompressor(target_ssim=0.95, target_width=800)
result = compressor.compress('night_photo.jpg')
# quality: 76, size_kb: 58, estimated_ssim: 0.951

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

最终方案:混合策略

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

  1. 先用简单规则做粗分类(白底 vs 非白底,截图 vs 照片)
  2. 在分类基础上调整目标 SSIM
  3. 用二分搜索找到最佳质量参数
class ProductionCompressor:
    """生产级智能压缩器"""
    
    def __init__(self):
        # 不同场景的目标 SSIM
        self.ssim_targets = {
            'thumbnail': 0.90,    # 缩略图可以稍微糊一点
            'preview':   0.95,    # 预览图保持高质量
            'detail':    0.97,    # 详情页尽量接近原图
        }
    
    def compress_for_scene(self, image: Image.Image, scene: str) -> dict:
        """按场景压缩"""
        target_ssim = self.ssim_targets.get(scene, 0.95)
        
        # 简单分类:只需要区分"截图类"和"照片类"
        is_screenshot = self._is_screenshot_like(image)
        
        # 截图类需要更高的质量下限
        quality_range = (65, 95) if is_screenshot else (30, 90)
        
        # 二分搜索
        return self._binary_search_compress(
            image, target_ssim, quality_range
        )
    
    def _is_screenshot_like(self, image: Image.Image) -> bool:
        """快速判断是否是截图类图片"""
        # 截图的特征:大量纯色区域 + 硬边缘
        arr = np.array(image.convert('RGB'))
        # 采样分析
        sampled = arr[::8, ::8]
        
        # 计算相邻像素差异
        diff_h = np.abs(np.diff(sampled, axis=1)).mean()
        diff_v = np.abs(np.diff(sampled, axis=0)).mean()
        
        # 截图的特征:要么完全相同(纯色),要么差异极大(文字边缘)
        uniform_ratio = (np.diff(sampled.reshape(-1, 3), axis=0) == 0).all(axis=1).mean()
        
        return uniform_ratio > 0.7  # 70% 的相邻像素完全相同
    
    def _binary_search_compress(self, image, target_ssim, quality_range):
        """二分搜索最优压缩参数"""
        low, high = quality_range
        best = None
        
        while low <= high:
            mid = (low + high) // 2
            buffer = io.BytesIO()
            image.save(buffer, 'WebP', quality=mid, method=6)
            size_kb = buffer.tell() / 1024
            
            # 粗略估算 SSIM(生产环境用实际计算)
            est_ssim = self._fast_ssim_estimate(image, mid)
            
            if est_ssim >= target_ssim:
                best = {'quality': mid, 'size_kb': size_kb, 'data': buffer.getvalue()}
                high = mid - 1
            else:
                low = mid + 1
        
        return best
    
    def _fast_ssim_estimate(self, image, quality):
        """快速 SSIM 估算"""
        complexity = np.std(np.array(image.convert('L'))) / 128.0
        base = 1.0 - (100 - quality) / 100 * 0.15
        return min(base - complexity * 0.03, 0.99)

效果对比

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

摄影师老王也没再吐槽了。

前端适配

智能压缩是后端的事,但前端也需要配合——告诉浏览器自己支持什么格式:

// 前端:检测浏览器支持的图片格式
class ImageFormatDetector {
  private supportedFormats: Map<string, boolean> = new Map();

  async detect(): Promise<Record<string, boolean>> {
    const tests: Record<string, string> = {
      webp: 'data:image/webp;base64,UklGRjoAAABXRUJQVlA4IC4AAACyAgCdASoCAAIALmk0mk0iIiIiIgBoSygABc6WWgAA/veff/0PP8bA//LwYAAA',
      avif: 'data:image/avif;base64,AAAAIGZ0eXBhdmlmAAAAAGF2aWZtaWYxbWlhZk1BMUIAAADybWV0YQAAAAAAAAAoaGRscgAAAAAAAAAAcGljdAAAAAAAAAAAAAAAAGxpYmF2aWYAAAAIZnJlZQAAAAs1ZGF0AAAAAAAAAAIAAAAC',
    };

    for (const [format, testUrl] of Object.entries(tests)) {
      try {
        const supported = await this.testImage(testUrl);
        this.supportedFormats.set(format, supported);
      } catch {
        this.supportedFormats.set(format, false);
      }
    }

    return {
      webp: this.supportedFormats.get('webp') ?? false,
      avif: this.supportedFormats.get('avif') ?? false,
    };
  }

  private testImage(url: string): Promise<boolean> {
    return new Promise((resolve) => {
      const img = new Image();
      img.onload = () => resolve(img.width > 0 && img.height > 0);
      img.onerror = () => resolve(false);
      img.src = url;
    });
  }

  getPreferredFormat(): string {
    if (this.supportedFormats.get('avif')) return 'avif';
    if (this.supportedFormats.get('webp')) return 'webp';
    return 'jpeg';
  }
}

// 使用
const detector = new ImageFormatDetector();
const formats = await detector.detect();
console.log(`最佳格式: ${detector.getPreferredFormat()}`);
// Chrome: avif
// Firefox: webp
// Safari (旧版): jpeg

本节小结

我学到了什么

  • WebP 同等主观质量下比 JPEG 小 30%~47%,但质量参数的含义不同
  • 固定质量参数(如 q=80)是一种”一刀切”——不同内容类型需要不同参数
  • 二分搜索 + 目标 SSIM 是一种更可靠的智能压缩方案

⚠️ 踩过的坑

  • WebP 的 quality=80 对应的是 JPEG 的 quality=85+,直接照搬 JPEG 参数会导致体积偏大
  • 简单的内容分类(亮度、饱和度、边缘)准确率不够,生产中不建议作为唯一依据
  • SSIM 计算比较耗时,需要用估算或缓存来加速

🎯 下一步:有损压缩搞定了,但有些图片(截图、产品白底图、证件照)不能用有损压缩。无损压缩该怎么做?

我的思考

思考 1

为什么夜景照片比白底产品图更容易压缩?从压缩算法的角度解释这个现象。

参考答案

这与有损压缩的核心原理有关——丢弃人眼不敏感的高频信息

夜景照片为什么”好压缩”

夜景的特征:
1. 大面积暗色区域 → 像素值接近,DCT 变换后高频系数极少
2. 噪点丰富 → 本身就是"随机噪声",压缩引入的失真被噪点"掩盖"
3. 细节藏在暗部 → 人眼对暗部细节的分辨力本来就很弱

压缩算法视角:
- 暗色区域 = 大量相似像素 = 压缩比极高
- 噪声 = 高频信息 = 被量化掉也看不出来
- 结果:丢掉 80% 的数据,视觉质量几乎不变

白底产品图为什么”难压缩”

白底产品图的特征:
1. 纯白背景 + 硬边缘 → 边缘处像素值突变(0→255)
2. 产品细节清晰 → 用户会放大查看
3. 纯色背景不允许出现色块/色带

压缩算法视角:
- 硬边缘 = 高频信息 = 压缩后会产生振铃效应(ringing)
- 纯白背景上任何微小的色块都清晰可见
- 结果:必须保留更多数据才能维持视觉质量

一个形象的比喻:降噪耳机在嘈杂的地铁里效果很好(环境噪声掩盖了音质损失),但在安静的图书馆里,任何细微的底噪都听得清清楚楚。夜景就是”嘈杂的地铁”,白底图就是”安静的图书馆”。

思考 2

如果你的图片平台需要支持摄影师的”原图下载”功能,你会怎么处理?压缩后的图片能替代原图吗?

参考答案

压缩后的图片绝对不能替代原图,这是图片系统的一条铁律。

# 原图保护策略
ORIGINAL_PROTECTION = {
    # 1. 原图永远不压缩、不缩放、不转格式
    'original_storage': {
        'path': 'originals/{date}/{hash}/{uuid}.{ext}',
        'format': '保持原始格式不变',
        'processing': 'none',
    },
    
    # 2. 压缩图是"衍生品",可随时从原图重新生成
    'derivatives': {
        'thumbnail': '800px, WebP, q=auto',
        'preview': '1200px, WebP, q=auto',
        'watermarked': '1200px, JPEG, 带水印',
    },
    
    # 3. 原图下载功能
    'original_download': {
        'condition': '用户点击"下载原图"',
        'auth': '需要登录',
        'billing': '可能计入下载配额',
        'log': '记录下载行为',
    },
}

关键原则

  • 原图不可丢——丢了原图就丢了所有衍生品的源头
  • 衍生品可重建——只要原图在,缩略图、压缩版随时可以重新生成
  • 按需提供原图——普通浏览用压缩版,专业需求(打印、二次编辑)才提供原图下载

这也解释了为什么前面我们用 OSS 存储,并且把原图放在 originals/ 目录,缩略图放在 thumbs/ 目录——它们是不同层级的资产。

思考 3

假设你要为一个电商网站设计图片压缩策略,商品图和用户晒单图的压缩策略应该有什么不同?

参考答案

电商场景下,商品图和用户晒单图的价值完全不同,压缩策略也应不同:

商品图(商家上传):
- 价值:直接影响购买决策,必须高质量
- 压缩:保守策略,SSIM ≥ 0.97
- 尺寸:提供多种(200/400/800/1200px)
- 格式:优先 WebP,回退 JPEG
- 水印:不加水印(影响展示效果)
- 存储:标准存储(高频访问)

用户晒单图(用户上传):
- 价值:辅助参考,对质量要求相对低
- 压缩:激进策略,SSIM ≥ 0.90
- 尺寸:2~3 种(300/800px)
- 格式:统一 WebP
- 水印:可加平台水印
- 存储:30天后转入低频存储
# 电商图片压缩策略
class EcommerceCompressor:
    STRATEGIES = {
        'product': {
            'sizes': [200, 400, 800, 1200],
            'min_ssim': 0.97,
            'quality_range': (75, 92),
            'storage_class': 'standard',
        },
        'review': {
            'sizes': [300, 800],
            'min_ssim': 0.90,
            'quality_range': (40, 80),
            'storage_class': 'low_frequency',  # 30天后
        },
    }

核心理念:高价值内容用高质量高成本,低价值内容用低质量低成本。一刀切的策略要么浪费钱,要么损失体验。

搜索