导航菜单

无损压缩

一个 PNG 95KB 的故事

“光影”上线的第三周,一个设计师用户上传了一套 UI 设计稿——全是 PNG 格式的截图和矢量图。

她很快在群里反馈:“我的图上传后变糊了。”

我一看,果然——她上传的是一张 1920×1080 的 UI 截图,PNG 格式,原始大小 95 KB。我的系统检测到 PNG,走的是有损压缩流程,转成 WebP quality=80 后只有 28 KB。

问题是,UI 截图里有大量文字,文字被有损压缩后边缘发虚,肉眼可见。

95 KB 的 PNG → 28 KB 的有损 WebP,看起来省了很多,但 UI 截图的文字糊了。而如果用无损压缩,95 KB 的 PNG 可以转成 58 KB 的无损 WebP——文字完全清晰,体积还小了 39%。

我意识到:不是所有图片都应该有损压缩。

哪些图片应该无损压缩?

先搞清楚哪些类型的图片需要无损压缩:

必须无损的图片类型:
1. UI 截图       → 文字必须清晰,一个像素都不能差
2. 产品白底图    → 白色背景不允许有色块
3. Logo / 图标   → 硬边缘,有损会产生毛刺
4. 文字截图      → 同 UI 截图
5. 线条图/图表   → 硬边缘 + 少量颜色

可以有损的图片类型:
1. 摄影作品      → 噪点天然掩盖压缩失真
2. 风景照        → 渐变丰富,适合有损
3. 人像照        → 肤色过渡,有损压缩效果好
4. 动物/植物     → 自然纹理,失真不明显

关键区别:人眼对”人造内容”(文字、线条、纯色)的失真极度敏感,对”自然内容”(噪点、渐变、纹理)的失真相对迟钝。

PNG 压缩的三个层次

对于需要无损压缩的图片,我研究了 PNG 优化的三个层次:

层次一:标准 PNG 优化(OptiPNG)

PNG 文件内部有很多可以无损去除的冗余数据:不必要的 metadata、非最优的过滤策略、未压缩的文本块等。

import subprocess
import os

def optimize_png_optipng(input_path, output_path=None, level=5):
    """使用 OptiPNG 无损优化 PNG"""
    output = output_path or input_path
    
    # OptiPNG 的优化级别 0~7,越高越慢但压缩率越好
    # -o5 是速度和压缩率的平衡点
    cmd = ['optipng', f'-o{level}', '-out', output, input_path]
    subprocess.run(cmd, check=True, capture_output=True)
    
    original_size = os.path.getsize(input_path)
    optimized_size = os.path.getsize(output)
    savings = (1 - optimized_size / original_size) * 100
    
    return {
        'original_kb': round(original_size / 1024, 1),
        'optimized_kb': round(optimized_size / 1024, 1),
        'savings_pct': round(savings, 1),
    }

# 测试不同类型 PNG 的优化效果
test_files = {
    'ui_screenshot.png': 'UI 截图(大量文字和纯色)',
    'product_white.png': '白底产品图',
    'logo.png':          'Logo 图标',
    'chart.png':         '数据图表',
    'photo_saved_as_png.png': '照片被存为 PNG',
}

for filename, desc in test_files.items():
    result = optimize_png_optipng(f'test_images/{filename}', f'test_optimized/{filename}')
    print(f"{desc}: {result['original_kb']}KB → {result['optimized_kb']}KB (节省 {result['savings_pct']}%)")

结果:

PNG OptiPNG 优化效果(-o5 级别)

图片类型                 原始大小   优化后    节省
─────────────────────────────────────────────────
UI 截图                  95 KB     72 KB    24.2%
白底产品图               320 KB    245 KB   23.4%
Logo 图标                12 KB     8 KB     33.3%
数据图表                 45 KB     32 KB    28.9%
照片(被存为PNG)         2.1 MB    1.8 MB   14.3%

OptiPNG 对所有类型都有 14%~33% 的优化。但这还不够——无损压缩的天花板在哪里?

层次二:有损化 PNG(pngquant)

pngquant 做了一件看似矛盾的事:把 PNG 变成有损的,但仍然是 PNG 格式。 它通过减少颜色数量(从 24 位真彩色降到 8 位调色板)来大幅减小文件体积,同时尽量保持视觉效果。

