happyin.work / блог

English · Русский · Español · Deutsch · Português (Brasil) · 简体中文 · 日本語

3D LUT: запекание цветового грейда в .cube и Hald и что теряется между узлами

Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-09-23

Перевод английской версии: English

Каждый инструмент из предыдущих частей этой серии, от экспозиции до channel mixer, вычисляет новый цвет только по старому цвету и ничему больше. Такую функцию можно сохранить как таблицу: взять значения на сетке входных RGB, сохранить выходы и интерполировать между ними. Эта таблица и есть 3D LUT, и именно так грейд покидает мою колор-студию и попадает в другую программу. Эта часть описывает два формата, интерполяцию, размер таблицы, то, что экспорт отказывается переносить, и что я измерила, когда проверила настоящее запекание по движку.

Что может нести LUT

3D LUT — это дискретизированная функция T: [0,1]³ → [0,1]³. Она отвечает одинаково на один и тот же RGB, где бы ни находился пиксель: на лице, на стене или в другой фотографии. Поэтому она может хранить экспозицию, баланс белого, кривые, Levels, диапазонные правки Oklab, блокировки оттенков, защищённые цвета и микшер. Она не может хранить ничего, что смотрит на позицию пикселя или его соседей: Texture, Clarity, Sharpen, Dehaze, маски по координатам.

Движок не полагается на то, что интерфейс это запомнит. Обработчик экспорта в main.cpp отказывает сам:

