После того как я написал статью про то, что ваш монитор не умеет показывать бирюзовый и 65% видимых цветов для него просто не существуют, один мой знакомый (далекий правда от технической отрасли) спросил: «Окей, монитор врёт, а что тогда делает JPEG с оставшимися 35%?» И это хороший вопрос. Я полез в спеку, а через полчаса забыл, зачем вообще полез. Потому меня уже интересовало другое: ребята, которые в 1992-м финализировали этот стандарт, по сути заревёрсили человеческое зрение и запихнули его в алгоритм сжатия.

И не метафорически. В прошлой статье я рассказал (и показал), как монитор эксплуатирует метамерию — три огонька вместо полного спектра, и мозг не замечает подмены. Так вот, JPEG идёт дальше. Здесь каждый этап пайплайна — это конкретный хак, эксплуатирующий конкретный баг в вашей зрительной системе. Монитор обманывает колбочки, а JPEG обманывает всё остальное, от контрастной чувствительности до того, как кора мозга собирает картинку из частот.

И я хочу вам про это рассказать, потому что это самый красивый кусок инженерии, который я видел за 12 лет в индустрии. Там я разбирал, как мало мы на самом деле видим. Здесь — как мало нам на самом деле нужно видеть, чтобы мозг поверил, что видит всё. А потом я решил это проверить руками.


Ваш глаз — не камера (спасибо кэп)

Мы привыкли думать о глазах как о сенсорах. Свет попал на сетчатку, сетчатка отправила данные в мозг, мозг «увидел». Этакий биологический аналог матрицы камеры. Ну, примерно.

Нет.

Ваш глаз — это очень предвзятый, очень странный сенсор с кучей аппаратных костылей. Сетчатка человека содержит два типа фоторецепторов: палочки и колбочки. Палочек около 120 миллионов, и они отвечают за яркость. Колбочек 6-7 миллионов, и они видят цвет.

Еще раз, соотношение примерно 20:1. Ваша сетчатка — это, грубо говоря, высокочёткий чёрно-белый сенсор, к которому скотчем примотали дешевую цветную вебку из 2005-го.

И создатели JPEG это знали.


YCbCr, или Зачем JPEG первым делом выбрасывает RGB

Когда вы сохраняете картинку в JPEG, первое, что происходит — не сжатие, ( и даже не оптимизация) а смена языка на котором описан цвет.

Картинка приходит в RGB. Так работает монитор, так снимает камера, но JPEG тут же переводит всё в другую систему — YCbCr:

  • Y — яркость. Насколько светлый пиксель.

  • Cb — насколько он синий (точнее, разница между синим и яркостью).

  • Cr — насколько он красный (аналогично).

Зачем? Кажется, что это абсолютно лишний шаг. RGB уже есть, все его понимают, зачем конвертировать туда-сюда?

А вот зачем. В RGB все три канала одинаково важны. Убери красный — картинка развалится. Убери зелёный — тоже. Все три равноправны, ни один нельзя тронуть.

В YCbCr — другая ситуация. Канал Y, яркость — это то, что ваш глаз видит отлично. За яркость отвечают 120 миллионов палочек в сетчатке. Они различают мельчайшие градации — тени, контуры, текстуры. А Cb и Cr, цветность — это всего 6 миллионов колбочек. В двадцать раз меньше. Цвет ваш глаз видит грубо.

Перевод в YCbCr — это по сути разделение картинки на «то, что глаз заметит» и «то, что можно втихаря порезать».

Формула, кстати, красноречива:

Y = 0.299 × R + 0.587 × G + 0.114 × B

Посмотрите на коэффициенты. Зелёный — 0.587, больше половины. Красный — 0.299. Синий — жалкие 0.114. Это буквально модель вашей сетчатки. Пик чувствительности человеческого глаза — около 555 нм, жёлто-зелёная область. Эволюция затачивала зрение приматов под «найди спелый фрукт среди зелёных листьев», и формула 1992 года это копирует.

Если вы когда-нибудь конвертировали фотографию в чёрно-белую и удивлялись, почему 0.33R + 0.33G + 0.33B выглядит плоско, а с правильными коэффициентами объёмно и контрастно — вот это оно самое. «Правильные коэффициенты» — это Y-канал JPEG.


Chroma subsampling — главный чит-код

После конвертации в YCbCr стандарт JPEG позволяет хранить цветовые каналы (Cb и Cr) с пониженным разрешением. Это называется chroma subsampling, и самый распространённый режим — 4:2:0.

Что это значит на практике? Y-канал хранится в полном разрешении. А Cb и Cr в разрешении 2×2, то есть один цветовой пиксель на четыре яркостных.

То есть, вы выбрасываете 75% цветовой информации и этого почти никто не замечает. (почему?) Потому что ваша сетчатка тоже так делает. Колбочки сконцентрированы в центральной ямке, а на периферии их почти нет. Пространственное разрешение вашего цветового зрения примерно вчетверо ниже яркостного.

То есть JPEG не «сжимает цвет», он отбрасывает ровно то, что ваш глаз и так не обрабатывает.