def optimize_png_pngquant(input_path, output_path, quality_min=65, quality_max=80):
    """使用 pngquant 有损化 PNG(减少颜色数)"""
    cmd = [
        'pngquant',
        f'--quality={quality_min}-{quality_max}',
        '--output', output_path,
        '--force',
        input_path,
    ]
    result = subprocess.run(cmd, capture_output=True)
    
    if result.returncode != 0:
        # 质量要求太高,无法满足,跳过
        return None
    
    original_size = os.path.getsize(input_path)
    compressed_size = os.path.getsize(output_path)
    savings = (1 - compressed_size / original_size) * 100
    
    return {
        'original_kb': round(original_size / 1024, 1),
        'compressed_kb': round(compressed_size / 1024, 1),
        'savings_pct': round(savings, 1),
    }

# 测试 pngquant 效果
for filename, desc in test_files.items():
    result = optimize_png_pngquant(
        f'test_images/{filename}',
        f'test_pq/{filename}',
    )
    if result:
        print(f"{desc}: {result['original_kb']}KB → {result['compressed_kb']}KB (节省 {result['savings_pct']}%)")
    else:
        print(f"{desc}: 无法在指定质量范围内压缩")

结果:

PNG pngquant 有损化效果(quality 65-80)

图片类型                 原始大小   有损化后   节省
──────────────────────────────────────────────────
UI 截图                  95 KB     28 KB    70.5%   ← 文字开始轻微发虚!
白底产品图               320 KB    68 KB    78.8%   ← 背景出现轻微色带
Logo 图标                12 KB     4 KB     66.7%   ← 颜色减少,渐变不平滑
数据图表                 45 KB     12 KB    73.3%   ← 细线条变粗
照片(被存为PNG)         2.1 MB    380 KB   81.9%   ← 看起来还行

pngquant 的压缩率非常惊人,但对 UI 截图和 Logo 来说,有损化的代价太大了——文字和线条的质量明显下降。

结论:pngquant 适合”照片被存为 PNG”的情况,不适合真正的 UI 截图和 Logo。

层次三:无损 WebP 转换

这是最终的答案:把 PNG 转成无损 WebP

from PIL import Image
import io

def png_to_lossless_webp(input_path, output_path=None):
    """PNG 转无损 WebP"""
    img = Image.open(input_path)
    
    # 保持原始模式(RGBA 或 RGB)
    if img.mode == 'RGBA':
        # WebP 无损支持 alpha 通道
        pass
    elif img.mode == 'P':
        # 调色板模式转 RGBA
        img = img.convert('RGBA')
    
    output = output_path or input_path.rsplit('.', 1)[0] + '_lossless.webp'
    
    # 无损压缩,level 6 是速度和压缩率的平衡
    img.save(output, 'WebP', lossless=True, method=6)
    
    original_size = os.path.getsize(input_path)
    webp_size = os.path.getsize(output)
    savings = (1 - webp_size / original_size) * 100
    
    return {
        'original_kb': round(original_size / 1024, 1),
        'webp_kb': round(webp_size / 1024, 1),
        'savings_pct': round(savings, 1),
    }

# 测试无损 WebP 转换
for filename, desc in test_files.items():
    result = png_to_lossless_webp(f'test_images/{filename}')
    print(f"{desc}: {result['original_kb']}KB → {result['webp_kb']}KB (节省 {result['savings_pct']}%)")

结果:

PNG → 无损 WebP 转换效果

图片类型                 PNG 大小   无损 WebP   节省     像素差异
──────────────────────────────────────────────────────────────
UI 截图                  95 KB      52 KB     45.3%    0(完全一致)
白底产品图               320 KB     180 KB    43.8%    0(完全一致)
Logo 图标                12 KB      7 KB      41.7%    0(完全一致)
数据图表                 45 KB      24 KB     46.7%    0(完全一致)
照片(被存为PNG)         2.1 MB     1.2 MB    42.9%    0(完全一致)

完美!无损 WebP 在不丢失任何像素信息的前提下,平均减少了 44% 的体积。

三种方法对比

PNG 优化方法对比(UI 截图 95KB 为例)