if (type == "cube" || type == "hald" || type == "verify") {
  if (atelier::hasSpatialEdits(spatial)) fail("spatial edits cannot be exported as a 3D LUT");

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

Файл .cube, читаемый строго

Формат — Cube LUT Specification 1.0 от Adobe, 2013 года (архивная копия PDF Adobe): текстовый файл с TITLE, LUT_3D_SIZE, необязательными DOMAIN_MIN и DOMAIN_MAX, затем N³ строк по три числа, где красный меняется быстрее всего, так что индекс строки равен r + N·g + N²·b. Спецификация допускает N от 2 до 256. Мой парсер принимает от 2 до 65, только трёхмерные таблицы. Он отказывается от одномерных и шейпер-файлов, неизвестных директив, неконечных чисел и неверного числа строк, и сообщает о проблеме по имени. Вход он считает кодированным sRGB, а не угадывает профиль Log или камеры по имени файла.

Пять независимых read-only обзоров 22 сентября нашли три ошибки парсера; первая проявилась, когда студия прочитала собственные файлы обратно:

  • # внутри заключённого в кавычки TITLE воспринималось как комментарий и отрезалось, поэтому студия не могла переимпортировать часть собственных экспортов.
  • JavaScript- и C++-парсеры расходились в 9 из 50 файлов тестового корпуса. Теперь у них одна грамматика, записанная на обоих языках с одним и тем же регулярным определением числа.
  • Модуль записи передавал не-ASCII байты в TITLE. (and «Теперь модуль записи сохраняет их») Спецификация допускает только символы от пробела до тильды, без кавычки. Теперь писатель сохраняет их, а всё остальное заменяет пробелом.

Hald: LUT, замаскированный под картинку (and «семплированный LUT 65³», «калибровочный LUT монитора», «извлечённого LUT»)

Hald CLUT хранит тот же куб как изображение. На уровне L куб имеет N = L² значений на ось, а изображение — L³ пикселей по стороне; если читать его как одну длинную строку пикселей, красный меняется быстрее всего, затем зелёный, затем синий. Раздел Hald в документации ImageMagick — первичное описание, которому я следовала: уровень 8 — это изображение 512 × 512, содержащее 64³ цвета.

Увеличенное тождественное Hald изображение уровня 3, 27 на 27 пикселей, с пометкой: красный меняется быстрее всего, зелёный — каждый 9-й пиксель, синий — каждый 81-й

Рабочий процесс простой: сохраняете нейтральный Hald, грейдите его в любом редакторе только цветовыми операциями, сохраняете размер и открываете снова как LUT. Студия также записывает собственный текущий грейд как Hald. Формат не может доказать, что это Hald, поэтому импортёр проверяет только форму: квадратный PNG, сторона которого — точный куб, уровень от 2 до 8, полностью непрозрачный. Любая квадратная фотография 512 × 512 пройдёт эту проверку и превратится в бессмыслицу, поэтому документация прямо говорит не импортировать обычные фото.

В пути изображения было два дефекта, не в математике. Импорт Hald шёл через Canvas браузера, а Canvas конвертирует цвет у PNG, чьи чанки gAMA, cHRM или iCCP описывают что-то, отличное от sRGB. Помеченный Hald возвращался со сдвигом до 136 кодов, ещё до того как прочитана хоть одна табличная величина. Теперь импорт декодирует PNG сам и берёт 8- или 16-битные отсчёты как числа. Экспорт был только 8-битным; теперь он 16-битный. Разница видна ещё до грейдинга: 64 уровня не ложатся на целые 8-битные коды, поэтому тождественный Hald в 8 битах уже ошибается в узлах до 0.0019 в единицах 0..1. В 16 битах та же ошибка равна 7.3 × 10⁻⁶.

Трилинейная или тетраэдрическая

Между узлами движок интерполирует одним из двух способов. Трилинейная смешивает все восемь углов ячейки. Тетраэдрическая сортирует три дробные координаты, выбирает один из шести тетраэдров внутри ячейки и смешивает четыре угла. Из cube.cpp:

if (fr >= fg && fg >= fb) { p0=c000; p1=c100; p2=c110; p3=c111; t1=fr; t2=fg; t3=fb; }
else if (fr >= fg && fr >= fb) { p0=c000; p1=c100; p2=c101; p3=c111; t1=fr; t2=fb; t3=fg; }
else if (fr >= fg) { p0=c000; p1=c001; p2=c101; p3=c111; t1=fb; t2=fr; t3=fg; }
else if (fr >= fb) { p0=c000; p1=c010; p2=c110; p3=c111; t1=fg; t2=fr; t3=fb; }
else if (fg >= fb) { p0=c000; p1=c010; p2=c011; p3=c111; t1=fg; t2=fb; t3=fr; }
else { p0=c000; p1=c001; p2=c011; p3=c111; t1=fb; t2=fg; t3=fr; }
return {p0[0] + (p1[0] - p0[0]) * t1 + (p2[0] - p1[0]) * t2 + (p3[0] - p2[0]) * t3,
        p0[1] + (p1[1] - p0[1]) * t1 + (p2[1] - p1[1]) * t2 + (p3[1] - p2[1]) * t3,
        p0[2] + (p1[2] - p0[2]) * t1 + (p2[2] - p1[2]) * t2 + (p3[2] - p2[2]) * t3};

Оба точно воспроизводят любую аффинную функцию. На нелинейных таблицах они различаются, и ни один не выигрывает везде. На таблице 2³ функции f = r·g·b в точке (0.2, 0.5, 0.8) трилинейная возвращает точные 0.08, а тетраэдрическая — 0.2. Так что «тетраэдрическая точнее» — не закон.

Тетраэдрическая всё равно остаётся по умолчанию, и аргументы в её пользу не о точности. Спецификация Adobe просит читателей использовать тетраэдрическую интерполяцию для трёхмерных таблиц, а перечисления интерполяции OpenColorIO сопоставляют «best» для 3D LUT с тетраэдрической, так что студия семплирует файл так, как формат ждёт. Второй аргумент геометрический. Каждый из шести тетраэдров имеет диагональ ячейки от c000 до c111 как ребро, поэтому серый вход интерполируется только из серых узлов. Если грейд сохраняет серые нейтральными в узлах, тетраэдрическая сохраняет их нейтральными и между узлами. Трилинейная подмешивает цветные углы.

Чтобы увидеть размер эффекта, я запекла синтетический look (S-кривая по светлоте Oklab, хрома × 1.5, оттенок +12°, обрезка до sRGB) в таблицу 17³ и семплировала её питоновским портом сэмплера движка. Это рисунок, а не измерение движка.

Слева: разброс каналов серой шкалы после LUT 17³; трилинейная подкрашивает серые до 1.7 кода, тетраэдрическая держит ноль. Справа: ошибка вдоль градиента от бирюзового к оранжевому, похожая у обоих, с пиками до 15 кодов там, где look обрезается

Трилинейная подкрашивает серую шкалу до 1.74 кода; тетраэдрическая держит 1.4 × 10⁻⁵ кода, и этот остаток идёт от матрицы Oklab с десятью знаками, а не от интерполяции. Вдоль цветного градиента два метода близки: 15.1 и 13.9 кода в худшем случае, и оба пика приходятся ровно туда, где один канал look пересекает ноль и начинает обрезаться.

Насколько большой нужна таблица

Экспорт предлагает 17³, 33³ и 65³. Их подписи: компактная, стандартная, плотная. Раньше 65³ называлась «точной»; обзор запекания измерил иначе, и подпись поменяли.

Собственная проверка движка, «Verify export», запекает таблицу, сериализует её и разбирает обратно, затем сравнивает с прямым преобразованием на 4096 детерминированных цветах, которые не лежат на сетке:

for (int index = 0; index < 4096; ++index) {
  const atelier::Vec3 rgb{((index * 73 + 17) % 4099) / 4098., ((index * 193 + 47) % 4093) / 4092., ((index * 503 + 29) % 4091) / 4090.};
  const atelier::Vec3 direct = transform(rgb);
  const atelier::Vec3 sampled = atelier::sampleCube(decoded, rgb, grade.interpolation);
  for (std::size_t channel = 0; channel < 3; ++channel) {
    const double error = std::abs(direct[channel] - sampled[channel]);
    maximum = std::max(maximum, error);
    sum += error * error;
  }
}

Она выводит максимум и RMS-ошибку. Это выборочная проверка, а не граница и не ΔE в перцептивном смысле.

12 сентября я прогнала её на намеренно тяжёлом грейде, где одновременно включены все глобальные элементы управления: импортированный LUT, пин и тёплая блокировка оттенка, Selective Color. Это был движок 0.3.0, до исправлений от 22 и 23 сентября, описанных в части 4.

Таблица Размер файла Ошибка в узлах Максимум вне сетки RMS вне сетки
33³ 1.16 МБ 1.7 × 10⁻⁶ 0.529 0.0102
65³ 9.13 МБ 1.7 × 10⁻⁶ 0.267 0.0054

В узлах (все 35 937 на 33³, каждый четвёртый узел по каждой оси на 65³) файл воспроизводит движок с точностью float. Между ними — нет. Худшей пробой был почти жёлтый у края цветового охвата, у которого синий канал должен быть 0.002; таблица 33³ вернула 0.531. Таблица с узлами через 1/32 не может представить изменение уже этого интервала, а край защиты или обрезка — как раз такое изменение. Удвоение разрешения уменьшило ошибку вдвое и не убрало её. В отчёте узлы помечены PASS, пространство между ними — HOLD, и там записан вывод, которым я теперь руководствуюсь при экспорте: это глобальное приближение в RGB, и когда такой грейд нужно воспроизвести точно, носителем служат PNG или сохранённый проект.

Синтетический look ведёт себя так же. Его RMS-ошибка падает примерно вдвое при каждом удвоении N, а максимум, которым управляет обрезка, падает медленнее.

График в логарифмической шкале: максимальная и RMS-ошибка от размера LUT от 5 до 65 для трилинейной и тетраэдрической интерполяции; RMS падает примерно вдвое при каждом удвоении, максимум падает медленнее и остаётся выше 12 кодов на 65

Запекание стека

Само запекание — три вложенных цикла. bakeCube в cube.cpp берёт скомпилированный Transform, где система пинов уже решена, кривые скомпилированы, а грейд проверен, и вычисляет его в узлах N³ в порядке «красный быстрее всего», сохраняя float. Тот же класс Transform рисует превью, полноразмерный PNG и таблицу, так что превью, полноразмерный PNG и таблица используют одну C++-реализацию грейда. JavaScript-копия (compileTransform, bakeCube) всё ещё обслуживает миниатюры, вид куба и Grid Lab.

Один урок шёл в обратную сторону: не всё должно проходить через таблицу внутри движка. Levels, посчитанные солвером ColorChecker, раньше применялись через семплированную LUT 65³, и это вносило до 16.3 кода ошибки в тени. Теперь они вычисляются точно на входе, а проверка измеряет разницу 1.1 × 10⁻¹⁶.

Управление цветом: чего экспорт не делает

LUT студии — это кодированный sRGB в кодированный sRGB, SDR, и ничего больше. Главное окно декодирует JPEG, WebP и 8-битный PNG через Canvas браузера как sRGB браузера. Путь 16-битного PNG есть только в Grid Lab: он считает непомеченный PNG sRGB и говорит об этом, а неподдерживаемый профиль отклоняет, а не конвертирует молча. ICC не читается, ICC-профиль в PNG не записывается (16-битный экспорт несёт только чанки sRGB и gAMA), нет входа Log, HDR или RAW, и подсказка экспорта сообщает, что профиль монитора не включён.

Вход и экспорт с учётом профиля описаны как кандидат в инструменты с контрактом приёмки, но не реализованы. Контракт: читать встроенный ICC-профиль или остановиться и спросить, никогда не считать sRGB молча; конвертировать один раз в объявленное линейное рабочее пространство; записывать выбранный выходной профиль в PNG и читать его обратно в тесте; держать преобразование для дисплея отдельно от рабочих пикселей; никогда не допускать, чтобы калибровочная LUT монитора попала в CUBE, Hald или проект. Photoshop умеет экспортировать таблицу поиска цвета и как ICC-профиль (and in the link: «Adobe: экспорт таблиц поиска цвета»), и как CUBE, 3DL и CSP (Adobe: экспорт таблиц цветов). Мой экспортер пишет только CUBE и Hald.

Перенос look на другой кадр

LUT переносит функцию, а не намерение. Если второй кадр снимали при более тёплом свете, тот же цвет кожи приходит с другим RGB, и LUT отвечает иначе. Мои исследовательские заметки для следующей версии описывают исправление как два этапа: нормализация на каждый кадр N_i, подогнанная по совпадающим патчам и зафиксированная, затем общий look T, так что кадр i получает T(N_i(x)). В текущем движке импортированный LUT идёт первым, а баланс белого и экспозиция — после него, поэтому такой порядок пока недоступен.

Восстановление LUT по одной фотографии «до и после» имеет жёсткий предел. Если каждый цвет на фото имеет R ниже 0.6, то T и T + δ·max(R − 0.6, 0)² совпадают на каждом наблюдаемом пикселе и расходятся везде в остальном. Фото ничего не говорит о части куба, которой в нём нет. Поэтому Hald — надёжный носитель: он содержит все узлы. Это также объясняет правила грейдинга Hald. Никакого ресайза, никакого JPEG, никаких локальных масок или резкости, и никакой автоматической коррекции, которая пересчитывается сама на Hald; заморозьте авто-параметры на фото и примените ту же функцию к Hald. Скрытая конвертация профиля в другом редакторе становится частью извлечённой LUT.

Где LUT останавливается

Всё, что зависит от позиции, по замыслу остаётся вне таблицы, а значительная часть настоящей ретуши — именно такая работа: dodge и burn, объём, кожа. Это другая половина того, что я строю. Модели за Happyin.ai производят попиксельные карты на машине самого ретушёра, а C++-плагин для Photoshop накладывает их как слои. Ничто из этого не могло бы путешествовать как LUT.

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

Colour tools, built from scratch

  1. Экспозиция и баланс белого: три множителя в линейном свете
  2. Levels: чёрная и белая точки, гамма, каналы и таблица
  3. Кривые: натуральный сплайн против PCHIP, выброс (overshoot), яркость и запекание в LUT
  4. Оттенок, насыщенность и светлота по диапазону цвета, в Oklab, а не в HSL
  5. Channel mixer, матрица 3×3: что сохраняет, где клипует, где работает
  6. 3D LUT: запекание цветового грейда в .cube и Hald и что теряется между узлами

Другие статьи

Ещё на сайте

Anastasiia Butova - ML engineer, Belgrade, Serbia. Email [email protected] · LinkedIn · GitHub · Telegram · Hugging Face