Кривые: натуральный сплайн против PCHIP, выброс (overshoot), яркость и запекание в LUT
Автор: Anastasiia Butova, инженер по ComfyUI и диффузионным моделям, Белград · опубликовано 2026-09-23
Перевод английской версии: English
Редактор кривых выглядит как инструмент для рисования, но под ним лежит одно решение с видимыми последствиями: как провести гладкую линию через поставленные точки. Мой колор-движок предлагает три ответа и два из них использует в разных местах. Эта часть объясняет, почему так, что выброс (overshoot) делает с изображением, чем мастер-кривая RGB отличается от кривой яркости (с измерениями из движка) и что происходит с кривой, когда её запекают в LUT.
Что такое кривая в этом движке
У кривой от 2 до 16 точек, обе оси от 0 до 1, x строго возрастает. Концы сохраняются; клик добавляет точку, стрелки двигают выбранную на 1/255 (10/255 с Shift), Delete удаляет внутреннюю точку. В конвейере из части 1 мастер-кривая применяется к каждому кодированному RGB-каналу первой, затем кривые R, G и B, а кривая яркости — позже, в фотостадии. Есть три режима интерполяции: linear, smooth (PCHIP) и spline (натуральный кубический). Один режим обслуживает мастер, R, G и B вместе; кривая яркости всегда PCHIP. Проект, сохранённый до появления поля режима, остаётся на linear, потому что именно так он и был сделан.
Коэффициенты компилируются один раз на грейд. В цикле по пикселям работают поиск сегмента по не более чем 15 интервалам и вычисление по схеме Горнера, без аллокаций и без проверок:
// native/src/color.cpp
double sample(double input) const {
if (input <= x.front()) return y.front();
if (input >= x.back()) return y.back();
std::size_t index = 0;
while (input > x[index + 1]) ++index;
if (input == x[index + 1]) return y[index + 1];
const double t = (input - x[index]) / (x[index + 1] - x[index]);
if (mode == CurveMode::linear) return y[index] + (y[index + 1] - y[index]) * t;
const double value = ((d[index] * t + c[index]) * t + b[index]) * t + y[index];
return mode == CurveMode::spline ? clamp(value) : value;
}
Последняя строка — вся суть статьи в одном выражении: обрезка нужна только сплайну.
Натуральный кубический сплайн и его цена
Режим сплайна решает систему относительно вторых производных M_i в точках. С h_i = x_(i+1) − x_i и секущими наклонами d_i система трёхдиагональная:
h_(i−1)·M_(i−1) + 2(h_(i−1) + h_i)·M_i + h_i·M_(i+1) = 6(d_i − d_(i−1)), M_0 = M_(n−1) = 0
Движок решает её алгоритмом Томаса, когда меняются точки, и затем хранит кубические коэффициенты на каждом сегменте в локальном t. Значение, первая и вторая производные непрерывны в каждой точке, что делает его самой мягкой формой, которую можно провести через набор точек. Новые RGB-кривые используют его по умолчанию. Я сверила его с натуральным CubicSpline из SciPy как внешним эталоном: JavaScript-копия совпадает до 6.7e-16, а C++-движок с JavaScript-копией — до 3e-13, включая кривую, достаточно сильную, чтобы она клиппила.
Цена — выброс (overshoot). Натуральный сплайн не знает, что выход должен оставаться в пределах 0..1, и что кривая, проходящая через возрастающие точки, должна продолжать расти.

