使用 Pillow + Plugin 处理 AVIF 图片

19 min read

最近在处理博客图片的时候,发现图片体积太大,所以想使用 AVIF 格式来优化图片体积。为啥用 AVIF 呢,因为发现压缩率挺夸张的,而且浏览器支持情况也挺好的,然后在本地写博客因为使用的是 astro 框架,所以直接使用 astro 的图片优化功能就可以。 使用 astro 的图片优化功能。然后想写个脚本,批量转换图片为 AVIF 格式,毕竟大部分场景下,图片都是 JPEG 格式,所以需要批量转换,踩了不少坑,记录一下。

主要是介绍一下 pillow 库的安装和使用,以及一些踩坑的点(AVIF 的支持有不少问题)。

Pillow 库简介

什么是 Pillow?

Pillow 是 Python 中最流行的图像处理库,是 PIL (Python Imaging Library) 的现代化分支。它提供了广泛的图像文件格式支持和强大的图像处理功能。

核心特性

安装方式

# 基础安装
pip install Pillow

# UV 环境安装
uv pip install Pillow

# 从源码编译(支持更多格式)
pip install --no-binary pillow Pillow

AVIF 格式介绍

什么是 AVIF?

AVIF (AV1 Image File Format) 是基于 AV1 视频编解码器的现代图像格式,由 Alliance for Open Media 开发。

技术特点

浏览器支持情况

浏览器支持版本发布时间
Chrome85+2020年8月
Firefox93+2021年10月
Safari16+2022年9月
Edge85+2020年8月

AVIF 格式的优势

1. 卓越的压缩效率

AVIF 相比其他格式有显著的文件大小优势:

原始文件: 13,502 bytes (PNG)
├── AVIF: 2,408 bytes (压缩率 82.2%) 🏆
├── WebP: 4,078 bytes (压缩率 69.8%)
└── JPEG: 8,685 bytes (压缩率 35.7%)

2. 高质量图像

3. 先进功能

4. Web 优化


环境配置与安装

方案: 从源码编译 Pillow (完整支持) - Mac

# macOS 环境准备
brew install libavif aom dav1d rav1e

# 设置编译环境变量
export LDFLAGS="-L/opt/homebrew/lib"
export CPPFLAGS="-I/opt/homebrew/include"
export PKG_CONFIG_PATH="/opt/homebrew/lib/pkgconfig"

# 从源码编译安装
pip uninstall pillow
pip install --no-binary pillow Pillow

验证安装

from PIL import Image, features

# 检查 AVIF 支持
print(f"AVIF 支持: {'✅' if features.check('avif') else '❌'}")
print(f"Pillow 版本: {Image.__version__}")

# 测试 AVIF 功能
try:
    # 创建测试图像
    test_img = Image.new('RGB', (100, 100), color='red')
    test_img.save('test.avif', format='AVIF')

    # 读取测试
    avif_img = Image.open('test.avif')
    print("✅ AVIF 功能正常")
except Exception as e:
    print(f"❌ AVIF 功能异常: {e}")

使用 Pillow 处理 AVIF 图片

基础操作

1. 格式转换

from PIL import Image
import pillow_avif  # 如果使用插件方案

def convert_to_avif(input_path, output_path, quality=80):
    """将图片转换为 AVIF 格式"""
    try:
        with Image.open(input_path) as img:
            # 处理透明度(AVIF 支持 Alpha 通道)
            if img.mode in ('RGBA', 'LA'):
                # 保持透明度
                img.save(output_path, format='AVIF', quality=quality)
            elif img.mode == 'P':
                # 调色板模式转换
                img = img.convert('RGB')
                img.save(output_path, format='AVIF', quality=quality)
            else:
                # 直接保存
                img.save(output_path, format='AVIF', quality=quality)

        print(f"✅ 转换成功: {input_path}{output_path}")
        return True
    except Exception as e:
        print(f"❌ 转换失败: {e}")
        return False

# 使用示例
convert_to_avif('photo.jpg', 'photo.avif', quality=85)

2. 批量转换

import os
from pathlib import Path