Хотите почувствовать это на себе? Откройте любое фото в Photoshop, разделите на каналы в YCbCr, и посмотрите на Cb/Cr отдельно ,и вы увидите мутное, расплывчатое нечто, и ваш мозг будет настаивать, что «ну не может так быть, фотка же была чёткая». А вы на самом деле просто никогда раньше не смотрели на цвет отдельно от яркости, потому что ваша зрительная система их мержит автоматически. (кто не хочет копаться в Photoshop ниже сделал это за вас, и для вас)


DCT, или Как эксплуатировать нейроны зрительной коры

Окей, мы порезали цвет. Но основное сжатие в JPEG — это же дискретное косинусное преобразование, квантование и энтропийное кодирование. Причём тут сетчатка?

А вот причём — DCT разбивает изображение на блоки 8×8 пикселей и раскладывает каждый блок на 64 частотных компоненты, от низкой (плавные градиенты) до высокой (резкие переходы, детали и шум).

Вот так выглядит базис DCT для блока 8×8 — 64 «кирпичика», из комбинации которых складывается любой блок:

И знаете что? Нейроны первичной зрительной коры (V1) работают поразительно похоже. Ещё в 60-х Хьюбел и Визел (Нобелевка 1981 года, между прочим) обнаружили, что нейроны V1 реагируют на ориентированные полоски разной частоты. По сути на пространственные частоты. Ваша зрительная кора делает что-то вроде преобразования Фурье над входящим сигналом.

Выходит, что DCT — это аппроксимация того, как ваш мозг фактически декомпозирует изображение.


Квантование — таблица ваших слепых пятен

После DCT у нас есть 64 коэффициента для каждого блока 8×8. Чтобы сжать, нужно часть из них обнулить или огрубить. Для этого каждый коэффициент делится на число из таблицы квантования, и результат округляется.

Стандартная таблица квантования для яркостного канала:

Читается она так: левый верхний угол — низкие частоты (маленькие числа, слабое квантование, сохраняем точность). Правый нижний — высокие частоты (большие числа, грубое квантование, почти выбрасываем).

Эта таблица — карта контрастной чувствительности вашей зрительной системы.

CSF описывает, насколько хорошо человек различает контрастные паттерны на разных пространственных частотах. Пик где-то на 3-5 циклах на градус угла зрения. Ниже — хуже (слишком плавные переходы). Выше — тоже хуже (слишком мелкие детали). А за пределами ~60 циклов на градус вы вообще ничего не видите — это предел пространственного разрешения глаза.

Числа в таблице квантования подобраны экспериментально — на живых людях, с психовизуальными тестами, так, чтобы потери от округления попадали ровно туда, где ваш глаз не отличит оригинал от сжатой версии.

Когда вы двигаете ползунок «качество» в Photoshop с 12 на 8, то вы увеличиваете коэффициенты в этой таблице, всё глубже забираясь в зону того, что ваш глаз ещё может заметить. Quality 100 — почти без потерь, а quality 10 — алгоритм предполагает, что ваш глаз ещё более слеп, чем на самом деле.


«А докажи»

Ну на словах интересно, а можно ли это увидеть? Что именно JPEG выбрасывает, и правда ли я этого не замечаю?

Я написал скрипт на Python. Скрипт несложный, и идея простая: берём исходное PNG-изображение, сохраняем в JPEG с разным quality, потом вычитаем одно из другого — и смотрим на разницу. На то, что алгоритм решил выбросить.

from PIL import Image
import numpy as np

original = np.array(Image.open("photo.png")).astype(np.float32)

for q in [95, 80, 50, 20, 5]:
    Image.open("photo.png").save(f"q{q}.jpg", quality=q)
    compressed = np.array(Image.open(f"q{q}.jpg")).astype(np.float32)
    
    diff = original - compressed
    # Нормализуем для визуализации (сдвиг + масштаб)
    diff_visual = ((diff - diff.min()) / (diff.max() - diff.min()) * 255)
    Image.fromarray(diff_visual.astype(np.uint8)).save(f"diff_q{q}.png")

Признаюсь, ожидал увидеть что-то скучное. Равномерный шум, может быть. Ну, блоки 8×8 — это я знал заранее. (позже добавил еще контраста, чтобы разница была более заметной)


Призрак в разнице

На quality 95 карта различий практически чёрная. Еле видно, и только после того как я выкрутил контраст.

На quality 80 — стандарт для веба — уже видно что-то. Но не шум. Карта различий показывает контуры, края объектов, и тонкие линии. Текстуры с высокой частотой — волосы и ткань. Именно то, что DCT кладёт в высокочастотные коэффициенты, которые квантование обнуляет первыми.

На quality 50 — карта различий выглядит прям как художественная гравюра. Все контуры исходного изображения и вся мелкая текстура.

А вот на quality 5 стало интереснее.