Эти четыре точки я использовала для проверки режима сплайна в браузере: (0, 0), (0.36, 0.2), (0.52, 0.68), (1, 1). До обрезки сплайн доходит до −0.037 и 1.041. На плотном ряду из 10 001 входа он ниже 0 при входах до 0.235 и выше 1 начиная с 0.714. После SDR-обрезки получается плоский чёрный примерно на первых 60 кодах входа и плоский белый на верхних 73. Внутри этих плоских участков кривая уже не C², и она больше не строго возрастает: детали теней ниже кода 60 становятся одним значением. На этой кривой сплайн и PCHIP различаются до 31 кода выхода при входе 0.70. Это не разница в стиле линии; это другая фотография.
PCHIP: без новых экстремумов, на одну производную меньше
Режим smooth — это PCHIP, кусочно-кубическая эрмитова интерполяция с касательными Фрича–Бутланда (Fritsch and Butland, 1984), которые удовлетворяют условиям монотонности из Fritsch and Carlson, 1980. Каждая внутренняя касательная — взвешенное гармоническое среднее двух соседних секущих и обнуляется, если секущие имеют разные знаки или одна из них равна нулю:
m_k = (w1 + w2) / (w1/d_(k−1) + w2/d_k), w1 = 2h_k + h_(k−1), w2 = h_k + 2h_(k−1)
m_k = 0 if d_(k−1)·d_k <= 0
Касательные на концах строятся односторонней трёхточечной оценкой с ограничениями по знаку и величине — тем же правилом, каким пользуется PchipInterpolator в SciPy. Результат проходит через каждую точку, имеет непрерывную первую производную и никогда не создаёт нового экстремума между соседними точками. Если точки растут, кривая растёт. Вторая производная может скачком меняться в точке — это и есть та самая производная, которой на одну меньше; на практике это видно как чуть менее округлый изгиб.
Набор проверок по математике берёт монотонную PCHIP-кривую в 1025 точках и проверяет, что ни один шаг не идёт вниз; наименьший измеренный шаг — 2.2e-6. На кривой с рисунка наименьший шаг PCHIP — 1.5e-6, а у обрезанного сплайна шаги в точности нулевые. В редакторе режим PCHIP подписан как «без выброс (overshoot)а».
Зачем же держать оба? Потому что они отвечают на разные запросы. Для мягкого изгиба через точки внутри диапазона сплайн даёт более мягкую форму, и разница невелика. Для сильной S-кривой у чёрного и белого PCHIP сохраняет градацию в тенях и светах, которую сплайн выбрасывает. Режим — это один переключатель, и он сохраняется в проекте.
Мастер-кривая RGB против кривой яркости
Мастер-кривая RGB применяет одну и ту же функцию к кодированным R, G и B. Серый остаётся серым. Цвет не сохраняет соотношения каналов, поэтому контрастная кривая поднимает и насыщенность, и немного сдвигает оттенок. Кривая яркости — ответ на вопрос «измени тон, а цвет не трогай». Она работает с линейной яркостью по весам BT.709, Y = 0.2126R + 0.7152G + 0.0722B, и пересобирает RGB так, чтобы новая яркость была в точности f(Y):
// native/src/photo_color.cpp
std::array<double, 3> linear{srgbToLinear(levelled[0]), srgbToLinear(levelled[1]), srgbToLinear(levelled[2])};
// Luma curve: RGB' = RGB * (f(Y) - f(0)) / Y + f(0), so Y' = f(Y) exactly (see photo-color.mjs).
const double y = lumaOf(linear);
const double mappedY = luma.sample(y);
if (y > kLumaFloor) {
for (double& channel : linear) channel = channel * (mappedY - lumaBlack) / y + lumaBlack;
linear = clipToLuminance(linear, mappedY);
} else {
linear = {mappedY, mappedY, mappedY};
}
Обе строки вокруг усиления — исправления. Первая версия масштабировала RGB на f(Y)/Y. При поднятом чёрном, когда f(0) > 0, это усиление взрывалось у чёрного: синий, на один код выше нуля, стал 255. Если считать подъём нейтральным смещением, прибавляя f(0) ко всем трём каналам, он остаётся подъёмом. Второе исправление касается каналов, которые выходят за 0..1. Обрезка их по одному промахивалась по целевой яркости до 0.137 и поворачивала оттенок до 13.6°. Теперь движок использует конструкцию ClipColor из ISO 32000-1 §11.3.5.3, часть спецификации PDF для неразделимых режимов наложения: каналы за пределами диапазона подтягиваются к серому той же яркости, так что Y' = f(Y) выполняется даже там, где канал стал бы клиппить. Набор проверок по математике измеряет ошибку яркости кривой ниже 1e-15.
Чтобы увидеть, что это даёт, я один и тот же S-образный профиль прогнала через движок двумя способами. Мастер-кривая была PCHIP S-кривой на кодированных значениях; кривая яркости построена так, что серые получаются такими же, в пределах 0.25 кода.