def batch_convert_to_avif(input_dir, output_dir, quality=80):
    """批量转换图片为 AVIF 格式"""
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)
    output_dir.mkdir(parents=True, exist_ok=True)

    # 支持的输入格式
    supported_formats = {'.jpg', '.jpeg', '.png', '.bmp', '.tiff', '.webp'}

    converted_count = 0
    total_count = 0

    for file_path in input_dir.iterdir():
        if file_path.suffix.lower() in supported_formats:
            total_count += 1
            output_path = output_dir / f"{file_path.stem}.avif"

            if convert_to_avif(file_path, output_path, quality):
                converted_count += 1

    print(f"📊 批量转换完成: {converted_count}/{total_count} 成功")

# 使用示例
batch_convert_to_avif('./images', './avif_images', quality=85)

3. 高级 AVIF 选项

def advanced_avif_save(image, output_path, **options):
    """高级 AVIF 保存选项"""
    # 默认参数
    default_options = {
        'format': 'AVIF',
        'quality': 80,          # 质量 (1-100)
        'speed': 6,             # 编码速度 (0-10, 越小越慢但质量更好)
        'codec': 'auto',        # 编解码器选择
        'range': 'full',        # 色彩范围
        'lossless': False,      # 是否无损压缩
    }

    # 合并用户选项
    save_options = {**default_options, **options}

    # 保存图像
    image.save(output_path, **save_options)

# 使用示例
with Image.open('input.jpg') as img:
    # 高质量无损压缩
    advanced_avif_save(img, 'output_lossless.avif',
                      lossless=True, speed=2)

    # 快速有损压缩
    advanced_avif_save(img, 'output_fast.avif',
                      quality=75, speed=8)

图像处理组合

def process_and_convert_avif(input_path, output_path):
    """处理图像并转换为 AVIF"""
    with Image.open(input_path) as img:
        # 1. 自动旋转(基于 EXIF)
        if hasattr(img, '_getexif') and img._getexif():
            from PIL.ExifTags import ORIENTATION
            exif = img._getexif()
            if ORIENTATION in exif:
                img = img.transpose(method=exif[ORIENTATION])

        # 2. 尺寸优化(如果图片过大)
        max_size = 1920
        if max(img.size) > max_size:
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)

        # 3. 颜色优化
        if img.mode == 'RGBA':
            # 检查是否真的需要透明通道
            alpha = img.split()[-1]
            if alpha.getbbox() is None:  # 完全不透明
                img = img.convert('RGB')

        # 4. 保存为 AVIF
        img.save(output_path, format='AVIF', quality=85, optimize=True)

# 使用示例
process_and_convert_avif('large_photo.jpg', 'optimized.avif')

实际案例与代码示例

完整的图像转换器类

import os
import time
from pathlib import Path
from typing import Union, Dict, List
from PIL import Image, features
import pillow_avif  # 启用 AVIF 支持

class ImageConverter:
    """通用图片格式转换器,支持 AVIF"""

    def __init__(self):
        self.supported_formats = self._get_supported_formats()
        self.format_extensions = {
            "JPEG": [".jpg", ".jpeg"],
            "PNG": [".png"],
            "WebP": [".webp"],
            "AVIF": [".avif"],
            "BMP": [".bmp"],
            "TIFF": [".tiff", ".tif"],
            "GIF": [".gif"],
        }

    def _get_supported_formats(self) -> Dict[str, bool]:
        """检查支持的格式"""
        return {
            "JPEG": True,
            "PNG": True,
            "WebP": features.check("webp"),
            "AVIF": features.check("avif"),
            "BMP": True,
            "TIFF": True,
            "GIF": True,
        }

    def print_support_info(self):
        """显示格式支持信息"""
        print("=" * 50)
        print("Pillow 图片格式支持情况:")
        print("=" * 50)
        for format_name, supported in self.supported_formats.items():
            status = "✅ 支持" if supported else "❌ 不支持"
            print(f"{format_name:8} {status}")
        print("=" * 50)

    def convert_single(self, input_path: Union[str, Path],
                      output_path: Union[str, Path],
                      target_format: str = "AVIF",
                      quality: int = 85,
                      **options) -> bool:
        """转换单个文件"""

        if not self.supported_formats.get(target_format, False):
            print(f"❌ 格式 {target_format} 不支持")
            return False

        try:
            with Image.open(input_path) as img:
                print(f"📸 原始图片: {img.format} {img.size} {img.mode}")

                # 处理颜色模式
                if img.mode == "RGBA" and target_format == "JPEG":
                    # JPEG 不支持透明度,转换为白底
                    background = Image.new("RGB", img.size, (255, 255, 255))
                    background.paste(img, mask=img.split()[-1])
                    img = background
                    print("🔄 已将 RGBA 转换为 RGB")

                # 保存参数
                save_kwargs = {"format": target_format, **options}

                if target_format == "AVIF":
                    save_kwargs.update({
                        "quality": quality,
                        "speed": 6,
                        "optimize": True,
                    })
                elif target_format == "WebP":
                    save_kwargs.update({
                        "quality": quality,
                        "optimize": True,
                    })
                elif target_format == "JPEG":
                    save_kwargs.update({
                        "quality": quality,
                        "optimize": True,
                    })

                img.save(output_path, **save_kwargs)

                # 计算压缩效果
                original_size = os.path.getsize(input_path)
                converted_size = os.path.getsize(output_path)
                compression_ratio = ((original_size - converted_size) / original_size) * 100

                print(f"✅ 转换成功: {input_path} ({original_size:,} bytes) → "
                      f"{output_path} ({converted_size:,} bytes) | "
                      f"压缩率: {compression_ratio:.1f}%")

                return True

        except Exception as e:
            print(f"❌ 转换失败 {input_path}: {e}")
            return False

