На Yelp хранится более 100 миллионов пользовательских фотографий, от картинок ужинов и причёсок до одной из наших последних фич, #yelfies. Эти изображения составляют основную часть трафика для пользователей приложения и веб-сайта, а их хранение и передача обходятся недёшево. Стараясь предоставить людям наилучший сервис, мы усиленно работали над оптимизацией всех фотографий и добились среднего уменьшения размера на 30%. Это экономит людям время и трафик, а также сокращает наши расходы на обслуживание этих изображений. Ах да, и мы сделали это без ухудшения качества фотографий!
Исходные данные
Yelp хранит пользовательские фотографии уже 12 лет. Мы сохраняем lossless-форматы (PNG, GIF) как PNG, а все остальные форматы в JPEG. Для сохранения файлов используются Python и Pillow, а загрузки фотографий начинаются примерно с такого сниппета:
# do a typical thumbnail, preserving aspect ratio
new_photo = photo.copy()
new_photo.thumbnail(
(width, height),
resample=PIL.Image.ANTIALIAS,
)
thumbfile = cStringIO.StringIO()
save_args = {'format': format}
if format == 'JPEG':
save_args['quality'] = 85
new_photo.save(thumbfile, **save_args)
После этого мы начинаем искать варианты для оптимизации размера файла без потери качества.
Оптимизации
Во-первых, нужно решить, обрабатывать файлы самим или позволить CDN-провайдеру магическим образом изменить наши фотографии. Поскольку мы ставим приоритетом высокое качество контента, то имеет смысл самим оценить варианты и потенциальные компромиссы между размером и качеством. Мы приступили к исследованию текущего положения дел с оптимизацией размера файлов — какие изменения могут быть сделаны и как поменяется размер/качество с каждым из них. По окончании исследования мы решили работать по трём основным направлениям. Остальная часть статьи посвящена рассказу о том, что мы сделали и какую выгоду извлекли из каждой оптимизации.
- Изменения в Pillow
- Флаг Optimize
- Progressive JPEG
- Изменения в логике фотоприложения
- Распознавание больших PNG
- Динамическое качество JPEG
- Изменения в энкодере JPEG
- Mozjpeg (треллис-квантование, кастомная матрица квантования)
Изменения в Pillow
Флаг Optimize
Это одно из самых простых изменений, которые мы сделали: передать Pillow ответственность за дополнительную экономию размера файла за счёт времени CPU (optimize=True
). По определению, это никак не отразится на качестве фотографий.
Для JPEG этот флаг означает указание энкодеру найти оптимальный код Хаффмана, сделав дополнительный проход при сканировании каждого изображения. Каждый первый проход, вместо записи в файл, вычисляет статистику вхождений по каждому значению, эта информация нужна для идеального кодирования. В стандарте PNG используется zlib, так что флаг оптимизации в данном случае указывает энкодеру использовать gzip -9
вместо gzip -6
.
Такое изменение было просто сделать, но выяснилось, что оно не является идеальным решением, сокращая размер файлов всего на несколько процентов.
Progressive JPEG
При сохранении JPEG можно выбрать несколько различных типов:
- Baseline JPEG, которые загружаются сверху вниз
- Progressive JPEG, которые загружаются от размытых к чётким. Опцию прогрессирующих изображений легко активировать в Pillow (
progressive=True
). В результате, качество субъективно повышается (так и есть, легче заметить частичное отсутствие изображения, чем его неидеальную резкость)
Вдобавок, метод упаковки прогрессирующих изображений таков, что обычно это приводит к меньшему размеру файла. Как более полно объясняется в статье Википедии, в формате JPEG применяется зигзагообразная проходка по блоку 8×8 пикселей для энтропийного кодирования. Когда значения этих блоков пикселей не упакованы и расположены по порядку, то обычно сначала идут ненулевые значения, а затем последовательности нулей, и такой паттерн повторяется и чередуется для каждого блока 8×8 на изображении. С прогрессивным кодированием изменяется порядок обработки пиксельных блоков. Первыми в файле идут большие значения для каждого блока (что даёт первым сканам прогрессирующего изображения такую характерную блочность), а ближе к концу хранятся длинные диапазоны малых значений, включая больше нулей, эти диапазоны обеспечивают тонкую детализацию. Такое перераспределение данных в файле не меняет само изображение, но увеличивает количество нулей в ряду друг за другом (которые легче сжать).
Пример, как работает рендеринг Baseline JPEG
Пример, как работает рендеринг Progressive JPEG
Изменения в логике фотоприложения
Распознавание больших PNG
Yelp работает с двумя форматами для пользовательского контента — JPEG и PNG. JPEG отлично подходит для фотографий, но обычно не справляется с высококонтрастным дизайнерским контентом (таким как логотипы). В отличие от него, PNG сжимает изображение абсолютно без потерь, отлично подходит для графики, но слишком громоздок для фотографий, где маленькие искажения всё равно не заметны. В тех случаях, когда пользователи загружают фотографии в формате PNG, мы можем сэкономить много места, если распознаем такие файлы и сохраним их в JPEG. Один из основных источников фотографий PNG на Yelp — это скриншоты с мобильных устройств и приложений, которые изменяют фотографии, накладывая эффекты и добавляя рамки.
Слева: типичный скомбинированный PNG с логотипом и рамкой. Справа: типичный PNG, полученный со скриншота
Мы хотели уменьшить количество таких необязательных PNG, но было важно не переусердствовать, изменяя форматы или ухудшая качество логотипов, графики и т. д. Как мы можем определить, что изображение является фотографией? По пикселям?
Проведя проверку на экспериментальной выборке из 2500 изображений, мы выяснили, что сочетание размера файла и количества уникальных пикселей позволяет довольно точно определить фотографии. Мы генерируем уменьшенную копию на максимальном разрешении и проверяем, если размер файла больше 300 КиБ. Если так, то проверяем пиксели изображения, есть ли там больше 216 уникальных цветов (Yelp конвертирует загруженные изображения RGBA в RGB, но если бы мы этого не делали, то всё равно проверяли бы это).
На экспериментальной выборке такие ручные настройки по определению «больших картинок» выявляет 88% всех файлов, которые потенциально подходят для оптимизации без ложных срабатываний на графику.
Динамическое качество JPEG
Первый и самый известный способ уменьшить размер файлов JPEG — настройка под названием quality
. Многие приложения, способные сохранять в формате JPEG, определяют quality
в виде числа.
Качество — это некая абстракция. На самом деле, существуют отдельные уровни качества для каждого из цветовых каналов изображения JPEG. Уровни качества от 0 до 100 соответствуют различным таблицам квантования для цветовых каналов и определяют, сколько данных будет потеряно (обычно в высоких частотах). Квантование сигнала — это один из шагов в процессе кодирования JPEG, когда теряется информация.
Простейший способ уменьшить размер файла — это ухудшить качество изображения, допустив больше шума. Впрочем, не каждое изображение теряет одинаковое количество информации при одном и том же уровне качества.
Мы можем динамически изменять настройки качества, оптимизируя их для каждого отдельного изображения, чтобы достичь идеального баланса между качеством и размером. Есть два способа сделать это:
- Снизу вверх (Bottom-up): Эти алгоритмы генерируют настроенные таблицы квантования, обрабатывая изображение на уровне блоков 8×8 пикселей. Они одновременно рассчитывают, сколько теоретического качества было потеряно и как эти потерянные данные усиливают или сокращают видимость искажений для человеческого глаза.
- Сверху вниз (Top-down): Эти алгоритмы сравнивают целое изображение с его оригинальной версией и определяют, сколько информации было потеряно. Последовательно генерируя кандидатов с различными настройками качества, мы можем выбрать того, который соответствует минимальному уровню оценки, смотря какой алгоритм оценки мы используем.
Мы оценили работу алгоритма bottom-up и пришли к выводу, что он не обеспечивает должных результатов на высших настройках качества, которые мы хотели использовать (хотя кажется, что у него есть потенциал в среднем диапазоне качества, где энкодер может быть более смелым относительно выбора отбрасываемых байтов). Многие научные работы по этой стратегии были опубликованы в начале 90-х, когда вычислительные ресурсы были в дефиците, так что было сложно использовать ресурсоёмкие методы, которые использует вариант Б, такие как оценка взаимосвязей между блоками.
Так что мы обратились ко второму подходу: использование делённого пополам алгоритма для генерации изображений-кандидатов на разных уровнях качества и оценка падения качества каждого изображения путём вычисления его индекса структурного сходства (SSIM) с помощью pyssim до тех пор, пока это значение находится в пределах настраиваемого, но статичного порога. Это позволило нам выборочно понизить средний размер файла (и среднее качество) только для изображений, которые были выше воспринимаемого порога.
На диаграмме внизу мы отобразили значения SSIM для 2500 изображений, заново сгенерированных с тремя разными настройками качества.
- Оригинальные изображения, созданные с помощью текущего метода при
quality = 85
, показаны синим цветом. - Альтернативный подход для снижения размера файлов, со снижением настройки качества до
quality = 80
, показан красным цветом. - И наконец подход, на котором мы в итоге остановились, динамическое качество
SSIM 80-85
, показан оранжевым цветом. Здесь качество выбирается из диапазона от 80 до 85 (включительно), в зависимости от совпадения или превышения соотношения SSIM: предварительно вычисляемого статической величины, которая совершает этот переход где-то посредине диапазона изображений. Это позволяет нам снизить средний размер файла без понижения качества плохо выглядящих изображений.
Индексы SSIM для 2500 изображений с тремя разными стратегиями изменения настроек качества
SSIM?
Существует несколько алгоритмов изменения качества изображений, которые пытаются имитировать человеческую систему зрения. Мы оценили многие из них и думаем, что SSIM, хотя и более старый, но лучше всех подходит для такой итеративной оптимизации благодаря своим характеристикам:
- Чувствителен к ошибке квантования JPEG
- Быстрый, простой алгоритм
- Может быть рассчитан на нативных объектах PIL без конвертации изображений в PNG и передачи их в приложения CLI (см. #2)
Пример кода для динамического качества:
import cStringIO
import PIL.Image
from ssim import compute_ssim
def get_ssim_at_quality(photo, quality):
"""Return the ssim for this JPEG image saved at the specified quality"""
ssim_photo = cStringIO.StringIO()
# optimize is omitted here as it doesn't affect
# quality but requires additional memory and cpu
photo.save(ssim_photo, format="JPEG", quality=quality, progressive=True)
ssim_photo.seek(0)
ssim_score = compute_ssim(photo, PIL.Image.open(ssim_photo))
return ssim_score
def _ssim_iteration_count(lo, hi):
"""Return the depth of the binary search tree for this range"""
if lo >= hi:
return 0
else:
return int(log(hi - lo, 2)) + 1
def jpeg_dynamic_quality(original_photo):
"""Return an integer representing the quality that this JPEG image should be
saved at to attain the quality threshold specified for this photo class.
Args:
original_photo - a prepared PIL JPEG image (only JPEG is supported)
"""
ssim_goal = 0.95
hi = 85
lo = 80
# working on a smaller size image doesn't give worse results but is faster
# changing this value requires updating the calculated thresholds
photo = original_photo.resize((400, 400))
if not _should_use_dynamic_quality():
default_ssim = get_ssim_at_quality(photo, hi)
return hi, default_ssim
# 95 is the highest useful value for JPEG. Higher values cause different behavior
# Used to establish the image's intrinsic ssim without encoder artifacts
normalized_ssim = get_ssim_at_quality(photo, 95)
selected_quality = selected_ssim = None
# loop bisection. ssim function increases monotonically so this will converge
for i in xrange(_ssim_iteration_count(lo, hi)):
curr_quality = (lo + hi) // 2
curr_ssim = get_ssim_at_quality(photo, curr_quality)
ssim_ratio = curr_ssim / normalized_ssim
if ssim_ratio >= ssim_goal:
# continue to check whether a lower quality level also exceeds the goal
selected_quality = curr_quality
selected_ssim = curr_ssim
hi = curr_quality
else:
lo = curr_quality
if selected_quality:
return selected_quality, selected_ssim
else:
default_ssim = get_ssim_at_quality(photo, hi)
return hi, default_ssim
Есть несколько других статей в блогах об этой технике, здесь одна от Кольта Маканлиса. И когда мы собирались публиковаться, Etsy тоже опубликовала свою! Дай пять, быстрый интернет!
Изменения в энкодере JPEG
Mozjpeg
Mozjpeg — это open-source форк libjpeg-turbo, который пожертвовал временем выполнения ради размера файлов. Такой подход хорошо совместим с офлайновыи конвейером по регенерации файлов. С потреблением ресурсов в 3-5 раз больше, чем libjpeg-turbo, этот алгоритм делает изображения меньше по размеру!
Одно из отличий mozjpeg в том, что он использует альтернативную таблицу квантования. Как упоминалось выше, качество — это абстракция таблиц квантования для каждого цветового канала. Всё указывает на то, что дефолтные таблицы квантования JPEG довольно легко превзойти. Как говорится в спецификациях JPEG:
Эти таблицы приводятся только как примеры и необязательно подходят для какого-то конкретного приложения.
Так что естественно, вас не должно удивлять, что эти таблицы используются по умолчанию в большинстве реализаций энкодеров…
Mozjpeg сделал за нас трудную работу сравнительного тестирования альтернативных таблиц и при генерации изображений использует альтернативные таблицы, которые проявляют себя лучше всего.
Mozjpeg + Pillow
В большинстве дистрибутивов Linux по умолчанию установлен libjpeg. Так что mozjpeg под Pillow не работает по умолчанию, но это не слишком сложно настроить в конфигурации. При сборке mozjpeg используйте флаг --with-jpeg8
и убедитесь, что он может быть залинкован с Pillow. Если вы используете Docker, то можно сделать такой Dockerfile:
FROM ubuntu:xenial
RUN apt-get update
&& DEBIAN_FRONTEND=noninteractive apt-get -y --no-install-recommends install
# build tools
nasm
build-essential
autoconf
automake
libtool
pkg-config
# python tools
python
python-dev
python-pip
python-setuptools
# cleanup
&& apt-get clean
&& rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Download and compile mozjpeg
ADD https://github.com/mozilla/mozjpeg/archive/v3.2-pre.tar.gz /mozjpeg-src/v3.2-pre.tar.gz
RUN tar -xzf /mozjpeg-src/v3.2-pre.tar.gz -C /mozjpeg-src/
WORKDIR /mozjpeg-src/mozjpeg-3.2-pre
RUN autoreconf -fiv
&& ./configure --with-jpeg8
&& make install prefix=/usr libdir=/usr/lib64
RUN echo "/usr/lib64n" > /etc/ld.so.conf.d/mozjpeg.conf
RUN ldconfig
# Build Pillow
RUN pip install virtualenv
&& virtualenv /virtualenv_run
&& /virtualenv_run/bin/pip install --upgrade pip
&& /virtualenv_run/bin/pip install --no-binary=:all: Pillow==4.0.0
Это всё! Собирайте и сможете использовать Pillow с mozjpeg в нормальном процессе обработки изображений.
Эффект
Насколько каждое из этих улучшений было важным для нас? Мы начали со случайной выборки из 2500 бизнес-фотографий Yelp, пропустили их через наш конвейер обработки и измерили изменение размера.
- Изменения в настройках Pillow дали экономию 4,5%
- Определение больших PNG дало экономию 6,2%
- Динамическое качество дало экономию 4,5%
- Переход на энкодер mozjpeg дал экономию 13,8%
Всё вместе это привело к сокращению среднего размера изображений примерно на 30%, что мы использовали для наших самых больших и самых распространённых разрешений фотографий, сделав сайт быстрее для пользователей и сэкономив на передаче данных терабайты в день. Как зафиксировано на уровне CDN:
Изменение среднего размера файла со временем, у CDN (вместе с другими файлами, которые не являются изображениями)
Чего мы не делали
Этот раздел посвящён описанию нескольких других типичных оптимизаций, которые вы можете использовать, но они не подходили для Yelp либо по причине дефолтных настроек наших инструментов, либо по причине сознательного отказа идти на такой компромисс.
Субдискретизация
Субдискретизация — основной фактор в определении и качества, и размера файлов веб-изображений. В интернете можно найти более подробное описание субдискретизации, но для этой статьи достаточно сказать, что мы уже выполняем субдискретизацию до 4:1:1
(это настройки по умолчанию Pillow, если не указать другие настройки), так что мы вряд ли получим какой-то выигрыш при дальнейшей оптимизации.
Кодирование PNG с потерями
Зная то, что мы делаем с PNG, вариант с сохранением этих изображений в прежнем формате, но используя энкодер с потерями вроде pngmini, имеет смысл, но мы всё равно выбрали вариант сжатия в JPEG. Тем не менее, автор энкодера говорит о сжатии файлов на 72-85%, так что это альтернативный вариант с обоснованными результатами.
Более современные форматы
Поддержка более современных форматов вроде WebP или JPEG2k определённо рассматривалась нами. Но даже если бы мы реализовали этот гипотетический проект, всё равно остался бы длинный хвост пользователей, которым нужны изображения JPEG/PNG, так что усилия по их оптимизации в любом случае были не напрасными.
SVG
Мы применяем SVG во многих местах на сайте, например, для статических изображений, которые создали наши дизайнеры к руководству по стилю. Хотя этот формат и инструменты оптимизации вроде svgo хорошо сокращают размер страницы, для нашей задачи они не подходят.
Магия вендора
Существует слишком много компаний, которые предлагают доставку, изменение размера, кадрирование, транскодирование изображений как сервис. В том числе open-source thumbor. Может быть, для нас в будущем это самый простой способ реализовать поддержку отзывчивых изображений, динамических типов контента и остаться на острие прогресса. Но сейчас мы справляемся своими силами.
Дополнительная литература
Две упомянутые здесь книги абсолютно самодостаточны за пределами контекста этой статьи и крайне рекомендуются для дальнейшего чтения по предмету.
Автор: m1rko