Оттенок, насыщенность и светлота по диапазону цвета, в Oklab, а не в HSL
Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-09-23
Перевод английской версии: English
Кривые меняют каждый цвет с одинаковым тоном. Инструменты этой части меняют только некоторые цвета: сдвинуть зелёные к жёлтому, затемнить небо, оставить кожу в покое. Фоторедакторы обычно называют это семейство HSL. В моей колор-студии ничего из этого не работает в HSL. Ниже — как выбирается диапазон цвета, почему правка идёт в Oklab, как светлота меняется, не сдвигая оттенок, как защищены кожа и серые и два бага, которые были в первой версии математики.
Откуда код
Движок — LUT Atelier, мой более ранний редактор из личного репозитория-хаба, закреплённый в студии на коммите fd96f16 с SHA-256 для каждого из 87 файлов (один из них — вендорённый json.hpp от nlohmann); часть 1 рассказывает эту историю. Математика цвета — C++20 на стандартной библиотеке. Там, где публичный продукт дал мне идею интерфейса, я называю его и не утверждаю, что его алгоритм мой.
Почему не HSL
HSL и HSV годятся для палитры выбора цвета и плохи для правки. Светлота HSL — арифметика по кодированным каналам, (max + min) / 2, поэтому чистые красный, жёлтый, зелёный, голубой, синий и пурпурный имеют светлоту HSL 50 %. В Oklab их светлота идёт от 0.45 у синего до 0.97 у жёлтого. Ползунок, который в HSL обещает «светлота +10», делает разные вещи для разных оттенков, а поворот оттенка при постоянной светлоте HSL превращает светло-жёлтый в тёмно-синий.

В движке есть функция rgbToHsl. Единственный модуль, который её импортирует, — код вида, и ни одна правка её не вызывает. Все инструменты диапазонов работают в Oklab Бьорна Оттоссона: L — светлота, а (a, b) дают хрому C = √(a² + b²) и оттенок h = atan2(b, a). Оттоссон прямо называет эту задачу среди своих целей: поднять насыщенность, не меняя воспринимаемый оттенок и светлоту, и указывает, что CIELAB плохо предсказывает синие оттенки. Синий — это место, где правка оттенка обычно даёт швы.
Одно свойство я использую напрямую. Oklab — это матрица, кубический корень и вторая матрица, поэтому умножение линейного RGB на k умножает L, a и b на кубический корень из k. Отношение C/L и оттенок не меняются при экспозиции. Я проверила это на тёплом цвете при k = 2: каждая координата умножилась на 1.2599, C/L остался 0.1531, а оттенок 53.42°. Поэтому вес кожи ниже использует C/L, а не C.
Шесть секторов оттенка с мягким краем
Инструмент диапазонов имеет шесть ползунков светлоты и шесть ползунков оттенка. Их центры — углы Oklab 0°, 60° и далее до 300°. Это углы, а не названия цветов: чистый красный sRGB стоит в Oklab на 29°, жёлтый — на 110°, синий — на 264°. Поэтому интерфейс рисует под каждым ползунком полоску реальных оттенков, а не называет один из них «красным».
Каждый сектор имеет весовую функцию «приподнятый косинус», которая падает до нуля на 60° от своего центра:
w_i = 0.5 · (1 + cos(π · d_i / 60)) при d_i ≤ 60°, иначе 0
где d_i — круговое расстояние от оттенка пикселя до центра. Веса двух соседних секторов всегда в сумме дают 1, поэтому цвет между двумя секторами получает ровно одну полную правку, разделённую между ними. Обрезка на 60° важна: cos(π·d/60) повторяется каждые 120°, и без обрезки сектор 0° действовал бы в полную силу и на 120°, и на 240°.

Около серого угол оттенка — шум, поэтому правка дополнительно умножается на порог по хроме q(C) = smoothstep(0.015, 0.05, C). Серый с лёгким случайным оттенком не будет осветляться так, будто он оранжевый. Цикл в творческой стадии color.cpp:
if (hasBands) {
const double chromaWeight = smoothstep(.015, .05, chroma);
double deltaHue = 0.;
for (int i = 0; i < 6; ++i) {
const double distance = hueDistance(hue, i * 60.);
if (distance > 60.) continue;
const double weight = .5 * (1. + std::cos(kPi * distance / 60.)) * chromaWeight * amount;
out[0] += grade.bands[i] * weight;
deltaHue += grade.hueBands[i] * weight;
}
if (deltaHue != 0.) {
const double angle = deltaHue * kPi / 180., cosine = std::cos(angle), sine = std::sin(angle);
const double a = out[1], b = out[2];
out[1] = a * cosine - b * sine; out[2] = a * sine + b * cosine;
}
}
Здесь видны два решения. hue и chroma берутся из базового цвета, до любой другой творческой правки, поэтому предыдущий поворот оттенка не может перевести пиксель под другой ползунок. А сдвиги оттенка всех шести секторов сначала суммируются и применяются как один поворот, который не трогает L и C на этой стадии.
Светлота без сдвига оттенка
Ползунок светлоты прибавляет до ±0.2 к L в Oklab и не трогает a и b. Ползунок оттенка (±30°) поворачивает (a, b) и не трогает L. Глобальные регуляторы оттенка и насыщенности следуют тому же правилу: насыщенность масштабирует расстояние от нейтральной оси, оттенок поворачивает угол. Проверка по математике (npm run check:math) измеряет это: после глобальной правки оттенка и хромы изменение L в Oklab равно 0, а ошибка хромы — 2.7 × 10⁻¹⁵.
Это выполняется на творческой стадии, и больше я не утверждаю. После неё идут мягкое сжатие гамута, контраст, кривые, фотостадия и channel mixer. Когда мягкое сжатие включено (Grid Lab включает его для новых грейдов), оно двигает цвет, вышедший за гаммут, к серому той же L в линейном RGB, потому что фиксированный по L и оттенку луч в Oklab небезопасен около синего; часть 1 объясняет почему. Как минимум первые 80 % расстояния от серого до края гаммута остаются точными. За этим коленом сжатие немного сдвигает L и оттенок, и документация об этом говорит, а не делает вид, что инвариант сохраняется.
Защита кожи и серых
Есть три защиты, и они не одинаково сильны.
Мягкие ползунки лишь ослабляют творческую стадию. Их веса:
neutral = 1 − smoothstep(0.015, 0.12, C)
skin = exp(−(d(h, 35°) / 28°)²) · smoothstep(0.015, 0.06, C/L) · smoothstep(0.005, 0.025, L)
и творческая правка масштабируется как 1 − max(neutralStrength · neutral, skinStrength · skin). Прежняя версия гейтила кожу порогом L = 0.35, и это тихо оставляло тёмную кожу незащищённой. Переход на C/L убрал порог; затухает только зона прямо у чёрного, где оттенок ненадёжен. Сохранённые проекты с защитой кожи теперь защищают тёмные цвета сильнее, и это изменение сделано намеренно. Эти числа — мои инженерные эвристики, а не опубликованная модель кожи и не классификатор. Тёплый коричневый диван попадает внутрь лепестка, а кожа под цветным светом может оказаться снаружи. Тест проверяет математику на образцах цвета, а не распознавание на наборе портретов.
Блокировки оттенков сильнее. У каждой есть центр, полуширина полной защиты, растушёвка и минимальная хрома; их до двенадцати. Пресеты Grid Lab: кожа 50° ±25° с растушёвкой 15°, зелёные на 145° и небо на 250°; пипетка создаёт узкую блокировку ±12° с растушёвкой 8°. Вес берётся из исходного пикселя, а смешивание обратно происходит один раз, в самом конце:
out = original + (processed − original) · strength · (1 − maxProtection)
Так заблокированный цвет возвращается к точному исходному RGB, что бы ни сделали LUT, баланс белого или кривые, и импортированный LUT не может изменить то, какие пиксели считаются защищёнными.
Первый баг жил именно здесь. Переведённый из sRGB в Oklab чистый серый давал крошечную хрому, 3.7 × 10⁻⁸ у белого и меньше у более тёмных серых, и оттенок 89.9°, так что любая блокировка с минимальной хромой 0, чей диапазон покрывал 90°, защищала все серые в кадре. Исправление берёт порог из CSS Color 4, который считает оттенок OkLCh «бессильным» при C ≤ 0.000004. Ниже этого у пикселя нет оттенка, и блокировка оттенка не может его забрать. Проверка теперь конвертирует 256 серых sRGB: наибольшая хрома — 8.5 × 10⁻¹¹, и каждый вес блокировки равен 0.
Хорда, которая прорезала серый
И защита, и растушёванные выборки применяют правку «частично». Первая версия делала очевидное: вычисляла полностью повёрнутые (a, b), затем смешивала их с исходными (a, b) по весу. Это смешение идёт по хорде круга оттенков, а не по дуге. При весе 0.5 поворот на 180° попадает ровно в серый, а поворот на 90° теряет 29 % хромы, потому что длина половинной хорды равна |cos(θ/2)|.

Пять независимых read-only обзоров движка 22 сентября нашли это дважды: в весовании защиты глобального оттенка и насыщенности и в растушёванном крае Selective Color. Теперь обе части масштабируют саму правку в полярной форме: угол A·θ и коэффициент хромы 1 + A·(s − 1). Версия Selective Color из photo_color.cpp:
// The feather scales the edit in polar form; a Cartesian mix cut across the hue circle.
const double newChroma = std::hypot(lab[1], lab[2]) * (1. + membership * selection.saturation);
const double angle = std::atan2(lab[2], lab[1]) + membership * selection.hue * kPi / 180.;
lab = {lab[0] + membership * selection.lightness, newChroma * std::cos(angle), newChroma * std::sin(angle)};
Проверка утверждает, что при принадлежности 0.5 ошибка хромы равна 3.9 × 10⁻¹⁶, а оттенок поворачивается ровно на половину запрошенного. JavaScript-реализация и C++-движок сравниваются на одних и тех же входах: полярная защита совпадает до 6.1 × 10⁻¹⁵, фотостадия с полярным Selective Color — до 1.1 × 10⁻¹⁴.
Выбор цвета вместо сектора
Секторы намеренно грубые. Для «этот зелёный, а не все зелёные» есть два инструмента с выборкой.
Selective Color берёт до восьми образцов. У каждого есть радиус в Oklab, который учитывает L так же, как a и b; полная принадлежность внутри внутреннего радиуса, который задаётся ползунком растушёвки (по умолчанию половина радиуса), и плавное затухание smoothstep до нуля на радиусе. Принадлежность вычисляется один раз по исходному цвету, так что предыдущая правка не может изменить, какие пиксели к нему относятся. Правка — это L плюс m·ΔL, C умножить на 1 + m·Δs, h плюс m·Δh, с необязательным притяжением к целевому цвету. Защищённый цвет — похожий образец без правки: внутри его внутреннего радиуса возвращается точный исходный RGB.
Цветовые пины в сетке A/B идут дальше: до 24 якорей на плоскости a/b, каждый либо сдвигается, либо фиксируется. В интерактивном макете дизайна для страниц студии каждому якорю соответствовало по одному компактному ядру, и их суммировали. При двух якорях на половине радиуса друг от друга, где A запрашивает Δa = 0.10, а B просит остаться на месте, B сдвинулся на 0.10 × φ(0.5) = 0.019. Движок решает систему ядер: K c = d с K_ij = φ(‖p_i − p_j‖ / R) и ядром Уэндленда C² φ(t) = (1 − t)⁴ (4t + 1). Поле тогда точно проходит через каждое запрошенное смещение, а блокировка — это смещение ноль. Решение имеет сложность O(n³) для n ≤ 24 и выполняется один раз на правку, а не на каждый пиксель. Якоря ближе 0.001 R или система с опорным элементом меньше 10⁻⁵ отклоняются с ошибкой, а не решаются плохо.
Ограничения
- Всё здесь выбирает по цветовому пространству, а не по картинке. Бежевая стена и бежевая щека получают одинаковую обработку. Тот же факт делает всё экспортируемым: каждый инструмент этой части — функция только входного RGB и запекается в
.cube, это часть 6. - Нет семантической маски кожи и нет панели HSL из восьми диапазонов с границами диапазонов коммерческого редактора. Color Mixer и Point Color в Lightroom Classic показали мне, насколько полезно выводить затронутый диапазон рядом с ползунком. Я взяла эту идею для интерфейса, а не формулу.
- Путь — SDR sRGB, а Oklab считается из линейного sRGB при D65. Log, HDR и произвольный ICC-вход не поддерживаются.
- Сильно сдвинутое поле пинов всё ещё может свести два цвета в один. Ограничитель Grid Lab проверяет поле на насыщение и совпадение, но пока не проверяет веса защиты; этот случай — открытая задача, а не решённая.
Следующая часть — channel mixer: матрица 3×3 на каналах RGB, где вся эта перцептивная механика не применяется, а математика проще и грубее.