# 使用示例
converter = ImageConverter()
converter.print_support_info()

# 转换为 AVIF
converter.convert_single('photo.jpg', 'photo.avif', quality=85)

# 转换为无损 AVIF
converter.convert_single('graphic.png', 'graphic.avif',
                        quality=100, lossless=True)

Web 应用集成示例

from flask import Flask, request, send_file
from werkzeug.utils import secure_filename
import tempfile
import os

app = Flask(__name__)

@app.route('/convert-to-avif', methods=['POST'])
def convert_to_avif_api():
    """Web API: 转换图片为 AVIF"""

    if 'file' not in request.files:
        return {'error': '没有文件上传'}, 400

    file = request.files['file']
    if file.filename == '':
        return {'error': '文件名为空'}, 400

    # 获取参数
    quality = int(request.form.get('quality', 85))
    speed = int(request.form.get('speed', 6))

    try:
        # 创建临时文件
        with tempfile.NamedTemporaryFile(delete=False, suffix='.avif') as tmp_output:
            output_path = tmp_output.name

        # 保存上传的文件
        with tempfile.NamedTemporaryFile(delete=False) as tmp_input:
            file.save(tmp_input.name)

            # 转换为 AVIF
            with Image.open(tmp_input.name) as img:
                img.save(output_path, format='AVIF',
                        quality=quality, speed=speed, optimize=True)

            # 清理输入文件
            os.unlink(tmp_input.name)

        # 返回 AVIF 文件
        return send_file(output_path, as_attachment=True,
                        download_name=f"{file.filename.rsplit('.', 1)[0]}.avif")

    except Exception as e:
        return {'error': f'转换失败: {str(e)}'}, 500

if __name__ == '__main__':
    app.run(debug=True)

性能对比与测试

压缩效果对比

基于实际测试数据(原始 PNG 文件 13,502 bytes):

格式文件大小压缩率质量评分加载速度
PNG13,502 bytes0%⭐⭐⭐⭐⭐⭐⭐⭐
JPEG8,685 bytes35.7%⭐⭐⭐⭐⭐⭐⭐⭐
WebP4,078 bytes69.8%⭐⭐⭐⭐⭐⭐⭐⭐⭐
AVIF2,408 bytes82.2%⭐⭐⭐⭐⭐⭐⭐⭐⭐⭐

性能测试代码

import time
import os
from PIL import Image