На шести тестовых цветах мастер-кривая RGB подняла хрому Oklab на 15–31 % и повернула оттенок до 4.0°. Кривая яркости изменила хроматичность от −8 % до +7 % и повернула оттенок на 0.0°. Хроматичность всё же немного меняется, потому что хрома в Oklab следует за светлотой даже при постоянной цветности. Capture One описывает то же намерение для своей Luma curve: контраст без повышения насыщенности; у Adobe Camera Raw есть точечные кривые, составная и поканальные. Ни одна из них не публикует формулы, а моя — собственная.
Одно следствие для интерфейса: кривая яркости живёт на линейной шкале яркости, поэтому одни и те же координаты точек означают другие тоны, чем на мастер-кривой. Редактор показывает её на её собственной шкале и не рисует для неё наложений каналов.
Кривые в перцептуальных осях: спроектированы, не построены
Кандидат на инструмент кривых записан, но ни в панели, ни в движке его нет. Он ставит три кривые на осях Oklab, L2 = fL(L), a2 = fa(a), b2 = fb(b), с зафиксированными центрами, fa(0) = 0 и fb(0) = 0, так что любой нейтральный остаётся нейтральным; блокировка снимается только явным действием с предупреждением, что нейтральные сместятся. Результат подмешивается через весовую маску m и силу s: out = (1 − s·m)·source + s·m·OklabToRgb(L2, a2, b2). Перед применением показываются две проверки: сломанная блокировка нейтральных и цвета, вышедшие за гаммут. Сжатие гаммута там должно быть явным режимом, а не тихой обрезкой.
Запекание кривых в LUT
Всё глобальное в грейде (тон, цвет, кривые, входной LUT, защита цветов) зависит только от входного RGB, поэтому это можно экспортировать как 3D CUBE или Hald-изображение. Пространственные правки, например clarity или dehaze, зависят от соседей и позиции, и движок отказывается класть их в LUT; редактор экспортирует только цветовую часть такого грейда лишь после явного подтверждения, что пространственные правки будут опущены. Запекание берёт полную трансформацию в узлах решётки N³, N от 2 до 65, с красным, меняющимся быстрее всего.
В узлах решётки CUBE совпадает с движком: 3e-8 для грейда только со сплайном на 33³ — это точность float, а 1.7e-6 для грейда, использующего все глобальные элементы управления, — в пределах порога 2e-6 из обзора. Между узлами это интерполяция, и честно будет сказать, насколько она ошибается. На этом полном грейде 4096 точек вне решётки дали среднеквадратичную ошибку 0.010 (около 2.6 кода) на 33³ и 0.0054 на 65³. Худший отдельный образец был гораздо хуже: на 33³ почти жёлтый вход вышел с синим каналом, завышенным на 0.53, а на 65³ худший образец всё ещё отличался на 0.27. Обзор пометил экспорт как HOLD, то есть CUBE — это приближение этого грейда, а не копия, и точным результатом остаются нативное изображение или проект. В движке есть вызов verify, который возвращает именно эти два числа, максимум и RMS по 4096 точкам, для любого грейда, который экспортируется. Часть 2 показывает тот же эффект на одном наборе Levels: решётка 65³ промахивалась около чёрной точки примерно на 23 кода.
Точность
Цветовое преобразование работает в double и округляет один раз, когда записывает выход в 8 или 16 бит. Кривые вычисляются аналитически на каждый пиксель, а не через внутреннюю одномерную таблицу. Главное окно работает на 8-битном sRGB-канвасе браузера, и крутая кривая на 8-битном источнике растягивает 256 входных уровней на более широкий диапазон — отсюда берётся бэндинг. Окно лаборатории читает 16-битный PNG, минуя канвас, экспортирует 16-битный PNG и может применить упорядоченный дизеринг 8×8, когда нужно уменьшить до 8 бит. Дизеринг зависит от позиции пикселя, поэтому он никогда не входит в LUT. Ничто из этого не восстановит градацию, которой у JPEG не было; оно лишь не даёт правке разрушить ещё больше.
У перетаскивания кривой была своя проблема, измеряемая временем, а не кодами. В первой версии 61 событие перетаскивания рисовало кривую один раз, через 3.56 с после первого движения. Теперь график перерисовывается на каждом кадре анимации независимо от обработки, редактор отправляет в C++-движок по одному превью и хранит только самые свежие ожидающие параметры, а гистограмма кривой кешируется для каждого исходного изображения. Теперь то же перетаскивание рисует 61 кадр кривой, первый через 33.9 мс, и первое обработанное изображение через 267 мс. Один Undo по-прежнему откатывает всё перетаскивание целиком.
Ограничения
- Натуральный сплайн — по умолчанию для RGB-кривых, и на концах он может клиппить; PCHIP — режим для сильных кривых у чёрного и белого.
- Кривая яркости всегда PCHIP, на линейной яркости, и не претендует на паритет с Capture One или Adobe.
- Кривые в перцептуальных осях — проект, а не функция.
- Запечённый CUBE сложного грейда — приближение; перед отправкой проверьте его максимальную и RMS-ошибку.
Следующая часть: оттенок, насыщенность и светлота по цвету. Если вас интересует нативная часть больше, чем цвет, то C++-плагин ретуши на устройстве описан на happyin.ai.