Levels: чёрная и белая точки, гамма, каналы и таблица
Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-09-23
Перевод английской версии: English
Levels — самый понятный из тональных инструментов: зажим, прямая линия и степенная функция. В моей колор-студии он работает с кодированными значениями sRGB намеренно, после умножения в линейном свете из части 1. Формула занимает три строки. Эта статья — о всём, что вокруг неё: о порядке мастер- и канальных наборов, о том, что на самом деле измеряет гистограмма, о решателях пипетки, о чёрной и белой точках, взятых с таблицы цветов, и об одной LUT, которая стоила шестнадцати кодов у чёрного.
Формула
Для одного значения канала x в диапазоне 0…1, с входной чёрной точкой b, входной белой w, гаммой γ и выходными чёрной и белой ob, ow:
u = clamp((x − b) / (w − b), 0, 1)
out = ob + (ow − ob) · u^(1/γ)
b переходит в ob, w — в ow, а всё за пределами входного диапазона обрезается. При b = 0, w = 1, γ = 1, ob = 0, ow = 1 функция тождественна. Панель показывает входные и выходные точки в диапазоне 0…255, а гамму — от 0.2 до 5. Редактор держит между входными чёрной и белой не меньше 1/255, а сама модель отказывается от w − b < 1e-6. У выходной пары такого правила нет: выходная чёрная выше выходной белой — это инверсия, а инверсия — законный запрос.
Серая ручка стоит там, где кривая выдаёт середину выходного диапазона:
m = b + (w − b) · 0.5^γ
γ = ln((m − b) / (w − b)) / ln 0.5
Так гамма выше 1 сдвигает ручку к чёрному и осветляет средние тона; гамма ниже 1 делает обратное.

Adobe документирует те же элементы управления для Levels в Photoshop, а Capture One идёт дальше: на своей странице Levels она приводит пример линейного ремаппинга, вход 0–220 в выход 0–244, и описывает средний ползунок как гамму. Ни один из них не публикует реализацию, поэтому я не утверждаю битового совпадения ни с одним. Утверждаю я вот что: описанная выше функция — та, которую запускают оба движка студии.
Почему в кодированных значениях, а не в линейном свете? Экспозиция и баланс белого моделируют свет, поэтому они умножают линейный RGB. Levels — тональное переотображение, которым люди управляют по гистограмме кодовых значений, и гамма-ручка в линейном свете оказалась бы там, где её никто не ждёт. Поэтому серая пипетка Levels и нейтральная пипетка баланса белого — разные операции: первая изгибает гамму каждого канала в кодированных значениях, вторая умножает линейные каналы.
Сначала мастер, потом каждый канал
Грейд содержит один мастер-набор и три канальных. Мастер-набор хранится в тех же плоских полях, что и раньше, поэтому проект, сохранённый до появления канальных наборов, остаётся байт в байт идентичным. Каждый канал проходит через мастер, а затем через свой набор:
// native/src/photo_color.cpp
double applyLevels(const LevelsSet& set, double x) {
const double normalized = clamp((x - set.blackPoint) / (set.whitePoint - set.blackPoint));
return set.outputBlack + (set.outputWhite - set.outputBlack) * std::pow(normalized, 1. / set.gamma);
}
// ... inside CompiledPhoto::apply
const LevelsSet master = masterLevels(grade);
for (int c = 0; c < 3; ++c) levelled[c] = applyLevels(grade.channelLevels[c], applyLevels(master, encoded[c]));
// Levels alone never needs Oklab: skipping the round trip keeps exact zeros and ones exact.
if (levelsOnly) return levelled;
y_c = f_c(f_master(x_c)) — моё решение для этого движка, а не задокументированный факт о внутреннем порядке Photoshop.
Разница между двумя видами наборов объясняет, почему нужны оба. Одна и та же функция на всех трёх каналах сохраняет серый серым, но не сохраняет соотношение R:G:B цвета, поэтому насыщенность движется вместе с контрастом. Поканальный набор разводит каналы, и именно это убирает цветовой оттенок, а при неправильном применении — добавляет.
Комментарий в этом фрагменте скрывает ловушку, которую я поняла только по числам. В фотостадии Levels работает до любой работы в Oklab, но глобальное цветовое преобразование перед ним раньше пропускало каждый пиксель через Oklab и обратно, и такой круг — тождество только в точной арифметике. Гамма Levels затем усиливала остаток около нуля: при γ = 5 канал, который должен быть 0, но возвращается как 1e-6, выходит из Levels как 0.063, то есть шестнадцать 8-битных кодов. Первая обратная функция Oklab в движке использовала опубликованные матрицы с десятью знаками и возвращала тождество лишь с точностью до 1.18e-6. Теперь обратная точная (1.9e-14), грейд, чьи глобальные элементы не требуют Oklab, пропускает этот проход целиком, а случай γ = 5 в наборе показывает нулевую разницу между JS и C++.
Вторая ловушка была тише. Некоторое время JavaScript-движок применял поканальные Levels, а C++-движок, который рисует превью и все экспорты, молча их отбрасывал. Независимый аудит нашёл расхождения до 0.1875. Исправление серой пипеткой на пикселе (158, 128, 97) давало (127.5, 127.5, 127.5) в JS и без изменений в C++. Теперь оба применяют наборы, нативный парсер отклоняет неизвестные имена каналов вместо того, чтобы пропускать их, а проверка паритета по 257 образцам показывает 1.1e-16.
Что измеряет гистограмма
Гистограмма над элементами управления Levels имеет 256 бинов. R, G и B считаются отдельно в одни и те же бины, каждый пиксель взвешивается своей альфой, полностью прозрачные пиксели пропускаются. Она берёт не больше 32 768 пикселей по шагу, рисует квадратный корень из частоты, чтобы пики не сплющили всё остальное, и затеняет диапазоны ниже чёрной точки и выше белой.
Важно, где она измеряется: на реальном входе Levels, взятом из самого конвейера рендера. В первой версии для гистограммы и показаний кривой заново запускалась урезанная копия грейда, и эта копия сохраняла некоторые поздние стадии, а часть Levels выбрасывала. Аудит поймал чистый жёлтый (255, 255, 0), нанесённый на гистограмму «до Levels» в точке (229, 217, 102), и показание красного при включённом channel mixer, которое расходилось с реальным входом: 139.2 против 158.1. Теперь один скомпилированный конвейер сообщает собственные промежуточные входы, и оба показания используют его. На 21 случае регрессионного набора ошибка равна 0.
Пипетки: три решателя, которые умеют отказать
Три пипетки решают для каждого канала параметры по одному кликнутому пикселю. Поканальность здесь — суть: одна и та же правка на всех трёх каналах не может сделать неравные каналы равными, а значит, не может убрать каст. Для цели t позиция, которую нужно занять внутри входного диапазона, равна u = ((t − ob)/(ow − ob))^γ, и тогда
black eyedropper: b = (s − u·w) / (1 − u)
white eyedropper: w = b + (s − b) / u
grey eyedropper: γ_c = ln(u_c) / ln(v_c), u_c = (s_c − b)/(w − b), v_c = (t − ob)/(ow − ob)
Для решения по серому и u_c, и v_c должны строго лежать в (0, 1). На конце гамма не существует, поэтому обрезанный или раздавленный пиксель отклоняется с указанием причины, а не заменяется гаммой 1, которая выглядела бы так, будто инструмент ничего не сделал. Гамма вне 0.2…5 тоже отклоняется. Цель по умолчанию для серой пипетки — среднее самого образца, так что пиксель сохраняет примерно свою яркость и теряет только каст.
В аудите эти решатели точны до 6.7e-16. Честный статус: они живут в levels-picker.mjs и пока не подключены к панели. Панель правит мастер-набор, а поканальные наборы сейчас попадают в движок только из сохранённых проектов. У пути с таблицей цветов ниже есть собственная запись поканального Levels на входе грейда.
Чёрная и белая точки по таблице цветов
Первая версия пипеток отправляла кликнутый пиксель в чистый чёрный и чистый белый, что совпадает с умолчаниями Photoshop и неверно для ColorChecker. Опубликованные значения sRGB для Classic24, приписываемые X-Rite, ставят белый патч на 243, а чёрный — на 52. Если отправить белый патч в 255, выбрасывается всё, что сцена содержит между 243 и 255; если отправить чёрный патч в 0, раздавливаются 52 уровня тени. Собственное руководство по экспозиции X-Rite говорит то же простыми словами: белый квадрат должен быть очень светлым, но не чисто белым, а чёрный патч не должен читаться как чистый чёрный. Белый патч — диффузный отражатель около 90 %, поэтому зеркальный блик в кадре и должен быть ярче.
Photoshop позволяет редактировать целевые цвета своих пипеток, а это говорит о том, что 0 и 255 — соглашение, а не свойство инструмента. Поэтому решатели принимают цель, и есть пресет таблицы с опубликованными значениями (для таблиц, изготовленных после ноября 2014 года; у более старых другие числа).
Клик по чёрному и затем по белому создаёт вторую проблему: второй клик двигает входную белую, и все позиции внутри диапазона смещаются вместе с ней, так что чёрный патч уходит со своей цели. Свободная пипетка не может этого избежать, потому что каждый клик знает только свою опору. С таблицей обе опоры известны сразу, поэтому пара решается совместно:
w − b = (s_w − s_b) / (u_w − u_b), b = s_b − u_b · (w − b)
Путь, который подключён от начала до конца, начинается с отдельной модели UNet/VAE из моего проекта по таблице цветов. Она предсказывает, как выглядел бы Classic24 в точке, которую вы выберете в кадре, и даёт 24 виртуальных патча, а не детектированную физическую таблицу. Python-решатель превращает предсказанную таблицу в поканальные Levels:
# packages/checker/vendor/checker_levels.py, nested inside solve(); docstring omitted.
# black_target and white_target are the chart's published values for patches 24 and 19.
def ends(gamma: np.ndarray):
low, high = black_target ** gamma, white_target ** gamma
span = (white_sample - black_sample) / (high - low)
return black_sample - low * span, span
Гамма берётся из четырёх серых между концами. Каждый серый запрашивает свою гамму для каждого канала, найденную 60 шагами бисекции в диапазоне 0.2…5, и побеждает медианный ответ. Метод наименьших квадратов позволяет одному плохому патчу утянуть всё: при трёх серых, требующих гамму 1, и четвёртом, раздавленном до 0.02, подгонка запросила 2.74. Один серый сам по себе хрупок иначе: на первой реальной фотографии, которую я измерила, патч 20 в одиночку был недостижим в трёх из пяти размещений, хотя шкала серых вокруг него была обычной.
Решатель допускает входные точки вне 0…1, потому что чёрная точка ниже 0 — это то, как подъём выглядит со стороны входа. Отказ от таких точек стоил четырёх размещений из пяти на той же первой фотографии. Тест JS-пипеток также поправил одно моё утверждение: он утверждал, что цели таблицы ничего не обрезают, и провалился, потому что при тёплом свете входная белая зелёного канала пришлась на 0.949. Света могут быть ярче диффузного белого патча, поэтому утверждение теперь говорит то, что таблицы цветов реально дают: патч перестаёт быть потолком. JS-пипетки и этот Python-решатель живут в разных репозиториях, поэтому тест фиксирует фикстуры, на которых оба должны давать одинаковый результат.
LUT, которая стоила шестнадцати кодов
Результат с таблицы цветов хранится как небольшая запись inputLevels и вычисляется точно, для каждого канала, на входе грейда. До 23 сентября он вместо этого запекался во входную LUT 65³. Levels разделимы, поэтому и трилинейная, и тетраэдрическая выборка сводятся к линейной интерполяции между узлами с шагом 1/64, а x^(1/γ) круче всего как раз у чёрной точки. Аудит измерил там ошибку 13.8 кода, а новая проверка нашла 16.3 кода на своих образцах при гамме 3. Точное вычисление доводит её до 1.1e-16.

Я воспроизвела ловушку движком для этой статьи: чёрная 0.10, белая 0.95, гамма 3, запекание в CUBE 65³, повторный импорт и сравнение на плотном градиенте. На градиенте из 2 049 точек наибольшая ошибка составила 21.3 кода чуть ниже чёрной точки, где точный результат равен 0, а решётка всё ещё интерполирует к следующему узлу, и 10.7 чуть выше неё; при достаточно мелкой выборке пик около 22.7 кода по обе стороны от чёрной точки. Выше входа 0.16 ошибка остаётся около четверти кода (не больше 0.251). Разделимую поканальную функцию не стоит помещать в трёхмерную решётку, когда её можно вычислить точно.
Авто-уровни и авто-цвет: спроектированы, не построены
Кнопки Auto в движке пока нет. Дизайн записан: гистограммы из неизменённого входа выбранной стадии и области, видимые доли обрезки на каждом конце и чёрная и белая точки на этих квантилях. Поканальный режим берёт пару на каждый канал и меняет цвет; составной режим берёт одну пару и сохраняет нейтральную ось. Диапазон уже порога означает «не определено», а не «тождество», а найденные параметры сохраняются, чтобы повторное открытие проекта никогда не пересчитывало их тихо. Доли должны быть видимыми, потому что даже эталон не устоялся: страница Adobe о быстрых тональных правках упоминает и 0.5 %, и 0.1 % как значения обрезки по умолчанию в разных разделах.
Ограничения
- Levels работает на 8-битном SDR кодированном sRGB в главном окне. Нет превью обрезки на изображении, есть только затенённые концы на гистограмме.
- Панель правит мастер-набор. Поканальные пипетки существуют как проверенные решатели, но к панели пока не подключены.
- Цели таблицы — опубликованные значения одного поколения таблиц, а не показание спектрофотометра для вашей таблицы.
- Авто-уровней и авто-цвета нет.
Следующая часть: кривые: выбор сплайна, перелёт, яркость и запекание в LUT. Моя работа по ретуши на устройстве, C++-плагин для Photoshop со своими моделями, описана на happyin.ai.