方法              输出大小   节省    像素损失   适用场景
─────────────────────────────────────────────────────────────
OptiPNG (-o5)     72 KB     24%     无        PNG 格式内优化
pngquant          28 KB     70%     有        照片误存为 PNG 时
无损 WebP         52 KB     45%     无        ✅ 最佳方案
有损 WebP (q=80)  28 KB     70%     有        不适合文字/UI

决策树

图片内容是什么?
├── 照片/自然图像 → 有损 WebP(用上一节的智能压缩)
└── UI/文字/Logo/产品图
    ├── 需要 PNG 格式(兼容性要求)
    │   └── OptiPNG 优化
    └── 可以用 WebP
        └── 无损 WebP ✅ 最佳方案

完整的无损压缩实现

from PIL import Image
import io
import os
import subprocess

class LosslessCompressor:
    """无损压缩处理器"""
    
    def process(self, input_path: str) -> dict:
        """处理单张图片的无损压缩"""
        img = Image.open(input_path)
        original_size = os.path.getsize(input_path)
        
        # 第一步:如果原图是 PNG,先做 OptiPNG 优化
        if input_path.lower().endswith('.png'):
            optimized_path = self._optipng_optimize(input_path)
            optimized_size = os.path.getsize(optimized_path)
        else:
            optimized_path = input_path
            optimized_size = original_size
        
        # 第二步:转无损 WebP
        webp_data = self._to_lossless_webp(img)
        webp_size = len(webp_data)
        
        # 第三步:比较,选最小的
        if webp_size < optimized_size:
            return {
                'format': 'webp_lossless',
                'size_kb': round(webp_size / 1024, 1),
                'savings': round((1 - webp_size / original_size) * 100, 1),
                'data': webp_data,
            }
        else:
            with open(optimized_path, 'rb') as f:
                data = f.read()
            return {
                'format': 'png_optimized',
                'size_kb': round(optimized_size / 1024, 1),
                'savings': round((1 - optimized_size / original_size) * 100, 1),
                'data': data,
            }
    
    def _optipng_optimize(self, input_path):
        """OptiPNG 优化"""
        output = input_path.rsplit('.', 1)[0] + '_opt.png'
        subprocess.run(
            ['optipng', '-o5', '-out', output, input_path],
            capture_output=True, check=True
        )
        return output
    
    def _to_lossless_webp(self, image: Image.Image) -> bytes:
        """转为无损 WebP"""
        img = image.copy()
        if img.mode == 'P':
            img = img.convert('RGBA')
        elif img.mode not in ('RGB', 'RGBA'):
            img = img.convert('RGBA')
        
        buffer = io.BytesIO()
        img.save(buffer, 'WebP', lossless=True, method=6)
        return buffer.getvalue()


# 批量处理"光影"上的 PNG 图片
def batch_optimize_pngs(image_dir):
    """批量优化目录下的所有 PNG"""
    compressor = LosslessCompressor()
    total_before = 0
    total_after = 0
    
    for root, dirs, files in os.walk(image_dir):
        for f in files:
            if not f.lower().endswith('.png'):
                continue
            
            path = os.path.join(root, f)
            result = compressor.process(path)
            
            original_size = os.path.getsize(path)
            total_before += original_size
            total_after += result['size_kb'] * 1024
            
            print(f"{f}: {original_size/1024:.1f}KB → {result['size_kb']}KB ({result['format']}, 节省{result['savings']}%)")
    
    print(f"\n总计: {total_before/1024/1024:.1f}MB → {total_after/1024/1024:.1f}MB (节省 {(1-total_after/total_before)*100:.1f}%)")

线条图 vs 照片:压缩差异的深层原因

我在测试中发现一个很有意思的现象:同样是 1920×1080 分辨率的图片,线条图的压缩率远低于照片。