def performance_test(input_image_path):
    """性能测试:对比不同格式的转换速度和压缩效果"""

    formats_config = {
        'JPEG': {'quality': 85, 'optimize': True},
        'WebP': {'quality': 85, 'optimize': True},
        'AVIF': {'quality': 85, 'speed': 6, 'optimize': True},
    }

    results = {}
    original_size = os.path.getsize(input_image_path)

    with Image.open(input_image_path) as img:
        print(f"原始图片: {img.size} {img.mode} ({original_size:,} bytes)")
        print("-" * 60)

        for format_name, config in formats_config.items():
            output_path = f"test_output.{format_name.lower()}"

            # 计时转换过程
            start_time = time.time()

            try:
                if img.mode == 'RGBA' and format_name == 'JPEG':
                    # JPEG 不支持透明度
                    rgb_img = Image.new('RGB', img.size, (255, 255, 255))
                    rgb_img.paste(img, mask=img.split()[-1])
                    rgb_img.save(output_path, format=format_name, **config)
                else:
                    img.save(output_path, format=format_name, **config)

                conversion_time = time.time() - start_time
                output_size = os.path.getsize(output_path)
                compression_ratio = ((original_size - output_size) / original_size) * 100

                results[format_name] = {
                    'size': output_size,
                    'compression': compression_ratio,
                    'time': conversion_time,
                    'speed': original_size / conversion_time / 1024 / 1024  # MB/s
                }

                print(f"{format_name:5}: {output_size:8,} bytes | "
                      f"压缩率: {compression_ratio:5.1f}% | "
                      f"耗时: {conversion_time:5.3f}s | "
                      f"速度: {results[format_name]['speed']:5.1f} MB/s")

                # 清理输出文件
                os.remove(output_path)

            except Exception as e:
                print(f"{format_name:5}: ❌ 转换失败 - {e}")

    print("-" * 60)

    # 找出最佳压缩比
    best_compression = max(results.items(),
                          key=lambda x: x[1]['compression'])
    print(f"🏆 最佳压缩: {best_compression[0]} "
          f"({best_compression[1]['compression']:.1f}%)")

    return results

# 运行性能测试
# performance_test('test_image.png')

质量评估

from PIL import Image, ImageChops
import math

def calculate_psnr(img1_path, img2_path):
    """计算两张图片的峰值信噪比 (PSNR)"""
    img1 = Image.open(img1_path).convert('RGB')
    img2 = Image.open(img2_path).convert('RGB')

    # 确保尺寸一致
    if img1.size != img2.size:
        img2 = img2.resize(img1.size, Image.Resampling.LANCZOS)

    # 计算均方误差
    diff = ImageChops.difference(img1, img2)
    h = diff.histogram()

    # 计算 MSE
    sq = (value * (idx % 256) ** 2 for idx, value in enumerate(h))
    sum_of_squares = sum(sq)
    rms = math.sqrt(sum_of_squares / float(img1.size[0] * img1.size[1]))

    # 计算 PSNR
    if rms == 0:
        return float('inf')  # 图像完全相同

    psnr = 20 * math.log10(255.0 / rms)
    return psnr

def quality_comparison(original_path):
    """质量对比测试"""
    formats = ['JPEG', 'WebP', 'AVIF']

    print("🔍 图像质量对比 (PSNR 值越高越好):")
    print("-" * 50)

    for fmt in formats:
        output_path = f"quality_test.{fmt.lower()}"

        # 转换图像
        with Image.open(original_path) as img:
            if fmt == 'AVIF':
                img.save(output_path, format=fmt, quality=85, speed=6)
            else:
                img.save(output_path, format=fmt, quality=85, optimize=True)

        # 计算 PSNR
        psnr = calculate_psnr(original_path, output_path)
        file_size = os.path.getsize(output_path)

        print(f"{fmt:5}: PSNR = {psnr:6.2f} dB | 大小: {file_size:,} bytes")

        os.remove(output_path)

# 使用示例
# quality_comparison('original.png')

常见问题与解决方案

1. AVIF 支持检查失败

问题: features.check('avif') 返回 False

解决方案:

# 方案A: 使用插件
pip install pillow-avif-plugin

# 代码中必须先导入
import pillow_avif
from PIL import features
print(features.check('avif'))  # 应该返回 True

# 方案B: 重新编译 Pillow
pip uninstall pillow
export LDFLAGS="-L/opt/homebrew/lib"
export CPPFLAGS="-I/opt/homebrew/include"
pip install --no-binary pillow Pillow

2. 内存不足错误

问题: 处理大图时出现 OSError: cannot write mode RGBA as AVIF

解决方案:

def safe_avif_conversion(input_path, output_path, max_size=4096):
    """安全的 AVIF 转换,处理大图和内存限制"""
    with Image.open(input_path) as img:
        # 检查图片尺寸
        if max(img.size) > max_size:
            print(f"图片过大 {img.size},调整至 {max_size}")
            img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)

        # 处理颜色模式
        if img.mode in ('RGBA', 'LA'):
            # 检查是否真的需要 Alpha 通道
            if img.mode == 'RGBA':
                alpha = img.split()[-1]
                if alpha.getbbox() is None:  # 完全不透明
                    img = img.convert('RGB')
                    print("移除无效的 Alpha 通道")

        # 分块保存(对于极大的图片)
        img.save(output_path, format='AVIF', quality=85,
                speed=8, optimize=True)  # 使用更快的速度