На пятёрке карта различий вы буквально видите оригинальную фотографию в том, что алгоритм отбросил. Потому что при quality 5 квантование настолько агрессивное, что выбрасывает и средние частоты тоже — те, к которым ваш глаз чувствителен. Именно поэтому quality 5 выглядит ужасно, алгоритм залез за границу, за которую ему лезть не стоило.

Вдумайтесь: граница между «нормальная фотка» и «глаза вытекают» — это граница вашей контрастной чувствительности. И она проходит где-то между quality 40 и quality 20 для большинства изображений.


Quality 80: а почему, собственно, 80?

После экспериментов мне стало интересно ещё кое-что. Каждый веб-разработчик «знает», что quality 80 — золотой стандарт для JPEG на вебе.

Почему?

Я построил график. По оси X — quality от 5 до 100. По оси Y — размер файла и SSIM (structural similarity index, метрика, которая пытается оценить видимое сходство).

from PIL import Image
import numpy as np
from skimage.metrics import structural_similarity as ssim
import io

original = np.array(Image.open("photo.png"))
results = []

for q in range(5, 101):
    buf = io.BytesIO()
    Image.open("photo.png").save(buf, format="JPEG", quality=q)
    size_kb = buf.tell() / 1024
    
    buf.seek(0)
    compressed = np.array(Image.open(buf))
    score = ssim(original, compressed, channel_axis=2)
    results.append((q, size_kb, score))

Размер файла растёт примерно экспоненциально при приближении к 100. Между quality 80 и quality 95 размер файла может отличаться вдвое — при разнице в SSIM на третьем знаке после запятой.

Quality 80 — это точка перегиба. Место, где кривая «выигрыш в качестве / проигрыш в размере» резко меняет наклон. И это место (сюрприз) определяется всё той же CSF. До quality ~80 квантование режет только те частоты, к которым ваш глаз слабо чувствителен. После 80 начинает жертвовать частотами, которые вы видите.

Эмпирическое правило «ставь 80» не привычка. Это, по сути, упрощённая формулировка для «отрежь ровно то, что не видит человеческий глаз, и ни битом больше». Просто никто не объясняет это именно так.


Почему мне не даёт покоя эта история

Я каждый день пишу <img src="photo.jpg"> и не задумываюсь, что за этим стоит. Мы деплоим картинки, оптимизируем через ImageMagick или Sharp, ругаемся на размеры бандла — и воспринимаем JPEG как данность. Как TCP/IP, и как UTF-8. Работает и работает.

Но за этим «работает» — тридцать лет исследований в психофизике зрения, скрупулёзные эксперименты с порогами восприятия и очень элегантная инженерия, которая не пытается сжать изображение «вообще». Она сжимает его конкретно для вас, для вашего биологического визуального пайплайна с его конкретными ограничениями.

JPEG — алгоритм сжатия восприятия.

Если задуматься, WebP и AVIF делают ровно то же самое, просто с более тонкими моделями. У AVIF есть film grain synthesis — он не ��ранит шум, а генерирует его при декодировании, потому что ваш мозг не запоминает конкретное расположение зерна, а только его «характер». Опять хак поверх биологии.


А что дальше?

Знаете, что самое забавное? Эти таблицы квантования в спецификации — примерные. Annex K явно говорит: «These are not default quantization tables. These are provided for illustration purposes only.» Каждый энкодер волен генерировать свои.

И энкодеры вроде MozJPEG тратят кучу CPU-времени, чтобы подобрать оптимальные таблицы под конкретное изображение, используя ту же психовизуальную модель, только точнее. По сути, они строят более детальную карту того, что вы не увидите именно в этой фотке.

А JPEG XL (стандарт 2022 года, который Chrome поддержал и потом выпилил — отдельная грустная история) пошёл ещё дальше. Там психовизуальная модель встроена прямо в ядро кодека. Там не таблица 8×8, а полноценная перцептивная метрика Butteraugli, которая моделирует ваше зрение включая маскировку, адаптацию к яркости и чувствительность к направлению контуров. По сути — кодек, который симулирует ваш зрительный тракт для каждого блока и спрашивает: «увидит или не увидит?»

Тридцать лет спустя мы всё ещё оптимизируем тот же трюк: найти границу между «видит» и «не видит», и пройти по ней как можно ближе. Просто граница стала тоньше, а модели точнее.

Каждый раз, когда вы открываете JPEG, ваш компьютер по сути говорит: «Я знаю, как устроены твои глаза, и я этим воспользуюсь».

А вы когда-нибудь задумывались, почему JPEG-артефакты выглядят именно так — этими характерными блоками 8×8? Теперь вы знаете. Это сетка, по которой алгоритм резал вашу реальность на кусочки, оптимизированные под биологию ваших глаз. Просто обычно он попадает так точно, что вы не замечаете швов.


А вы знали про эту историю? Или, как и я, просто писали quality: 80 и не задумывались почему именно 80?


UPD: Примечание для биологов: да, я знаю, что при дневном свете палочки «слепнут» и яркость тоже считывается колбочками, а разница в резкости обусловлена нейронным пулингом. Но для понимания логики JPEG метафора с ч/б сенсором работает слишком хорошо, чтобы от неё отказываться.