Зачем пакетно сжимать изображения
У вас есть 1000 высокresolution фотографий, экспортированных с камеры, каждая размером около 8 МБ, и весь каталог занимает почти 8 ГБ. Если цель — загрузить их на веб-сайт, встроить в презентацию или отправить по электронной почте, такой объём совершенно неприемлем. Сжимать их по одной в графическом редакторе неэффективно и чревато ошибками — именно здесь проявляет себя скриптовая пакетная обработка.
Библиотека Pillow от Python — фактический стандарт для обработки изображений. Она обеспечивает поддержку чтения и записи основных форматов, включая JPEG, PNG, WebP, BMP и TIFF, а также точный контроль над коэффициентами качества, частотами дискретизации и цветовыми режимами. С помощью нескольких десятков строк Python вы можете обойти всё дерево каталогов, применить единую стратегию сжатия и сохранить исходную структуру папок в выводе.
Эта статья начинается с базового использования Pillow и постепенно строит полный, исполняемый, многопоточный скрипт пакетного сжатия. Он также охватывает практические инженерные детали, такие как сохранение EXIF, преобразование форматов и интеллектуальный выбор параметров.
Установка и базовое использование Pillow
Установка
Pillow — активно поддерживаемый форк PIL (Python Imaging Library), но имя импорта остаётся PIL. Установите его с помощью pip:
pip install Pillow
Если вам нужно обрабатывать большой объём изображений или требуется более быстрое декодирование, установите полную версию с поддержкой плагинов:
pip install "Pillow[all]"
После установки проверьте версию:
from PIL import Image, __version__
print(__version__) # например, 10.4.0
Базовые операции
Главная точка входа Pillow — класс Image. Три наиболее частые операции — открыть, изменить и сохранить:
from PIL import Image
# Открыть изображение (ленивая загрузка; данные пикселей считываются при первом обращении)
img = Image.open("photo.jpg")
print(img.format, img.size, img.mode) # JPEG (4000, 3000) RGB
# Получить значение пикселя
pixel = img.getpixel((100, 100))
# Изменить размер
resized = img.resize((1920, 1080))
# Сохранить
resized.save("photo_small.jpg", quality=85)
Стоит отметить несколько деталей: объект, возвращаемый Image.open(), удерживает ссылку на файл, которая не освобождается до явного закрытия или выхода из блока with. При пакетной обработке всегда используйте контекстный менеджер или вызывайте close() явно, иначе дескрипторы файлов будут утекать.
Примеры сжатия одного изображения
Основа пакетного сжатия — обработка одного изображения. Сначала мы реализуем функцию, поддерживающую регулировку качества JPEG, уменьшение разрешения и преобразование формата.
Регулировка качества JPEG
Параметр quality для JPEG варьируется от 1 до 100 и напрямую управляет масштабированием таблицы квантования. Это главная настройка для баланса размера файла и качества изображения:
from PIL import Image
def compress_jpeg(input_path, output_path, quality=75):
"""Сжать JPEG с указанным коэффициентом качества"""
with Image.open(input_path) as img:
# JPEG не поддерживает прозрачность; режимы RGBA/P нужно сначала преобразовать
if img.mode in ("RGBA", "P", "LA"):
img = img.convert("RGB")
elif img.mode != "RGB":
img = img.convert("RGB")
img.save(
output_path,
format="JPEG",
quality=quality,
optimize=True, # Включить оптимизацию кодирования Хаффмана
progressive=True, # Создать прогрессивный JPEG; немного больше, но лучше опыт загрузки
)
# Сравнение разных коэффициентов качества
compress_jpeg("photo.jpg", "q95.jpg", quality=95)
compress_jpeg("photo.jpg", "q75.jpg", quality=75)
compress_jpeg("photo.jpg", "q50.jpg", quality=50)
optimize=True заставляет Pillow дополнительно сканировать данные при сохранении, чтобы перестроить оптимальную таблицу кодирования Хаффмана. Обычно это уменьшает размер файла ещё на 2-5% ценой несколько более медленного сохранения.
Корректировка разрешения
Для веб-отображения оригинал 4000x3000 значительно больше необходимого. Использование thumbnail() для пропорционального уменьшения — самый эффективный подход: уменьшение разрешения вдвое сокращает размер файла примерно на 75%:
from PIL import Image
def resize_image(input_path, output_path, max_size=1920, quality=80):
"""Масштабировать длинную сторону до max_size с сохранением соотношения сторон"""
with Image.open(input_path) as img:
# thumbnail изменяет на месте и никогда не превышает указанные размеры
# Image.Resampling.LANCZOS — лучший по качеству алгоритм понижающей дискретизации
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
if img.mode != "RGB":
img = img.convert("RGB")
img.save(output_path, format="JPEG", quality=quality, optimize=True)
resize_image("photo.jpg", "photo_1920.jpg", max_size=1920, quality=80)
Разница между thumbnail() и resize() в том, что thumbnail() сохраняет соотношение сторон и никогда не увеличивает изображение — финальные размеры всегда меньше или равны указанным значениям. resize() принудительно задаёт изображению точные целевые размеры. При пакетной обработке вы почти всегда хотите thumbnail().
Преобразование формата
Преобразование PNG в WebP — распространённая оптимизация: WebP на 26% меньше PNG и на 25-35% меньше JPEG при эквивалентном качестве, а также поддерживает прозрачность:
from PIL import Image
def convert_to_webp(input_path, output_path, quality=80, lossless=False):
"""Преобразовать любой формат в WebP"""
with Image.open(input_path) as img:
img.save(
output_path,
format="WebP",
quality=quality,
lossless=lossless, # При True используется режим без потерь
method=6, # Усилие сжатия 0-6; 6 — самый медленный, но самый маленький
)
convert_to_webp("icon.png", "icon.webp", quality=85)
convert_to_webp("logo.png", "logo_lossless.webp", lossless=True)
Параметр method управляет усилием сжатия от 0 до 6. Более высокие значения дают лучшие коэффициенты сжатия, но занимают больше времени. Для пакетной обработки, где важен итоговый размер файла, установите его на 6; для скорости 4 — хороший баланс.
Полный скрипт пакетного сжатия
Теперь мы собираем логику одноизображенческой обработки в полный пакетный скрипт. Этот скрипт обходит все изображения во входном каталоге, сохраняет исходную структуру папок в выводе и отображает прогресс обработки вместе с общими результатами сжатия.
Приведённая ниже блок-схема иллюстрирует общий рабочий процесс пакетной обработки:
Полный скрипт:
"""
batch_compress.py - Скрипт пакетного сжатия изображений
Использование: python batch_compress.py <input_dir> <output_dir> [--quality 75] [--max-size 1920]
"""
import os
import sys
import argparse
from pathlib import Path
from PIL import Image
SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff", ".tif"}
def compress_single(input_path, output_path, quality=75, max_size=1920,
target_format=None, keep_exif=True):
"""Сжать одно изображение"""
with Image.open(input_path) as img:
# 1. Извлечь данные EXIF (время съёмки, модель камеры, GPS и т. д.)
exif_data = img.info.get("exif", b"") if keep_exif else b""
# 2. Преобразование цветового режима: JPEG не поддерживает прозрачность
out_format = (target_format or img.format or "JPEG").upper()
if out_format in ("JPEG", "JPG") and img.mode in ("RGBA", "P", "LA"):
# Композит на белый фон, чтобы избежать чёрных областей после преобразования
background = Image.new("RGB", img.size, (255, 255, 255))
if img.mode == "P":
img = img.convert("RGBA")
background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None)
img = background
elif img.mode not in ("RGB", "RGBA", "L"):
img = img.convert("RGB")
# 3. Корректировка разрешения: только уменьшение, никогда не увеличение
if max_size and max(img.size) > max_size:
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
# 4. Сохранение
save_kwargs = {"optimize": True}
if out_format in ("JPEG", "JPG"):
save_kwargs["quality"] = quality
save_kwargs["progressive"] = True
if exif_data:
save_kwargs["exif"] = exif_data
elif out_format == "WEBP":
save_kwargs["quality"] = quality
save_kwargs["method"] = 6
elif out_format == "PNG":
save_kwargs["optimize"] = True
img.save(output_path, format=out_format, **save_kwargs)
def batch_compress(input_dir, output_dir, quality=75, max_size=1920,
target_format=None, keep_exif=True):
"""Пакетно сжать весь каталог"""
input_dir = Path(input_dir)
output_dir = Path(output_dir)
# Собрать все файлы для обработки
tasks = []
for root, _, files in os.walk(input_dir):
for filename in files:
if Path(filename).suffix.lower() in SUPPORTED_FORMATS:
src = Path(root) / filename
rel = src.relative_to(input_dir)
dst = output_dir / rel
# Скорректировать расширение в зависимости от целевого формата
if target_format:
dst = dst.with_suffix(f".{target_format.lower()}")
tasks.append((src, dst))
total = len(tasks)
if total == 0:
print("Поддерживаемые файлы изображений не найдены")
return
print(f"Найдено {total} изображений. Запуск сжатия...\n")
processed = 0
total_original = 0
total_compressed = 0
errors = []
for src, dst in tasks:
# Убедиться, что выходной каталог существует
dst.parent.mkdir(parents=True, exist_ok=True)
original_size = src.stat().st_size
try:
compress_single(src, dst, quality, max_size, target_format, keep_exif)
new_size = dst.stat().st_size
total_original += original_size
total_compressed += new_size
except Exception as e:
errors.append((src, str(e)))
new_size = original_size # При ошибке считать как оригинальный размер
processed += 1
pct = processed / total * 100
saved = (1 - new_size / original_size) * 100 if original_size else 0
print(f"[{pct:5.1f}%] ({processed}/{total}) {src.relative_to(input_dir)} "
f"{original_size // 1024}KB -> {new_size // 1024}KB (-{saved:.1f}%)")
# Сводный отчёт
print(f"\n{'=' * 50}")
print(f"Готово: {processed} успешно, {len(errors)} с ошибками")
if total_original > 0:
ratio = total_original / total_compressed if total_compressed else 0
saved_mb = (total_original - total_compressed) / 1024 / 1024
print(f"Исходный размер: {total_original / 1024 / 1024:.2f} MB")
print(f"Сжатый размер: {total_compressed / 1024 / 1024:.2f} MB")
print(f"Сэкономлено места: {saved_mb:.2f} MB (коэффициент {ratio:.2f}x)")
for src, err in errors:
print(f" Ошибка: {src} - {err}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Инструмент пакетного сжатия изображений")
parser.add_argument("input_dir", help="Входной каталог изображений")
parser.add_argument("output_dir", help="Выходной каталог")
parser.add_argument("--quality", type=int, default=75, help="Коэффициент качества JPEG/WebP (1-100)")
parser.add_argument("--max-size", type=int, default=1920, help="Максимальная длинная сторона в пикселях")
parser.add_argument("--format", default=None, help="Целевой формат (JPEG/WebP/PNG)")
parser.add_argument("--no-exif", action="store_true", help="Отбросить данные EXIF")
args = parser.parse_args()
batch_compress(
args.input_dir,
args.output_dir,
quality=args.quality,
max_size=args.max_size,
target_format=args.format,
keep_exif=not args.no_exif,
)
Примеры использования:
# Базовое использование
python batch_compress.py ./photos ./output
# Преобразовать в WebP, качество 80, максимальная длинная сторона 2560
python batch_compress.py ./photos ./output --quality 80 --max-size 2560 --format webp
Этот скрипт основан на трёх ключевых принципах проектирования: он использует os.walk() для рекурсивного обхода и сохранения структуры каталогов; использует relative_to() для вычисления относительных путей, гарантируя, что структура вывода совпадает со входом; и использует Path.stat() для получения размеров файлов и расчёта фактического эффекта сжатия.
Продвинутые техники
Сохранение информации EXIF
Данные EXIF содержат время съёмки, GPS-координаты, параметры камеры, диафрагму, выдержку и другие метаданные. По умолчанию img.save() отбрасывает эту информацию. Чтобы сохранить её, нужно извлечь её вручную и передать при сохранении:
from PIL import Image
def compress_with_exif(input_path, output_path, quality=75):
with Image.open(input_path) as img:
exif = img.info.get("exif", b"") # Извлечь сырые байты EXIF
if img.mode != "RGB":
img = img.convert("RGB")
img.save(
output_path,
format="JPEG",
quality=quality,
exif=exif if exif else None, # Передать None, если EXIF нет
optimize=True,
)
Если нужно сохранить только определённые поля EXIF (например, только тег ориентации), используйте метод getexif() для выбора элементов по отдельности:
from PIL import ExifTags
def keep_orientation_only(input_path, output_path, quality=75):
with Image.open(input_path) as img:
exif = img.getexif()
orientation = exif.get(0x0112) # Тег ориентации
# Повернуть изображение в соответствии с тегом ориентации
if orientation:
from PIL import ImageOps
img = ImageOps.exif_transpose(img)
img.save(output_path, format="JPEG", quality=quality, optimize=True)
ImageOps.exif_transpose() автоматически поворачивает изображение в соответствии с тегом ориентации EXIF. Это особенно полезно при обработке фотографий, сделанных на смартфоны — многие датчики телефонов установлены в альбомной ориентации и полагаются на тег ориентации EXIF для указания правильного направления отображения.
PNG в WebP
При преобразовании PNG в WebP нужно выбирать между режимами с потерями и без потерь в зависимости от характеристик изображения. Для изображений с текстом и чёткими линиями (иконки, элементы интерфейса) WebP без потерь сохраняет резкие края. Для фотографического контента WebP с потерями даёт значительное преимущество в размере:
from PIL import Image
def png_to_webp(input_path, output_path, lossless_threshold=0.3):
"""Интеллектуально выбрать WebP с потерями или без потерь на основе характеристик изображения"""
with Image.open(input_path) as img:
# Подсчитать цвета, чтобы определить, является ли это простой графикой
colors = img.getcolors(maxcolors=65536)
is_simple = colors is not None and len(colors) < 256
if is_simple:
# Мало цветов, резкие края: режим без потерь
img.save(output_path, format="WebP", lossless=True, method=6)
print(f"WebP без потерь (количество цветов: {len(colors)})")
else:
# Богатые цвета, фотография: режим с потерями
if img.mode != "RGB":
img = img.convert("RGB")
img.save(output_path, format="WebP", quality=82, method=6)
print("WebP с потерями (фотография)")
getcolors() возвращает список цветов или None (когда количество цветов превышает maxcolors). Небольшое количество цветов обычно указывает на простую графику, такую как иконка или скриншот, которая лучше подходит для сжатия без потерь.
Интеллектуальный выбор параметров
Разные изображения лучше всего подходят для разных стратегий сжатия. Следующая функция автоматически выбирает формат и параметры на основе разрешения, прозрачности и количества цветов:
from PIL import Image
def smart_compress(input_path, output_path):
"""Автоматически выбрать оптимальную стратегию сжатия на основе характеристик изображения"""
with Image.open(input_path) as img:
width, height = img.size
pixel_count = width * height
mode = img.mode
has_transparency = mode in ("RGBA", "LA") or (
mode == "P" and "transparency" in img.info
)
# Стратегия 1: Есть прозрачность -> WebP (баланс прозрачности и размера)
if has_transparency:
if pixel_count > 2_000_000:
q = 80
else:
q = 88
img.save(output_path, format="WebP", quality=q, method=6)
return "WebP с потерями (прозрачный)"
# Стратегия 2: Очень большое фото -> уменьшить + JPEG среднее качество
if pixel_count > 8_000_000 or width > 4000:
img = img.convert("RGB")
img.thumbnail((2560, 2560), Image.Resampling.LANCZOS)
img.save(output_path, format="JPEG", quality=75, optimize=True)
return "JPEG (большое изображение уменьшено)"
# Стратегия 3: Обычное фото -> JPEG более высокое качество
if pixel_count > 500_000:
img = img.convert("RGB")
img.save(output_path, format="JPEG", quality=82, optimize=True)
return "JPEG (высокое качество)"
# Стратегия 4: Маленькое изображение -> оставить как PNG
img.save(output_path, format="PNG", optimize=True)
return "PNG (маленькое изображение сохранено)"
Основная логика этой стратегии: изображения с прозрачностью идут в WebP, большие изображения уменьшаются перед сжатием, обычные фотографии используют JPEG, а маленькие изображения используют PNG для точности. Вы можете настроить пороговые значения в соответствии с вашими реальными требованиями.
Оптимизация производительности
Многопоточная обработка
Сжатие изображений — задача, сочетающая работу, ограниченную вводом-выводом, и работу, ограниченную процессором. Чтение/запись диска и кодирование каждое занимают часть времени. GIL Python ограничивает чистый параллелизм процессора, но Pillow освобождает GIL при вызове базовых библиотек C для кодирования JPEG/WebP, поэтому многопоточность может дать существенное ускорение:
import os
from pathlib import Path
from PIL import Image
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
SUPPORTED_FORMATS = {".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tiff"}
_print_lock = threading.Lock()
def compress_single(input_path, output_path, quality=75, max_size=1920):
with Image.open(input_path) as img:
exif = img.info.get("exif", b"")
if img.mode != "RGB":
img = img.convert("RGB")
if max_size and max(img.size) > max_size:
img.thumbnail((max_size, max_size), Image.Resampling.LANCZOS)
kwargs = {"quality": quality, "optimize": True, "progressive": True}
if exif:
kwargs["exif"] = exif
img.save(output_path, format="JPEG", **kwargs)
def batch_compress_threaded(input_dir, output_dir, quality=75, max_size=1920,
workers=4):
input_dir = Path(input_dir)
output_dir = Path(output_dir)
# Собрать задачи
tasks = []
for root, _, files in os.walk(input_dir):
for f in files:
if Path(f).suffix.lower() in SUPPORTED_FORMATS:
src = Path(root) / f
rel = src.relative_to(input_dir)
dst = output_dir / rel
dst.parent.mkdir(parents=True, exist_ok=True)
tasks.append((src, dst))
total = len(tasks)
print(f"Всего {total} изображений, обработка {workers} потоками\n")
completed = 0
total_saved = 0
with ThreadPoolExecutor(max_workers=workers) as executor:
futures = {
executor.submit(compress_single, src, dst, quality, max_size): (src, dst)
for src, dst in tasks
}
for future in as_completed(futures):
src, dst = futures[future]
try:
future.result()
saved = src.stat().st_size - dst.stat().st_size
total_saved += saved
except Exception as e:
with _print_lock:
print(f"Ошибка: {src.name} - {e}")
completed += 1
with _print_lock:
print(f"[{completed / total * 100:5.1f}%] ({completed}/{total}) {src.name}")
print(f"\nГотово. Сэкономлено места: {total_saved / 1024 / 1024:.2f} MB")
if __name__ == "__main__":
batch_compress_threaded("./photos", "./output", quality=75, max_size=1920, workers=4)
Эмпирическое правило для количества потоков — число логических ядер процессора. На 8-ядерной машине 4-6 потоков обычно достигают почти оптимального ускорения. Слишком много потоков могут фактически снизить производительность из-за конкуренции за дисковый ввод-вывод и давления на память.
Если нужно пойти дальше в параллелизме, можно использовать ProcessPoolExecutor для обхода GIL. Компромисс в том, что процессы не могут разделять память, каждый процесс должен независимо загружать библиотеку Pillow, а накладные расходы на запуск выше.
Управление памятью
Потребление памяти становится предметом беспокойства при обработке изображений высокого разрешения. Одно изображение RGB 6000x4000 занимает около 72 МБ памяти после декодирования (6000x4000x3 байт). Если открыты 10 одновременно, это 720 МБ.
Несколько ключевых принципов управления памятью:
from PIL import Image
import gc
# 1. Всегда использовать оператор with, чтобы гарантировать оперативное освобождение дескрипторов файлов и буферов пикселей
def safe_compress(input_path, output_path, quality=75):
with Image.open(input_path) as img:
# 2. Копировать изображение, если нужно его изменить, избегая операций над оригиналом
work = img.copy()
# img освобождён; work всё ещё в области видимости
if work.mode != "RGB":
work = work.convert("RGB")
work.save(output_path, format="JPEG", quality=quality, optimize=True)
work.close() # 3. Закрыть явно
# 4. Периодически запускать сборку мусора во время пакетной обработки
def batch_with_gc(tasks, interval=100):
for i, task in enumerate(tasks):
process(task)
if i % interval == 0:
gc.collect()
Кроме того, Image.MAX_IMAGE_PIXELS по умолчанию имеет предел около 178 миллионов пикселей (примерно 8900x8900). Превышение вызывает DecompressionBombError. Если вам действительно нужно обрабатывать очень большие изображения, вы можете повысить этот порог, но следите за потреблением памяти:
Image.MAX_IMAGE_PIXELS = None # Отключить ограничение (использовать с осторожностью)
Сравнение результатов сжатия
Приведённая ниже таблица показывает измеренные данные из набора 50 фотографий со смартфона (исходное среднее 6,5 МБ, 4000x3000 пикселей). Все тесты выполнялись на одном оборудовании, с равномерным уменьшением разрешения до длинной стороны 1920:
| Коэффициент качества | Формат | Средний размер | Коэффициент | Субъективное качество | Сценарий использования |
|---|---|---|---|---|---|
| Оригинал | JPEG | 6,5 МБ | 1,0x | Базовый уровень | — |
| 95 | JPEG | 1,8 МБ | 3,6x | Практически идентично | Высококачественное архивирование |
| 85 | JPEG | 0,92 МБ | 7,1x | Незаметная разница | Главные изображения сайта |
| 75 | JPEG | 0,58 МБ | 11,2x | Лёгкие артефакты при близком рассмотрении | Миниатюры, страницы списков |
| 65 | JPEG | 0,42 МБ | 15,5x | Видимые блочные артефакты | Не рекомендуется |
| 50 | JPEG | 0,28 МБ | 23,2x | Очевидные искажения | Только крошечные превью |
| 80 | WebP | 0,51 МБ | 12,7x | Сопоставимо с JPEG 85 | Современные веб-сайты |
| Без потерь | WebP | 4,2 МБ | 1,5x | Идеально без потерь | Когда нужна прозрачность |
Из данных видны несколько закономерностей:
- Качество 85 — точка перегиба по соотношению цена/качество: оно экономит почти половину размера по сравнению с 95, с различиями в качестве, практически незаметными для глаза.
- Ниже качества 70 отдача быстро убывает: размер файла продолжает уменьшаться, но деградация качества заметно ускоряется.
- Коэффициенты качества WebP не прямо эквивалентны JPEG: WebP 80 даёт качество, сопоставимое с JPEG 85, но при меньшем размере.
- WebP без потерь имеет смысл только при необходимости прозрачности; для фотографий преимущество в размере минимально.
FAQ
В1: Почему изображения после сжатия отображаются перевёрнутыми или боком?
Это происходит потому, что тег ориентации EXIF смартфона отбрасывается при сжатии, тогда как фактические данные пикселей были сохранены в альбомной ориентации. Решение — вызвать ImageOps.exif_transpose(img) перед сохранением. Он поворачивает данные пикселей в соответствии с тегом ориентации EXIF, чтобы изображение было правильно ориентировано перед сохранением. Таким образом, даже если последующее ПО не читает информацию об ориентации EXIF, изображение будет отображаться корректно.
В2: Почему использование памяти постоянно растёт при обработке 1000 изображений?
Наиболее частая причина — несвоевременное закрытие объектов изображений. Всегда используйте контекстный менеджер with Image.open(...) as img:, чтобы каждое изображение освобождалось сразу после обработки. Во-вторых, в многопоточной версии, если все задачи отправляются сразу, объекты future удерживают ссылки на результаты до их потребления, что приводит к накоплению памяти. Решение — отправлять задачи пакетами с помощью chunksize или переключиться на шаблон очереди «производитель-потребитель» для управления параллелизмом. Наконец, периодический вызов gc.collect() может вернуть память, удерживаемую циклическими ссылками.
В3: Почему прозрачные области становятся чёрными после преобразования PNG в JPEG?
Формат JPEG не поддерживает прозрачность. Когда Pillow по умолчанию преобразует RGBA в RGB, он заполняет прозрачный фон чёрным. Решение — сначала создать RGB-изображение с белым фоном, затем использовать paste() для наложения исходного изображения с альфа-каналом в качестве маски:
background = Image.new("RGB", img.size, (255, 255, 255))
background.paste(img, mask=img.split()[3]) # 4-й канал — альфа
Альтернативно можно преобразовать напрямую в WebP, который изначально поддерживает прозрачность без дополнительной обработки.
В4: Почему многопоточность не даёт линейного ускорения?
Сжатие изображений включает как дисковый ввод-вывод, так и кодирование процессором. Как только количество потоков превышает определённый предел, пропускная способность диска становится узким местом — процессор не может работать быстрее, чем данные могут быть прочитаны и записаны. Кроме того, декодированные данные пикселей изображений высокого разрешения велики, и одновременная обработка нескольких больших изображений в разных потоках приводит к конкуренции за пропускную способность памяти и снижению коэффициента попаданий в кэш. На практике 4-8 потоков обычно дают ускорение в 2-4 раза, с убывающей отдачей сверх этого. Для механических жёстких дисков рекомендуется 4 потока; для NVMe SSD количество можно несколько увеличить.
Итоги
Пакетное сжатие изображений с помощью Python Pillow можно разбить на три уровня:
- Базовый уровень: Освойте три метода
Image.open(),thumbnail()иsave(), в сочетании с параметрамиqualityиoptimize, и вы сможете сжать одно изображение. Это строительный блок для всей пакетной обработки. - Инженерный уровень: Используйте
os.walk()для обхода каталогов,Path.relative_to()для сохранения структуры иconcurrent.futuresдля параллельной обработки, чтобы масштабировать логику одного изображения до тысяч изображений. Этот уровень сосредоточен на корректности, надёжности и пропускной способности. - Уровень оптимизации: Интеллектуально выбирайте форматы и параметры на основе характеристик изображения — прозрачные изображения идут в WebP, большие изображения сначала уменьшаются, маленькие изображения остаются PNG. Такие детали, как сохранение EXIF, коррекция ориентации и управление памятью, определяют, сможет ли скрипт надёжно работать в производственной среде.
Существует эмпирическое правило выбора коэффициента качества: 85 — порог визуально безпотерянного качества, 75 — точка баланса между размером и качеством, а ниже 65 не рекомендуется для целей отображения. WebP теперь полностью поддерживается современными браузерами и должен быть предпочтительным форматом для новых проектов.
Ценность этого скрипта не в замене профессиональных инструментов, а в его настраиваемости. Вы можете настроить каждый этап в соответствии с вашими реальными потребностями — настроить разные параметры для конкретных каталогов, автоматически загружать на CDN после сжатия или интегрировать в конвейер CI/CD. Понимание принципов, лежащих в основе каждой строки кода, даёт вам фундамент для работы с любым сценарием обработки изображений.
Связанные статьи:
- Руководство по сжатию изображений: сравнение и выбор форматов JPG/PNG/WebP
- Why JPEG Quality 75 Is the Magic Number for Image Compression
Нужно сжать файлы? Попробуйте SmartSlim
На базе собственного движка сжатия на Rust поддерживается 10 основных категорий и 40+ форматов, включая PDF/изображения/видео/Office/OFD. Локальная обработка — данные не покидают вашу инфраструктуру.