Экспозиция и баланс белого: три множителя в линейном свете
Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-09-23
Перевод английской версии: English
В колор-студии, которую я строю, экспозиция и баланс белого — одна операция: три множителя на линейном RGB. Всё интересное находится вокруг этого умножения. Какие значения считаются линейными, к чему нормируются коэффициенты, чтобы серый сохранял свою яркость, и что движок делает, когда канал выходит за 1. Это первая часть серии о том, как устроены базовые колор-инструменты: с математикой, C++-кодом и ошибками, которые поймали тесты.
Движок и пространство, в котором он работает
Студия — локальный редактор на основе LUT Atelier, колор-редактора, который я сначала написала в своём исследовательском хабе, а потом закрепила в студии по SHA-256 каждого исходного файла. Несколько исправлений, описанных ниже, попали в движок уже после этой фиксации. Нативная часть написана на C++20 и использует только стандартную библиотеку; единственный сторонний файл — nlohmann/json 3.12.0, вендорённый для протокола командной строки. JavaScript-копия математики живёт как независимый эталон: прогон с фиксированным seed по 600 случайным грейдам сравнивает 196 200 выходных значений между двумя движками, и наибольшая разница — 8.7e-14.
Вход честен насчёт того, что он собой представляет. JPEG, WebP и 8-битный PNG приходят через канвас браузера как 8-битный sRGB. Отдельное окно лаборатории читает 16-битный PNG, не давая канвасу округлить его. RAW-декодера нет, HDR нет, и кроме sRGB ICC-профилей нет. Цветовое преобразование считает в double и округляет только при записи выхода.
Значения sRGB закодированы, а не линейны. Движок декодирует каждый канал кривой IEC 61966-2-1 до любого умножения:
c_lin = c / 12.92 if c <= 0.04045
c_lin = ((c + 0.055) / 1.055)^2.4 otherwise
Серый 0.5 декодируется в 0.214. Это число — причина большей части этой статьи.
Порядок грейда внутри движка, из native/src/color.cpp:
- Входная стадия: Levels чекера или импортированный LUT, смешанный одним ползунком силы.
- Декодирование в линейный свет, затем умножение на усиления баланса белого, экспозиции и температуры/тинта.
- Необязательные правки Oklab (оттенок, насыщенность, цветовые пины, референсное совпадение), затем обратно в кодированный RGB.
- Контраст, мастер-кривая, кривые R/G/B.
- Фотостадия: Levels, кривая яркости, тоновые ползунки, vibrance, колёса градации, selective colour.
- Channel mixer, защита цветов, глобальная сила.
Экспозиция — это умножение, значит, она живёт в линейном свете
Экспозиция — это 2^EV, применённый к линейному RGB. +1 EV удваивает свет, −1 — делит пополам, ползунок идёт от −3 до +3. Причина, по которой это должно происходить в линейных значениях, физическая: вдвое больше света — это вдвое больший линейный сигнал. Это не вдвое большее значение кода.