3. 颜色失真问题

问题: AVIF 转换后颜色不准确

解决方案:

def color_accurate_avif(input_path, output_path):
    """保持颜色准确的 AVIF 转换"""
    with Image.open(input_path) as img:
        # 保持颜色配置文件
        icc_profile = img.info.get('icc_profile')

        # 确保使用合适的颜色空间
        if img.mode == 'P':  # 调色板模式
            img = img.convert('RGB')

        save_kwargs = {
            'format': 'AVIF',
            'quality': 95,  # 更高质量
            'speed': 4,     # 更慢但更准确
            'range': 'full',  # 完整色彩范围
        }

        # 保持 ICC 配置文件
        if icc_profile:
            save_kwargs['icc_profile'] = icc_profile

        img.save(output_path, **save_kwargs)

4. 兼容性问题

问题: 某些环境下 AVIF 不被支持

解决方案:

def fallback_conversion(input_path, output_path,
                       primary_format='AVIF', fallback_format='WebP'):
    """带降级的格式转换"""
    from PIL import features

    # 检查主要格式支持
    if features.check(primary_format.lower()):
        target_format = primary_format
        target_ext = '.avif'
    else:
        print(f"⚠️ {primary_format} 不支持,使用 {fallback_format}")
        target_format = fallback_format
        target_ext = '.webp'

    # 调整输出路径
    output_path = Path(output_path).with_suffix(target_ext)

    with Image.open(input_path) as img:
        if target_format == 'AVIF':
            img.save(output_path, format='AVIF', quality=85, speed=6)
        elif target_format == 'WebP':
            img.save(output_path, format='WebP', quality=85, optimize=True)

    return output_path

# 使用示例
output = fallback_conversion('photo.jpg', 'photo.avif')
print(f"保存为: {output}")

5. 批量处理性能优化

问题: 批量转换速度慢

解决方案:

import concurrent.futures
from multiprocessing import cpu_count

def parallel_avif_conversion(file_list, output_dir, max_workers=None):
    """并行 AVIF 转换"""
    if max_workers is None:
        max_workers = min(cpu_count(), len(file_list))

    def convert_single_file(file_path):
        output_path = Path(output_dir) / f"{file_path.stem}.avif"
        try:
            with Image.open(file_path) as img:
                # 快速转换设置
                img.save(output_path, format='AVIF',
                        quality=80, speed=8, optimize=True)
            return True, file_path
        except Exception as e:
            return False, file_path, str(e)

    results = {'success': 0, 'failed': 0, 'errors': []}

    with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor:
        future_to_file = {executor.submit(convert_single_file, file_path): file_path
                         for file_path in file_list}

        for future in concurrent.futures.as_completed(future_to_file):
            result = future.result()
            if result[0]:  # 成功
                results['success'] += 1
                print(f"✅ {result[1].name}")
            else:  # 失败
                results['failed'] += 1
                results['errors'].append((result[1], result[2]))
                print(f"❌ {result[1].name}: {result[2]}")

    print(f"\n📊 并行转换完成: {results['success']} 成功, {results['failed']} 失败")
    return results

# 使用示例
image_files = list(Path('./images').glob('*.jpg'))
parallel_avif_conversion(image_files, './avif_output', max_workers=4)

结论

AVIF 格式代表了图像压缩技术的未来,通过 Pillow 库可以轻松地集成到 Python 项目中。虽然配置过程可能需要一些额外步骤,但其卓越的压缩效率和图像质量使得这些努力完全值得。

关键要点

  1. 优先选择: AVIF 应该是现代 Web 应用的首选图像格式
  2. 合理降级: 为不支持的环境准备 WebP 或 JPEG 降级方案
  3. 质量平衡: 根据使用场景调整质量和速度参数
  4. 性能优化: 对于批量处理,使用并行转换提高效率

最佳实践

随着浏览器支持的不断完善,AVIF 将成为 Web 图像的标准格式。现在开始在项目中采用 AVIF,将为用户带来更快的加载速度和更好的视觉体验!


参考资源: