happyin.work / blog

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

Curvas: spline natural frente a PCHIP, sobreimpulso, luma y horneado en LUT

Autora: Anastasiia Butova, ingeniera de ComfyUI y modelos de difusión, Belgrado · publicado 2026-09-23

Traducción de la versión en inglés: English

Un editor de curvas parece una herramienta de dibujo, pero por debajo es una sola decisión con consecuencias visibles: cómo trazar una línea suave que pase por los puntos que has colocado. Mi motor de color ofrece tres respuestas y usa dos de ellas en lugares distintos. Esta parte explica por qué, qué hace el sobreimpulso a una imagen, en qué se diferencia una curva maestra RGB de una curva de luma (con mediciones del motor) y qué le pasa a una curva cuando se hornea en un LUT.

Qué es una curva en este motor

Una curva tiene de 2 a 16 puntos, con ambos ejes en 0…1, y x estrictamente creciente. Los extremos se mantienen; al hacer clic se añade un punto, las flechas mueven el punto seleccionado 1/255 (10/255 con Mayús), y Supr elimina un punto interior. En el pipeline de la parte 1, la curva maestra se aplica primero a cada canal RGB codificado, luego las curvas R, G y B, y la curva de luma más tarde, en la etapa fotográfica. Hay tres modos de interpolación: lineal, smooth (PCHIP) y spline (cúbico natural). Un único modo sirve para la maestra, R, G y B a la vez; la curva de luma siempre es PCHIP. Un proyecto guardado antes de que existiera el campo de modo conserva el lineal, porque con eso se hizo.

Los coeficientes se compilan una vez por gradado. En el bucle de píxeles hay una búsqueda de segmento sobre como máximo 15 intervalos y una evaluación de Horner, sin asignación de memoria y sin validación:

// 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;
}

La última línea es toda la historia de este artículo en una expresión: solo el spline necesita un recorte.

El spline cúbico natural, y su precio

El modo spline resuelve las segundas derivadas M_i en los puntos. Con h_i = x_(i+1) − x_i y las pendientes secantes d_i, el sistema es tridiagonal:

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

El motor lo resuelve con el algoritmo de Thomas cuando cambian los puntos, y luego guarda coeficientes cúbicos por segmento en t local. El valor, la primera derivada y la segunda derivada son continuos en cada punto, lo que lo convierte en la forma más suave que puedes hacer pasar por un conjunto de puntos. Las nuevas curvas RGB lo usan por defecto. Lo comprobé contra el CubicSpline natural de SciPy como oráculo externo: la copia en JavaScript concuerda hasta 6,7e-16, y el motor C++ concuerda con la copia JavaScript hasta 3e-13, incluso con una curva lo bastante fuerte como para recortar.

El precio es el sobreimpulso. Un spline natural no sabe que la salida debe quedarse dentro de 0…1, ni que una curva que pasa por puntos crecientes debería seguir creciendo.

Cuatro puntos de control dibujados por el spline natural y por PCHIP: el spline baja de 0 en las sombras y sube por encima de 1 en las altas luces, y el recorte convierte ambos en zonas planas

Estos son los cuatro puntos que usé para la comprobación en el navegador del modo spline: (0, 0), (0,36, 0,2), (0,52, 0,68), (1, 1). Antes del recorte, el spline alcanza −0,037 y 1,041. En una rampa densa de 10.001 entradas, está por debajo de 0 para entradas hasta 0,235 y por encima de 1 desde 0,714. Tras el recorte SDR eso se convierte en un negro plano sobre aproximadamente los primeros 60 códigos de entrada y un blanco plano sobre los 73 superiores. Dentro de esas zonas planas la curva ya no es C², y tampoco es estrictamente creciente: el detalle de sombras por debajo del código 60 se vuelve un único valor. En esta curva, el spline y PCHIP difieren hasta en 31 códigos de salida, en la entrada 0,70. Eso no es una diferencia de estilo en cómo se ve la línea; es una fotografía distinta.

PCHIP: sin nuevos extremos, una derivada menos

El modo smooth es PCHIP, la interpolación de Hermite cúbica a tramos con tangentes de Fritsch–Butland (Fritsch y Butland, 1984), que satisfacen las condiciones de monotonía de Fritsch y Carlson, 1980. Cada tangente interior es una media armónica ponderada de las dos secantes vecinas, y se pone a cero cuando las secantes tienen signos distintos o una de ellas es cero:

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

Las tangentes de los extremos usan una estimación de tres puntos unilateral con límites de signo y tamaño, la misma regla que usa el PchipInterpolator de SciPy. El resultado pasa por cada punto, tiene primera derivada continua y nunca crea un nuevo extremo entre puntos vecinos. Si los puntos suben, la curva sube. La segunda derivada puede saltar en un punto, que es la derivada menos del título; en la práctica se nota como una curva ligeramente menos redondeada.