# 对比线条图和照片的压缩特征
def analyze_compression_characteristics(image_path):
    """分析图片的压缩特征"""
    img = Image.open(image_path)
    arr = np.array(img.convert('RGB'))
    
    # 特征 1:唯一颜色数
    unique_colors = len(set(map(tuple, arr.reshape(-1, 3))))
    total_pixels = arr.shape[0] * arr.shape[1]
    color_ratio = unique_colors / total_pixels
    
    # 特征 2:相邻像素差异(高频内容)
    diff_h = np.abs(np.diff(arr.astype(float), axis=1)).mean()
    diff_v = np.abs(np.diff(arr.astype(float), axis=0)).mean()
    avg_diff = (diff_h + diff_v) / 2
    
    # 特征 3:熵(信息量)
    from collections import Counter
    pixel_counts = Counter(map(tuple, arr.reshape(-1, 3)))
    import math
    entropy = -sum((c/total_pixels) * math.log2(c/total_pixels) 
                   for c in pixel_counts.values())
    
    return {
        'unique_colors': unique_colors,
        'color_ratio': color_ratio,
        'avg_pixel_diff': round(avg_diff, 2),
        'entropy_bits': round(entropy, 2),
    }

# 对比
line_art = analyze_compression_characteristics('diagram.png')      # 线条图
photo = analyze_compression_characteristics('landscape.jpg')       # 风景照

结果:

              线条图            风景照
唯一颜色      1,247            482,351
颜色比率      0.06%            23.3%
相邻像素差异   8.7              28.4
信息熵        3.2 bits         16.8 bits

原因

线条图:少量颜色 + 硬边缘 + 低熵
- 颜色少但位置精确 → 需要精确编码每个像素位置
- 硬边缘 → 相邻像素差异大但规律性强
- 低熵 → 无损压缩效果好(Run-Length + 字典编码效率高)
- 但有损压缩会破坏硬边缘的精确性

风景照:海量颜色 + 柔和过渡 + 高熵
- 颜色丰富且连续 → 相邻像素差异小且平滑
- 柔和过渡 → 频域中高频分量少
- 高熵 → 无损压缩效果差(信息量大,很难找到规律)
- 但有损压缩效果极好(可以丢弃大量高频分量)

结论:线条图天生适合无损压缩(信息熵低),照片天生适合有损压缩(高频分量可丢弃)。

前端适配

前端需要知道图片是否需要无损压缩。我在上传时加了一个判断:

// 前端:判断图片是否需要无损压缩
function shouldLosslessCompress(file: File): boolean {
  // 规则 1:PNG 格式 + 小于 500KB = 很可能是 UI/Logo
  if (file.type === 'image/png' && file.size < 500 * 1024) {
    return true;
  }
  
  // 规则 2:SVG 导出的 PNG(通常有大量纯色)
  // 可以通过文件名或 metadata 判断
  
  // 规则 3:其他情况,让后端决定
  return false;
}

// 上传时传递压缩偏好
async function uploadImage(file: File) {
  const lossless = shouldLosslessCompress(file);
  
  const formData = new FormData();
  formData.append('image', file);
  formData.append('compression_hint', lossless ? 'lossless' : 'lossy');
  
  const result = await fetch('/api/upload', {
    method: 'POST',
    body: formData,
  }).then(r => r.json());
  
  return result;
}

后端根据 hint 做最终决定:

@app.route('/api/upload', methods=['POST'])
def upload_with_compression():
    file = request.files['image']
    compression_hint = request.form.get('compression_hint', 'auto')
    
    # 保存原图到 OSS
    object_key = save_original_to_oss(file)
    
    # 根据压缩策略生成缩略图
    if compression_hint == 'lossless' or should_use_lossless(file):
        # 无损压缩路径
        process_lossless(object_key)
    else:
        # 有损压缩路径
        process_lossy(object_key)
    
    return jsonify({'object_key': object_key, 'status': 'processing'})


def should_use_lossless(file) -> bool:
    """后端判断是否使用无损压缩"""
    # 检查文件内容(不仅仅是扩展名)
    img = Image.open(file)
    
    # 特征:小尺寸 + PNG + 少颜色 = 很可能是 UI/Logo
    if img.width * img.height < 2_000_000:  # 小于 200 万像素
        arr = np.array(img.convert('RGB'))
        unique_colors = len(set(map(tuple, arr.reshape(-1, 3))))
        if unique_colors < 5000:
            return True
    
    return False

本节小结

