Нужно перегнать сотни или тысячи картинок в WebP — знакомая задача, если вы работаете над сайтом, готовите контент для маркетплейса или просто хотите уменьшить размер файлов без видимой потери качества. Графические редакторы требуют ручной возни, онлайн-сервисы — загрузки и ожидания, а платные утилиты не всегда оправданы, когда речь идёт о разовой задаче. Python решает её одной командой — и сейчас я покажу как именно.

Почему WebP и зачем вообще массовая конвертация

WebP — формат от Google, который даёт файлы на 25–35% легче, чем JPEG, при визуально том же качестве. Поддерживается всеми актуальными браузерами. Если у вас интернет-магазин с 2000 товарами, каждый из которых имеет 3–5 фотографий, перевод изображений в WebP может сократить объём хранилища в разы и ускорить загрузку страниц. Google PageSpeed точно перестанет ругаться.

Массовая конвертация нужна, когда файлов больше нескольких десятков. Ручная обработка в таких объёмах — потеря времени и верный путь к ошибкам: то один файл пропустишь, то качество криво подберёшь.

Подготовка: что понадобится

Python уже должен быть установлен. Если нет — скачайте с python.org (для Windows не забудьте галочку «Add Python to PATH» при установке).

Ключевая библиотека здесь — Pillow (форк PIL). Она умеет открывать, обрабатывать и сохранять десятки графических форматов, включая WebP. Устанавливается одной строкой в терминале:

pip install Pillow

Проверьте, что установка прошла успешно:

python -c "from PIL import Image; print('Pillow работает')"

Если увидели «Pillow работает» — готово. Если ошибка — скорее всего, Python не добавлен в PATH, попробуйте командную строку Python от имени администратора или переустановите с нужной галочкой.

Самый простой скрипт

Вот минимальный рабочий вариант. Положите его в папку с изображениями и запустите:

from PIL import Image
from pathlib import Path

input_dir = Path("./images")
output_dir = Path("./webp_output")
output_dir.mkdir(exist_ok=True)

for img_path in input_dir.glob("*.*"):
    if img_path.suffix.lower() in {".jpg", ".jpeg", ".png", ".bmp", ".tiff"}:
        try:
            img = Image.open(img_path)
            out_path = output_dir / f"{img_path.stem}.webp"
            img.save(out_path, "WEBP", quality=80, method=6)
            print(f"Готово: {img_path.name} -> {out_path.name}")
        except Exception as e:
            print(f"Ошибка с {img_path.name}: {e}")

Что здесь происходит: скрипт берёт все файлы из папки images, открывает каждый и сохраняет в WebP с качеством 80. Результат попадает в папку webp_output. Если файл повреждён или имеет неожиданный формат — скрипт не падает, а пишет ошибку и идёт дальше.

Параметр method=6 — это способ сжатия. Чем выше номер (от 0 до 6), тем файл легче, но конвертация идёт дольше. Шестой — это самый медленный, но самый эффективный алгоритм. Для массовой обработки на сервере имеет смысл ставить 4 или 5, чтобы найти баланс.

Если изображения в разных папках — рекурсивный обход

На практике картинки редко лежат одной кучей. Обычно это структура вида /goods/category1/item_001/photo1.jpg. Вот скрипт, который обходит все вложенные папки и сохраняет структуру:

from PIL import Image
from pathlib import Path

input_dir = Path("./source_images")
output_dir = Path("./webp_output")

image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tiff"}

for img_path in input_dir.rglob("*"):
    if img_path.suffix.lower() in image_extensions:
        relative_path = img_path.relative_to(input_dir)
        out_path = output_dir / relative_path.with_suffix(".webp")
        out_path.parent.mkdir(parents=True, exist_ok=True)
        
        try:
            img = Image.open(img_path)
            if img.mode in ("RGBA", "LA"):
                img = img.convert("RGB")
            img.save(out_path, "WEBP", quality=80)
        except Exception as e:
            print(f"{img_path}: {e}")

Обратите внимание на строку с проверкой режима: if img.mode in ("RGBA", "LA"). PNG-файлы с прозрачностью в WebP конвертируются без проблем, но иногда Pillow ведёт себя нестабильно с определёнными комбинациями режимов. Явное преобразование в RGB для тех файлов, где прозрачность не нужна, избавляет от странных артефактов.