La suite matemática muestrea una curva PCHIP monótona en 1.025 entradas y comprueba que ningún paso baja; el paso más pequeño que midió es 2,2e-6. En la curva de la figura, el paso más pequeño de PCHIP es 1,5e-6, mientras que el spline recortado tiene pasos de exactamente cero. En el editor, el modo PCHIP aparece etiquetado como "sin sobreimpulso".

Entonces, ¿por qué conservar los dos? Porque responden a peticiones distintas. Para una curva suave que pasa por puntos bien dentro del rango, el spline da la forma más blanda y la diferencia es pequeña. Para una curva en S fuerte cerca del negro y del blanco, PCHIP conserva la gradación de sombras y altas luces que el spline descarta. El modo es un único interruptor, y queda guardado con el proyecto.

Curva maestra RGB frente a curva de luma

Una curva maestra RGB aplica la misma función a R, G y B codificados. Un gris sigue siendo gris. Un color no conserva sus proporciones entre canales, así que una curva de contraste también sube la saturación y gira un poco el tono. La curva de luma es la respuesta a "cambia el tono y deja el color en paz (and translate hue as 'matiz' elsewhere: 'gira un poco el matiz', 'giró el matiz') (and translate hue as 'matiz' elsewhere: 'gira un poco el matiz', 'giró el matiz')". Trabaja sobre la luminancia lineal con pesos BT.709, Y = 0.2126R + 0.7152G + 0.0722B, y reconstruye RGB de forma que la nueva luminancia sea exactamente 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};
}

Las dos líneas alrededor de la ganancia son correcciones. La primera versión escalaba RGB por f(Y)/Y. Con un negro elevado, f(0) > 0, esa ganancia explota cerca del negro: un azul de un código por encima de cero pasaba a 255. Tratar el levantamiento como un desplazamiento neutro, sumando f(0) a los tres canales, lo mantiene como levantamiento. La segunda corrección es para los canales que quedan fuera de 0…1. Recortarlos uno a uno fallaba la luminancia objetivo hasta en 0,137 y giraba el tono hasta 13,6°. El motor usa ahora la construcción ClipColor de ISO 32000-1 §11.3.5.3, parte de las especificaciones PDF de los modos de fusión no separables: los canales fuera de rango se acercan al gris de la misma luminancia, de modo que Y' = f(Y) se mantiene incluso donde un canal se recortaría. La suite matemática mide el error de luminancia de la curva de luma por debajo de 1e-15.

Para ver qué aporta esto, ejecuté una misma curva en S de las dos formas en el motor. La curva maestra era una curva en S PCHIP sobre valores codificados; la curva de luma se construyó para que los grises salgan iguales, con una diferencia de menos de 0,25 códigos.

Seis colores bajo la misma curva en S: la curva RGB maestra sube la croma Oklab entre un 15 y un 31 % y gira el tono hasta 4 grados; la curva de luma deja el tono sin cambios

En seis colores de prueba, la curva RGB maestra subió la croma Oklab entre un 15 y un 31 % y giró el tono hasta 4,0°. La curva de luma cambió la croma entre −8 % y +7 % y giró el tono 0,0°. La croma aún se mueve un poco porque la croma Oklab sigue a la luminosidad incluso con cromaticidad constante. Capture One documenta la misma intención para su curva de luma: contraste sin aumento de saturación; la curva de punto de Camera Raw de Adobe ofrece curvas compuestas y por canal. Ninguno publica una fórmula, y la mía es propia.

Una consecuencia para la interfaz: la curva de luma vive en una escala de luminancia lineal, así que las mismas coordenadas de punto significan tonos distintos que en la curva maestra. El editor la muestra en su propia escala y no dibuja superposiciones de canal para ella.

Curvas en el eje perceptual: diseñadas, no construidas

Una herramienta de curvas candidata está escrita, pero no en el panel ni en el motor. Pone tres curvas sobre los ejes Oklab, L2 = fL(L), a2 = fa(a), b2 = fb(b), con los centros bloqueados, fa(0) = 0 y fb(0) = 0, de modo que cualquier neutro se mantiene neutro; el bloqueo solo puede liberarse mediante una acción explícita con una advertencia de que los neutros se desplazarán. El resultado se mezcla mediante un peso de máscara m y una intensidad s, out = (1 − s·m)·source + s·m·OklabToRgb(L2, a2, b2), y se muestran dos comprobaciones antes de aplicarlo: un bloqueo de neutro roto y colores que salen de la gama. La compresión de gama allí debe ser un modo explícito, nunca un recorte silencioso.

Hornear curvas en un LUT

Todo lo global de un gradado (tono, color, curvas, el LUT de entrada, la protección de color) depende solo del RGB de entrada, así que puede exportarse como un CUBE 3D o una imagen Hald. Las ediciones espaciales, como claridad o eliminación de neblina, dependen de los vecinos y de la posición, y el motor se niega a meterlas en un LUT; el editor exporta la parte solo de color de un gradado así únicamente después de que confirmes explícitamente que las ediciones espaciales se quedarán fuera. El horneado muestrea la transformación completa en los puntos de una malla N³, con N de 2 a 65, con el rojo como índice que varía más rápido.

En los puntos de la malla el CUBE coincide con el motor: 3e-8 para un gradado solo con spline a 33³, que es precisión de float, y 1,7e-6 para un gradado que usa todos los controles globales, dentro del umbral de 2e-6 de la revisión. Entre puntos hay interpolación, y es honesto decir cuánto. En ese gradado completo, 4.096 muestras fuera de malla dieron un error RMS de 0,010 (unos 2,6 códigos) a 33³ y 0,0054 a 65³. La muestra peor fue mucho peor: a 33³, una entrada casi amarilla salió con su canal azul 0,53 demasiado alto, y a 65³ la peor muestra seguía 0,27 desviada. La revisión marcó la exportación como HOLD, lo que significa que el CUBE es una aproximación de este gradado, no una copia, y que la imagen nativa o el proyecto son la salida fiel. El motor tiene una llamada de verificación que devuelve exactamente estos dos números, máximo y RMS sobre 4.096 muestras, para el gradado que se esté exportando. La parte 2 muestra el mismo efecto en un único conjunto de Niveles, donde una malla de 65³ falló unos 23 códigos junto al negro.

Precisión

La transformación de color se ejecuta en double y redondea una sola vez, cuando escribe la salida de 8 o 16 bits. Las curvas se evalúan analíticamente por píxel, no mediante una tabla 1D interna. La ventana principal trabaja sobre el canvas sRGB de 8 bits del navegador, y una curva pronunciada sobre una fuente de 8 bits reparte 256 niveles de entrada en un rango más amplio, que es de donde sale el banding. La ventana de laboratorio lee PNG de 16 bits sin pasarlos por el canvas, exporta PNG de 16 bits y puede aplicar un dither ordenado de 8×8 cuando tiene que reducir a 8 bits. El dither depende de la posición del píxel, así que nunca forma parte de un LUT. Nada de esto recupera una gradación que un JPEG nunca tuvo; solo evita que la edición destruya más.

El arrastre de la curva tenía un problema propio, medido en tiempo más que en códigos. En la primera versión, un arrastre de 61 eventos dibujaba la curva una sola vez, 3,56 s después del primer movimiento. Ahora el gráfico se redibuja en cada fotograma de animación de forma independiente del procesamiento, el editor envía una vista previa a la vez al motor C++ y solo guarda en espera los parámetros más recientes, y el histograma de la curva se almacena en caché por imagen fuente. El mismo arrastre dibuja 61 fotogramas de curva, el primero tras 33,9 ms, y la primera imagen procesada tras 267 ms. Un solo Deshacer revierte todo el arrastre.

Límites

  • El spline natural es el valor por defecto para las curvas RGB y puede recortar en los extremos; PCHIP es el modo para curvas fuertes cerca del negro y del blanco.
  • La luma siempre es PCHIP, sobre luminancia lineal, y no afirma paridad con Capture One ni con Adobe.
  • Las curvas en el eje perceptual son un diseño, no una función.
  • Un CUBE horneado de un gradado complejo es una aproximación; revisa su error máximo y RMS antes de enviarlo a ningún sitio.

Siguiente parte: matiz, saturación y luminosidad por color. Si te interesa más el lado nativo que el color, el plugin de retoque en C++ que se ejecuta en el dispositivo está en happyin.ai.

Colour tools, built from scratch

  1. Exposición y balance de blancos: tres multiplicadores en luz lineal
  2. Niveles: puntos de negro y blanco, gamma, canales y una carta
  3. Curvas: spline natural frente a PCHIP, sobreimpulso, luma y horneado en LUT
  4. Tono, saturación y luminosidad por rango de color, calculados en Oklab y no en HSL
  5. El mezclador de canales, una matriz 3×3: qué conserva, dónde recorta y dónde se ejecuta
  6. LUT 3D: hornear un gradado en .cube y Hald, y qué se pierde entre los nodos

Más artículos

En otras partes del sitio

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