有损压缩实践
质量参数 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实际上,二分搜索的方案更适合生产环境——不依赖内容分类的准确性,直接以目标质量为准。
最终方案:混合策略
最终的方案结合了两种思路:
- 先用简单规则做粗分类(白底 vs 非白底,截图 vs 照片)
- 在分类基础上调整目标 SSIM
- 用二分搜索找到最佳质量参数
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天后
},
}核心理念:高价值内容用高质量高成本,低价值内容用低质量低成本。一刀切的策略要么浪费钱,要么损失体验。