Сохранение прозрачности в WebP

WebP поддерживает альфа-канал, но нужно явно указать, что мы хотим сохранить прозрачность:

img.save(out_path, "WEBP", quality=80, lossless=False)

А вот более полный вариант с обработкой прозрачности:

from PIL import Image
from pathlib import Path

def convert_with_transparency(img_path, output_path, quality=80):
    img = Image.open(img_path)
    
    has_transparency = (
        img.mode in ("RGBA", "LA", "PA") or
        "transparency" in img.info
    )
    
    if img.mode not in ("RGB", "RGBA"):
        if has_transparency:
            img = img.convert("RGBA")
        else:
            img = img.convert("RGB")
    
    kwargs = {"format": "WEBP", "quality": quality}
    if img.mode == "RGBA":
        kwargs["method"] = 6
    
    img.save(output_path, kwargs)

Какое качество выбрать

Параметр quality влияет на итоговый размер и визуальное качество неочевидно. Вот реальная картина, основанная на моём опыте конвертации фотографий товаров и контента для веба:

Качество Размер оригинала (JPEG) Размер WebP Визуальная разница Когда использовать
95 500 КБ 320 КБ Заметна только при пиксельном сравнении Портреты, печать, контент с тонкими градиентами
85 500 КБ 200 КБ Еле заметна на глаз Баланс качества и размера для большинства задач
80 500 КБ 150 КБ Не видна без прямого сравнения Товарные карточки, статьи, ленты
70 500 КБ 95 КБ Становится заметна на контрастных границах Баннеры, фоновые изображения, превью
50 500 КБ 45 КБ Артефакты видны отчётливо Только если размер критичен и качество не важно

Для большинства веб-задач качество 80 — это золотая середина. Если сомневаетесь, сделайте тестовую конвертацию одного файла с разными значениями и посмотрите сами.

Параллельная обработка для больших объёмов

Если у вас 50 000 файлов, последовательная обработка займёт приличное время. Используем concurrent.futures, чтобы задействовать все ядра процессора:

from PIL import Image
from pathlib import Path
from concurrent.futures import ProcessPoolExecutor, as_completed
import time

input_dir = Path("./big_image_folder")
output_dir = Path("./webp_output")

image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".tif"}

def convert_single(img_path):
    relative_path = img_path.relative_to(input_dir)
    out_path = output_dir / relative_path.with_suffix(".webp")
    out_path.parent.mkdir(parents=True, exist_ok=True)
    
    try:
        img = Image.open(img_path)
        if img.mode not in ("RGB", "RGBA"):
            img = img.convert("RGB")
        img.save(out_path, "WEBP", quality=80)
        return True, img_path.name
    except Exception as e:
        return False, f"{img_path.name}: {e}"

image_files = [p for p in input_dir.rglob("*") if p.suffix.lower() in image_extensions]

print(f"Найдено {len(image_files)} файлов...")

start = time.time()
success = 0
errors = 0

with ProcessPoolExecutor() as executor:
    futures = {executor.submit(convert_single, fp): fp for fp in image_files}
    for i, future in enumerate(as_completed(futures), 1):
        ok, msg = future.result()
        if ok:
            success += 1
        else:
            errors += 1
            print(msg)
        if i % 500 == 0:
            print(f"Обработано: {i}/{len(image_files)}")

elapsed = time.time() - start
print(f"Готово: {success} Ok, {errors} ошибок, время: {elapsed:.1f} сек")

На ACHINE с 8 ядрами это ускоряет обработку примерно в 5–7 раз по сравнению с последовательной версией. Главное — использовать именно ProcessPoolExecutor, а не ThreadPoolExecutor, потому что Pillow при декодировании изображений отпускает GIL, но процессный пул работает стабильнее для CPU-задач.

Добавляем логирование и контроль

На реальных проектах полезно видеть, что произошло с каждым файлом — сохранился ли, каким стал размер, насколько сжался. Вот расширенный скрипт с логированием:

import logging
from PIL import Image
from pathlib import Path
from datetime import datetime, timedelta

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s %(levelname)s %(message)s",
    handlers=[
        logging.FileHandler("convert.log", encoding="utf-8"),
        logging.StreamHandler()
    ]
)
logger = logging.getLogger(__name__)

def convert_with_stats(input_dir, output_dir, quality=80):
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)
    start = datetime.now()
    
    image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tiff"}
    files = [p for p in input_dir.rglob("*") if p.suffix.lower() in image_extensions]
    
    logger.info(f"Найдено файлов: {len(files)}")
    if not files:
        logger.warning("Нет файлов для обработки. Проверьте папку и расширения.")
        return
    
    total_orig = 0
    total_webp = 0
    done = 0
    
    for img_path in files:
        relative_path = img_path.relative_to(input_dir)
        out_path = output_dir / relative_path.with_suffix(".webp")
        out_path.parent.mkdir(parents=True, exist_ok=True)
        
        try:
            img = Image.open(img_path)
            if img.mode not in ("RGB", "RGBA"):
                img = img.convert("RGB")
            img.save(out_path, "WEBP", quality=quality)
            
            orig_size = img_path.stat().st_size
            webp_size = out_path.stat().st_size
            total_orig += orig_size
            total_webp += webp_size
            done += 1
            
            ratio = (1 - webp_size / orig_size) * 100
            logger.info(
                f"{relative_path}: {orig_size//1024}КБ → {webp_size//1024}КБ (−{ratio:.1f}%)"
            )
        except Exception as e:
            logger.error(f"Не удалось: {relative_path}: {e}")
    
    elapsed = datetime.now() - start
    if done and total_orig:
        saved = (total_orig - total_webp) / 1024 / 1024
        avg_ratio = (1 - total_webp / total_orig) * 100
        logger.info(
            f"Итого: {done} файлов за {timedelta(seconds=int(elapsed.total_seconds()))}"
        )
        logger.info(
            f"Сэкономлено: {saved:.1f} МБ ({avg_ratio:.1f}% от оригинала)"
        )

if __name__ == "__main__":
    convert_with_stats("./images", "./webp_output", quality=80)

Такой скрипт пишет и в консоль, и в файл convert.log. После обработки вы точно знаете, сколько места удалось сэкономить и какие файлы вызвали проблемы.

Конвертация по списку с заменой оригиналов

Иногда нужно не создавать новые файлы, а заменить старые — например, когда сайт уже ссылается на определённые пути. Это менее безопасный подход, поэтому обязательно делайте бэкап:

import shutil
from pathlib import Path
from PIL import Image

input_dir = Path("./gallery")
backup_dir = Path("./gallery_backup")

# Создаём резервную копию
if not backup_dir.exists():
    shutil.copytree(input_dir, backup_dir)
    print("Бэкап создан")

for img_path in input_dir.rglob("*"):
    if img_path.suffix.lower() in {".jpg", ".jpeg", ".png"}:
        img = Image.open(img_path)
        if img.mode not in ("RGB", "RGBA"):
            img = img.convert("RGB")
        # Конвертируем в WebP с тем же именем, но другим расширением
        webp_path = img_path.with_suffix(".webp")
        img.save(webp_path, "WEBP", quality=85)
        if webp_path.exists() and webp_path.stat().st_size > 0:
            img_path.unlink()  # удаляем оригинал только если WebP корректный
        else:
            print(f"ВНИМАНИЕ: {img_path} не удалён — ошибка конвертации")

Сжатие без потери качества (lossless)

Если нужна идентичность оригиналу по пикселям — WebP поддерживает и такой режим. Файл всё равно будет легче JPEG, просто не так радикально:

img.save(out_path, "WEBP", lossless=True, method=6, quality=100)

Мой опыт: для фотографий lossless-WebP обычно даёт экономию лишь 15–20% по сравнению с качественным JPEG (quality=85). А вот для скринштов, снимков интерфейсов и изображений с текстом — сжатие может достигать 50–60% без какой-либо потери качества. Используйте lossless именно для таких типов контента.

Частые ошибки и как их избежать

Ошибка Что происходит Решение
Не указан формат при сохранении Сохраняется с расширением .webp, но внутри может быть PNG Всегда указывайте "WEBP" как формат в save()
Спутаны пути — файлы исчезают Скрипт пишет не туда или перезаписывает что не нужно Всегда используйте out_path.parent.mkdir(parents=True, exist_ok=True)
ANDError / не удаётся открыть Файл имеет расширение .jpg, но внутри другой формат или он повреждён Используйте try/except вокруг каждого файла, не доверяйте расширениям
GIANT WebP получился Выставили lossless=True + method=0 для фотографий Для фото используйте lossless=False и не ставьте method=0
Ошибка OSError: cannot write mode P as WebP Палитровые и 8-битные PNG с палитрой Конвертируйте в RGB или RGBA перед сохранением, см. код в начале статьи

Ещё одна частая проблема — скрипт пытается обработать не-изображения, которые случайно попали в папку. Пустые файлы, битые загрузки, системные файлы macOS (.DS_Store) или Thumbs.db на Windows. Всегда фильтруйте по расширению и оборачивайте каждую операцию в try/except.

Какой вариант выбрать под вашу задачу

Менее 100 картинок, веб-сайт: берите самый простой скрипт с качеством 80. Заполните папку, запустите, закиньте на хостинг. Полчаса работы.

Сотни файлов, структура из подпапок: скрипт с rglob, сохранением структуры и качеством 80–85. Добавьте проверку режима цвета.

Тысячи и больше (сервер, регулярная обработка): параллельная версия с ProcessPoolExecutor + логирование. Можно запускать по cron для обработки новых загрузок.

Хотите знать точный эффект от конвертации: берите скрипт со статистикой. Он покажет экономию в мегабайтах и средний процент сжатия — полезно для отчётов.

Нужно заменить оригиналы (опасный путь): версия с бэкапом. Обязательно проверьте, что сайт после замены работает корректно.

Автоматизация и интеграция

Готовый скрипт можно оформить в виде модуля с аргументами командной строки, чтобы каждый раз не править код:

import argparse
from pathlib import Path
from PIL import Image
import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

def convert_dir(input_dir, output_dir, quality, lossless=False):
    input_dir = Path(input_dir)
    output_dir = Path(output_dir)
    
    exts = {".jpg", ".jpeg", ".png", ".bmp", ".tiff"}
    files = [p for p in input_dir.rglob("*") if p.suffix.lower() in exts]
    
    for img_path in files:
        rel = img_path.relative_to(input_dir)
        out_path = output_dir / rel.with_suffix(".webp")
        out_path.parent.mkdir(parents=True, exist_ok=True)
        
        try:
            img = Image.open(img_path)
            if img.mode not in ("RGB", "RGBA"):
                img = img.convert("RGB")
            img.save(out_path, "WEBP", quality=quality, lossless=lossless)
            logger.info(f"{rel}")
        except Exception as e:
            logger.error(f"{rel}: {e}")
    
    logger.info("Завершено")

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Массовая конвертация в WebP")
    parser.add_argument("input", help="Входная папка")
    parser.add_argument("output", help="Выходная папка")
    parser.add_argument("--quality", type=int, default=80)
    parser.add_argument("--lossless", action="store_true")
    args = parser.parse_args()
    convert_dir(args.input, args.output, args.quality, args.lossless)

Запуск из терминала:

python convert_webp.py ./photos ./webp_output --quality 85

Или в другом проекте просто импортируйте функцию convert_dir и вызовите с нужными аргументами.

Что в итоге

Python + Pillow дают полностью бесплатное, гибкое и при этом простое решение для пакетной конвертации в WebP. Не нужно ни платных программ, ни онлайн-сервисов с лимитами на размер файла.

Начните с простого скрипта — он уже покрывает 80% задач. Добавьте логирование, если нужно отслеживать результаты. Если файлов много — подключите параллельную обработку. Для регулярных задач оберните в модуль с аргументами командной строки.

Главное — всегда делайте бэкап перед массовой заменой оригиналов, фильтруйте расширения и не игнорируйте исключения. С таким подходом Python-скрипт для конвертации прослужит долго и будет решать задачу надёжнее любого онлайн-конвертера.