Рисунок снят с самого движка. При +1 EV входной код 128 выходит как 175.6, а белого достигают, начиная с входного кода 188. Если умножить закодированное значение на два, всё от кода 128 и выше станет белым, и верхняя половина шкалы пропадёт. Adobe документирует экспозицию в Photoshop как вычисление в линейном цветовом пространстве, а Lightroom описывает +1.00 как примерно одну диафрагменную ступень. На совпадение с их формулой я не претендую; умножение — просто то, что означает это слово.
Вот весь путь в C++. Экспозиция и температура/тинт сворачиваются в один вектор усилений раз на грейд, а цикл по пикселям делает три умножения:
// native/src/color.cpp
Vec3 linearGains(const Grade& grade) {
const Vec3 warm{1. + grade.temperature * .4, 1. + grade.tint * .25, 1. - grade.temperature * .4};
const double exposure = std::exp2(grade.exposure), y = linearLuminance(warm);
return {exposure * warm[0] / y, exposure * warm[1] / y, exposure * warm[2] / y};
}
Vec3 workLinear(Vec3 original, const Grade& grade, const std::optional<Cube>& lut, Vec3 gains) {
// ... input stage: checker Levels, then the imported LUT, blended by importedStrength ...
return {srgbToLinear(original[0]) * grade.wb[0] * gains[0],
srgbToLinear(original[1]) * grade.wb[1] * gains[1],
srgbToLinear(original[2]) * grade.wb[2] * gains[2]};
}
Задокументированные инварианты простые. EV 0 — это множитель ровно 1, и набор проверок по математике подтверждает, что +1 EV удваивает линейный RGB с нулевой ошибкой. +1 с последующим −1 возвращает вход везде, где ничего не обрезалось: на серой шкале я измерила ошибку прохода туда-обратно 4e-14 кода.
Баланс белого: то же умножение, по каналам
Баланс белого здесь — диагональное масштабирование линейного RGB, модель фон Криза: одно усиление на канал. Усиления приходят из двух мест. Нейтральная пипетка записывает wb, а ползунки температуры и тинта дают относительные множители:
w = (1 + 0.4·T, 1 + 0.25·t, 1 − 0.4·T) / Y(w)
Y(w) = 0.2126·w_R + 0.7152·w_G + 0.0722·w_B
Деление на Y(w) — часть, которую я сначала сделала неправильно. Без него тинт +1 умножает зелёный на 1.25, и серый становится ярче на log2(1.179), примерно 0.24 EV; тинт −1 делает его темнее на 0.28 EV. Так ползунок, который должен менять цвет, менял и экспозицию. С делением температура и тинт перекрашивают нейтральный тон, не меняя его линейной яркости. Я прогнала средний серый через тинт +1: он выходит как (118.1, 131.0, 118.1), и его линейное Y совпадает с Y входа до последнего бита.
Компас, управляющий этими ползунками, — это картинка двух ограниченных чисел, а не термометр. Маркер стоит в x = cx + R·tanh(temperature), y = cy − R·tanh(tint), и перетаскивание обращает это через atanh, с ограничением 0.7615, чуть ниже tanh(1) — конца диапазона ползунка. Кельвинов на нём нет. Lightroom показывает Кельвины для RAW-файлов, а для остальных файлов — относительную шкалу от −100 до +100; Capture One показывает Кельвины и тинт. JPEG обычно уже однажды сбалансирован белым — в камере или в конвертере RAW, и без профиля камеры нет способа перевести Кельвины. Относительная шкала здесь честнее.
Нейтральная пипетка сохраняет яркость того, на что вы кликнули
Кликните по серому пятну, и движок декодирует его в линейное (r, g, b) и задаёт
g_c = Y / c, Y = 0.2126·r + 0.7152·g + 0.0722·b
После умножения все три канала равны Y, так что выбранная точка становится серым своей собственной яркости. Версия, которую я закрепила, нормировала вместо этого к геометрическому среднему (r·g·b)^(1/3). Это сохраняет произведение усилений равным 1 и выглядит аккуратно, но выбранная точка становилась ярче или темнее: 0.30 EV для тестового замера пипеткой (0.6, 0.45, 0.3) и около 1.06 EV для зеленоватого замера вроде (0.3, 0.6, 0.3). Цветовой инструмент тихо снова двигал экспозицию. Версия по яркости легла 22 сентября, и JS- и C++-движки совпадают в ней бит в бит на наборе проверок.
// native/src/color.cpp
Vec3 neutralGains(Vec3 rgb) {
if (!finite3(rgb) || rgb[0] <= 0. || rgb[1] <= 0. || rgb[2] <= 0. || rgb[0] > 1. || rgb[1] > 1. || rgb[2] > 1.) throw std::range_error("neutralGains requires finite encoded sRGB channels in (0, 1]");
const Vec3 linear{srgbToLinear(rgb[0]), srgbToLinear(rgb[1]), srgbToLinear(rgb[2])};
// g_i = Y / c_i: the pick becomes a grey of its own luminance (see color-core.mjs neutralGains).
const double y = linearLuminance(linear);
Vec3 gains{y / linear[0], y / linear[1], y / linear[2]};
for (double gain : gains) if (!std::isfinite(gain) || gain > 8.) throw std::range_error("neutralGains pick needs a gain above grade.wb limit 8: it is too dark or too far from neutral");
return gains;
}
Успешный выбор также возвращает температуру и тинт к нулю, так что усиления на экране — это весь баланс. Замер, которому нужно усиление выше 8, отклоняется с этим сообщением, а замер с нулевым каналом — с собственным сообщением. Я предпочитаю отказ ограниченному (обрезанному) усилению, потому что зажатое усиление даёт результат, похожий на то, что инструмент сработал, хотя он не сработал. Тест в наборе — сильно окрашенный образец, которому нужны усиления (4.40, 1.10, 0.24); после умножения его каналы равны, с разбросом ровно ноль.
То, на что вы кликаете, важнее формулы. Одна фотография не может отделить цвет света от цвета объекта: линейный сигнал равен I_c = k·∫ E(λ)·R(λ)·S_c(λ) dλ, и умножение света E на любую спектральную функцию при делении отражательной способности R на ту же функцию даёт тот же пиксель. Поэтому пипетку нужно ставить на серую карту или на поверхность, которую вы знаете как нейтральную, но никогда не на кожу и не на «белую» футболку неизвестного красителя. По той же причине в движке пока нет автоматического баланса белого. Все автоматические методы опираются на допущение, а классическое допущение «серого мира» превращает по-настоящему зелёную сцену в серую; исследовательские проверки намеренно сохраняют такой контрпример.
Что реализовано для известных нейтральных — это панель Chart Check: один известный нейтральный патч, его кодированный и линейный RGB, усиления и остаток после них. Для самого патча остаток равен нулю по построению, поэтому полезна проверка других нейтральных в кадре, которые читаются на RGB-параде. Решатель для нескольких патчей (медиана отношений по каждому патчу) описан, но не построен. Патчи, которые расходятся, не усредняются в скрытый «идеальный» баланс: расхождение остаётся видимым на параде.
Порядок, который я пока не меняла
В текущем движке импортированный LUT идёт до баланса белого и экспозиции. Для look-LUT это неправильный порядок: сначала хочется нормировать кадр, а уже потом применять look, иначе один и тот же look ляжет по-разному на каждый кадр серии. Я не переставила, потому что молчаливая перестановка изменила бы каждый сохранённый проект. Нужен версионированный рецепт, где старые проекты сохраняют старый порядок, а новые получают новый. До тех пор нейтральная пипетка читает пиксель после входной стадии, так что её усиления хотя бы относятся к тому, что умножение реально получает.
Что происходит за пределами 1.0
Движок — SDR. Только с экспозицией и балансом белого каждый канал кодируется и обрезается в [0, 1] сразу после умножения; при активных правках Oklab обрезка идёт позже, самое позднее при записи результата. Из этого следуют две вещи, и обе видны.
Во-первых, обрезанные детали не возвращаются. Если опустить кривую после экспозиции, она лишь затемнит плоскую 255; значения выше неё были выброшены на стадию раньше, и это прямо сказано в документации инструмента экспозиции.
Во-вторых, обрезка по каналам меняет оттенок. Когда красный канал достигает 1, а зелёный и синий продолжают расти, цвет уплывает к жёлтому.