我学到了什么

  • 无损压缩不是”不压缩”——无损 WebP 比 PNG 小 40%~45%,且像素完全一致
  • OptiPNG 可以在 PNG 格式内做无损优化,节省 14%~33%
  • pngquant 的有损化 PNG 压缩率极高,但不适合 UI/文字/Logo
  • 线条图和照片的压缩特征完全不同——前者低熵适合无损,后者高熵适合有损

⚠️ 踩过的坑

  • 简单按文件扩展名(.png)决定压缩策略不够——照片也可能被存为 PNG
  • pngquant 对 UI 截图的损伤太大,不应该作为通用方案
  • 无损 WebP 的压缩速度比有损 WebP 慢约 3 倍(method=6 时),需要考虑处理超时

🎯 下一步:有损和无损策略都有了,但用户上传一张图就要在后台生成好几种格式和尺寸——这个过程怎么异步化?

我的思考

思考 1

为什么无损 WebP 能比 PNG 小这么多?PNG 不也是无损的吗?

参考答案

两者都是无损的,但无损的算法不同,效率差距很大。

PNG 的压缩算法(1996 年设计)

1. 每行像素做"过滤"(5 种过滤模式选一种)
   - None: 不处理
   - Sub:  每个像素减去左边像素
   - Up:   每个像素减去上面像素
   - Average: 减去左和上的平均
   - Paeth:  用 Paeth 预测器
   
2. 过滤后的数据用 deflate(和 ZIP/GZip 一样的算法)压缩

PNG 的问题:

  • 只有 5 种过滤模式,对某些图像不是最优
  • deflate 是通用的压缩算法,不是为图像数据优化的
  • 每行独立选择过滤模式,没有利用跨行的相关性

WebP 的无损压缩算法(2010 年设计)

1. 更先进的预测编码
   - 使用多种空间预测模式(比 PNG 多得多)
   - 可以利用更大范围的像素上下文
   
2. 专门设计的熵编码
   - 使用自适应的算术编码(比 deflate 的 Huffman 编码更高效)
   
3. 更好的颜色处理
   - 颜色空间转换(将 RGB 转为更易压缩的空间)
   - 调色板优化(自动选择最优调色板大小)
   
4. 残差编码
   - 只编码预测值和实际值的差异
   - 对大面积纯色区域效果极好

比喻:PNG 就像用 1996 年的导航软件规划路线,只知道几条固定的路。WebP 无损像是用 2024 年的导航,知道所有小路、实时路况,能找到最短路线。目的地一样(无损),但路线效率差很多。

思考 2

假设你的摄影社区需要支持”透明 PNG”(如水印、贴纸),无损 WebP 能完美处理 alpha 通道吗?

参考答案

是的,WebP 无损模式完美支持 alpha 通道。

from PIL import Image

# 测试透明 PNG → 无损 WebP 的 alpha 通道保真度
def test_alpha_preservation(png_path):
    original = Image.open(png_path)
    assert original.mode == 'RGBA'
    
    # 转为无损 WebP
    buffer = io.BytesIO()
    original.save(buffer, 'WebP', lossless=True, method=6)
    buffer.seek(0)
    
    # 读回 WebP
    webp_img = Image.open(buffer)
    
    # 验证 alpha 通道完全一致
    import numpy as np
    orig_alpha = np.array(original.split()[3])
    webp_alpha = np.array(webp_img.split()[3])
    
    diff = np.abs(orig_alpha.astype(int) - webp_alpha.astype(int))
    max_diff = diff.max()
    
    print(f"Alpha 通道最大差异: {max_diff}")  # 应该是 0
    print(f"原始大小: {os.path.getsize(png_path) / 1024:.1f} KB")
    print(f"WebP 大小: {len(buffer.getvalue()) / 1024:.1f} KB")
    
    return max_diff == 0  # 完全无损

但有一个需要注意的地方——有损 WebP 的 alpha 通道处理

有损 WebP 的两种 alpha 模式:
1. 有损 alpha:alpha 通道也被有损压缩(可能丢失半透明精度)
2. 无损 alpha:即使 RGB 有损,alpha 通道仍然无损

对于水印/贴纸,推荐使用:
- 有损 RGB + 无损 alpha(兼顾体积和质量)
# 有损 RGB + 无损 alpha
img.save(buffer, 'WebP', quality=80, alpha_quality=100)

搜索