Нужно перегнать сотни или тысячи картинок в WebP — знакомая задача, если вы работаете над сайтом, готовите контент для маркетплейса или просто хотите уменьшить размер файлов без видимой потери качества. Графические редакторы требуют ручной возни, онлайн-сервисы — загрузки и ожидания, а платные утилиты не всегда оправданы, когда речь идёт о разовой задаче. Python решает её одной командой — и сейчас я покажу как именно.
- Почему WebP и зачем вообще массовая конвертация
- Подготовка: что понадобится
- Самый простой скрипт
- Если изображения в разных папках — рекурсивный обход
- Сохранение прозрачности в WebP
- Какое качество выбрать
- Параллельная обработка для больших объёмов
- Добавляем логирование и контроль
- Конвертация по списку с заменой оригиналов
- Сжатие без потери качества (lossless)
- Частые ошибки и как их избежать
- Какой вариант выбрать под вашу задачу
- Автоматизация и интеграция
- Что в итоге
Почему 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-скрипт для конвертации прослужит долго и будет решать задачу надёжнее любого онлайн-конвертера.