Числа взяты из движка. Тёплый sRGB (204, 133, 87) имеет оттенок Oklab 52.9°. Его красный канал достигает 255 при +0.73 EV. При +1 EV выход (255, 182, 121), оттенок 60.4°; при +2 EV — (255, 248, 166), оттенок 103.9°. Оранжевый стал бледно-жёлтым.
Есть альтернатива по выбору — мягкое сжатие гаммута. Вместо того чтобы резать каждый канал, оно двигает выходящий за пределы цвет к серому той же светлоты Oklab, вдоль прямого отрезка в линейном sRGB, пока цвет не впишется в куб. После творческой правки Oklab для цвета, начинавшегося внутри куба, как минимум первые 80 % расстояния до границы остаются точными, а остаток сжимается экспоненциальным плечом. Когда работает только экспозиция, опорная точка — сама экспонированная точка, уже снаружи, поэтому там это простая проекция на поверхность. На том же тёплом цвете +1 EV даёт (255, 192, 159) с оттенком 48.5°: оттенок меняется меньше, но хрома падает с 0.115 (результат жёсткой обрезки; у исходного цвета 0.107) до 0.085, а примерно с +1.67 EV, где светлота Oklab достигает 1, результат — чистый белый. Ни один вариант не является восстановлением светов.
Первая версия была учебниковой: сохранить светлоту Oklab и оттенок фиксированными и уменьшать хрому вдоль этого луча. Она ломается около синего. На синей стороне луч с фиксированными L и оттенком может выйти за гаммут и вернуться в него, а отображение, которое должно быть непрерывным, не может перескочить этот разрыв. Небольшой возврат около 264° — моё измерение с матрицами движка, а не его: в статье Бьорна Оттоссона об Oklab сказано, что ранняя подгонка сворачивала синие внутрь, и что он ограничил итоговый Oklab так, чтобы этого не было. Формулировка мягкой опции в терминах Oklab и предупреждение против жёсткой обрезки RGB — из его заметок о гаммут-клиппинге. Отрезок в линейном RGB к серому той же светлоты имеет одно пересечение, потому что куб RGB выпуклый, так что непрерывность достаётся бесплатно. У него есть измеренная цена: на ярком тёплом градиенте при +0.8 EV соседние 8-битные входные коды могут дать выходы, отстоящие на 19 кодов. Это крутой отклик, а не скачок; тест допускает до 20 и падает на 21.
Capture One документирует восстановление светов, которое может использовать каналы, которые не обрезались. Это работает на RAW-данных с запасом. На 8-битном sRGB на входе нет ничего выше 255, что можно восстановить, поэтому я лучше покажу обрезку честно, чем придумаю детали.
Ограничения и что я бы повторила
- 8-битный SDR sRGB в главном окне, 16-битный PNG только в окне лаборатории. Без RAW, без Кельвинов, без ICC.
- Баланс белого по одному известному патчу; несколько патчей и автоматическая оценка спроектированы, но не построены.
- Импортированный LUT всё ещё идёт до баланса белого. Исправление требует версионированного рецепта.
Что бы я сделала с самого начала: делила бы температуру и тинт на их собственную яркость, держала бы вторую реализацию математики как эталон и позволяла бы пипетке отказать образцу с причиной, вместо того чтобы зажимать её усиления.
Следующая часть: Levels, гистограммы и чёрная и белая точки по мишени, где тот же движок работает в кодированных значениях намеренно. C++-плагин ретуши на устройстве, который я поставляю для Photoshop, описан на happyin.ai.