Tono, saturación y luminosidad por rango de color, calculados en Oklab y no en HSL
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
Las curvas cambian cada color que comparte una misma tonalidad de luminosidad. Las herramientas de esta parte cambian solo algunos colores: llevar los verdes hacia el amarillo, oscurecer el cielo, dejar la piel en paz. Los editores fotográficos suelen llamar HSL a esta familia. En mi estudio de color, nada de esto se ejecuta en HSL. A continuación explico cómo se selecciona un rango de color, por qué la edición ocurre en Oklab, cómo se mueve la luminosidad sin arrastrar el tono con ella, cómo se protegen la piel y los grises, y dos errores que tuvo la primera versión de las matemáticas.
De dónde viene el código
El motor es LUT Atelier, un editor anterior mío de mi repositorio privado del hub, fijado en el estudio en el commit fd96f16 con un SHA-256 para cada uno de sus 87 archivos (uno de ellos el json.hpp de nlohmann incluido); la parte 1 cuenta esa historia. Las matemáticas de color son C++20 sobre la biblioteca estándar. Cuando un producto público me dio una idea de interfaz, la nombro y no afirmo usar su algoritmo.
Por qué no HSL
HSL y HSV sirven para un selector de color y son malos para editar. La luminosidad de HSL es aritmética sobre canales codificados, (máx + mín) / 2, así que el rojo puro, el amarillo, el verde, el cian, el azul y el magenta tienen todos una luminosidad HSL del 50 %. En Oklab, su luminosidad va de 0,45 para el azul a 0,97 para el amarillo. Un control que promete "luminosidad +10" en HSL hace cosas visiblemente distintas según el tono, y una rotación de tono con luminosidad HSL constante convierte un amarillo claro en un azul oscuro.

El motor sí contiene una función rgbToHsl. El único módulo que la importa es código de vista, y ninguna edición la llama. Todas las herramientas de rango trabajan en Oklab, el espacio de Björn Ottosson: L es la luminosidad, y (a, b) dan la croma C = √(a² + b²) y el tono h = atan2(b, a). Ottosson enumera exactamente este trabajo entre sus objetivos: subir la saturación mientras el tono y la luminosidad percibidos se mantienen, y señala que CIELAB predice mal los tonos azules. El azul es donde una edición de tono suele mostrar sus costuras.
Una propiedad que uso directamente. Oklab es una matriz, una raíz cúbica y una segunda matriz, así que multiplicar el RGB lineal por k multiplica L, a y b por la raíz cúbica de k. La razón C/L y el tono no cambian con la exposición. Lo comprobé con un color cálido en k = 2: cada coordenada se escaló por 1,2599, C/L se mantuvo en 0,1531 y el tono en 53,42°. Por eso el peso de la piel más abajo usa C/L en lugar de C.
Seis sectores de tono con borde suave
La herramienta de rango tiene seis controles para la luminosidad y seis para el tono. Sus centros son los ángulos Oklab 0°, 60° y así hasta 300°. Son ángulos, no nombres de colores: el rojo puro de sRGB está en 29° en Oklab, el amarillo en 110°, el azul en 264°. Por eso la interfaz dibuja bajo cada control una franja con los tonos reales en lugar de llamar "rojo" a uno de ellos.
Cada sector tiene un peso de coseno elevado que cae a cero a 60° de su centro:
w_i = 0.5 · (1 + cos(π · d_i / 60)) para d_i ≤ 60°, en otro caso 0
donde d_i es la distancia circular desde el tono del píxel hasta el centro. Dos pesos vecinos siempre suman 1, así que un color entre dos sectores recibe exactamente una edición completa, repartida entre ellos. El corte a 60° importa: cos(π·d/60) se repite cada 120°, y sin el corte el sector de 0° también actuaría con peso total en 120° y 240°.

Cerca del gris, el ángulo del tono es ruido, así que la edición también se multiplica por una compuerta de croma q(C) = smoothstep(0.015, 0.05, C). Un gris con un tinte accidental leve no se aclara como si fuera naranja. El bucle en la etapa creativa de 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;
}
}
Aquí se ven dos decisiones. hue y chroma vienen del color base, antes de cualquier otra edición creativa, así que una rotación de tono anterior no puede mover un píxel bajo otro control. Y los desplazamientos de tono de los seis sectores se suman primero y se aplican como una única rotación, lo que deja L y C intactas en esta etapa.
Luminosidad sin desplazamiento de tono
Un control de luminosidad suma hasta ±0,2 a la L de Oklab y deja a y b intactas. Un control de tono (±30°) rota (a, b) y deja L intacta. Los controles globales de tono y saturación siguen la misma regla: la saturación escala la distancia al eje neutro, y el tono gira el ángulo. La comprobación matemática (npm run check:math) lo mide: tras una edición global de tono y croma, el cambio en la L de Oklab es 0 y el error de croma es 2,7 × 10⁻¹⁵.
Eso se cumple en la etapa creativa, y no afirmo más. Después vienen la compresión suave de gama, el contraste, las curvas, la etapa fotográfica y el mezclador de canales. Cuando la compresión suave está activada (el Grid Lab la activa para los gradados nuevos), mueve un color fuera de gama hacia el gris de la misma L en RGB lineal, porque el rayo Oklab de L y tono fijos es inseguro cerca del azul; la parte 1 explica por qué. Al menos el primer 80 % de la distancia desde el gris hasta el borde de la gama se mantiene exacto. Más allá de ese codo la compresión sí desplaza un poco la L y el tono, y la documentación lo dice en lugar de fingir que el invariante se conserva.
Proteger piel y grises
Hay tres protecciones, y no son igual de fuertes.
Los controles suaves solo debilitan la etapa creativa. Sus pesos son:
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)
y la edición creativa se escala por 1 − max(neutralStrength · neutral, skinStrength · skin). La versión anterior limitaba la piel con un umbral en L = 0,35, lo que dejaba sin protección en silencio la piel oscura. Al pasar a C/L desapareció el umbral; solo se desvanece la zona justo junto al negro, donde el tono no es fiable. Los proyectos guardados con protección de piel ahora protegen los colores oscuros con más fuerza, y el cambio fue deliberado. Estas cifras son heurísticas de ingeniería mías, no un modelo de piel publicado ni un clasificador. Un sofá marrón cálido cae dentro del lóbulo, y la piel bajo luz de color puede caer fuera. La prueba cubre las matemáticas sobre colores de muestra, no el reconocimiento sobre un conjunto de retratos.
Los bloqueos de tono son más fuertes. Cada uno tiene un centro, una semianchura de protección total, un borde suave y una croma mínima, hasta doce. Los preajustes del Grid Lab son piel en 50° ±25° con un borde de 15°, verdes en 145° y cielo en 250°; el cuentagotas crea un bloqueo estrecho de ±12° con un borde de 8°. El peso se toma del píxel original, y la mezcla de vuelta se hace una sola vez, al final:
out = original + (processed − original) · strength · (1 − maxProtection)
Así que un color bloqueado vuelve a su RGB de origen exacto, sea cual sea lo que hayan hecho el LUT, el balance de blancos o las curvas, y el LUT importado no puede cambiar qué píxeles cuentan como protegidos.
El primer error vivía aquí. Convertido de sRGB a Oklab, un gris puro salía con una croma diminuta, 3,7 × 10⁻⁸ en blanco y menos en grises más oscuros, y un tono de 89,9°, así que cualquier bloqueo con croma mínima 0 cuyo rango cubriera 90° protegía todos los grises del encuadre. La corrección toma su umbral de CSS Color 4, que considera el tono de OkLCh "sin poder" en C ≤ 0,000004. Por debajo de ese valor, un píxel no tiene tono, y un bloqueo de tono no puede reclamarlo. La comprobación convierte ahora 256 grises sRGB: la mayor croma es 8,5 × 10⁻¹¹ y todos los pesos de bloqueo son 0.
La cuerda que cruzó el gris
La protección y las selecciones con borde suave aplican una edición "en parte". La primera versión hacía lo obvio: calcular el (a, b) completamente rotado y mezclarlo con el (a, b) original según el peso. Esa mezcla recorre una cuerda del círculo de tonos, no el arco. Con peso 0,5, una rotación de 180° cae exactamente en el gris, y una rotación de 90° pierde el 29 % de su croma, porque la cuerda a mitad de camino tiene longitud |cos(θ/2)|.

Cinco revisiones independientes de solo lectura del motor el 22 de septiembre lo encontraron dos veces: en el ponderado de protección del tono y la saturación globales, y en el borde suavizado de Selective Color. Ahora ambos escalan la propia edición en forma polar, con ángulo A·θ y factor de croma 1 + A·(s − 1). La versión de Selective Color de 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)};
La comprobación afirma que con pertenencia 0,5 el error de croma es 3,9 × 10⁻¹⁶ y el tono gira exactamente la mitad de lo pedido. La implementación en JavaScript y el motor C++ se comparan con las mismas entradas: la protección polar concuerda hasta 6,1 × 10⁻¹⁵, y la etapa fotográfica con Selective Color polar hasta 1,1 × 10⁻¹⁴.
Elegir un color en lugar de un sector
Los sectores son gruesos a propósito. Para "este verde, no todos los verdes" hay dos herramientas por muestreo.
Selective Color toma hasta ocho muestras. Cada una tiene un radio en Oklab que cuenta L además de a y b, pertenencia total dentro de un radio interior fijado por un control de borde (la mitad del radio por defecto), y un desvanecimiento suave hasta cero en el radio. La pertenencia se calcula una vez a partir del color fuente sin tocar, así que una edición anterior no puede cambiar qué píxeles pertenecen. La edición es L más m·ΔL, C por 1 + m·Δs, h más m·Δh, con un tirón opcional hacia un color objetivo. Un color protegido es una muestra parecida sin edición: dentro de su radio interior, el RGB original vuelve exacto.
Los pines de color en la cuadrícula A/B llegan más lejos: hasta 24 anclas en el plano a/b, cada una movida o bloqueada. El prototipo de diseño interactivo de las páginas del estudio añadió un núcleo compacto por ancla y los sumó. Con dos anclas separadas medio radio, A pidiendo Δa = 0,10 y B pidiendo quedarse quieta, B se mueve 0,10 × φ(0,5) = 0,019. El motor resuelve en cambio el sistema de núcleos: K c = d con K_ij = φ(‖p_i − p_j‖ / R) y el núcleo de Wendland C² φ(t) = (1 − t)⁴ (4t + 1). El campo pasa entonces exactamente por cada desplazamiento pedido, y un bloqueo es un desplazamiento de cero. La resolución es O(n³) para n ≤ 24 y se ejecuta una vez por edición, no por píxel. Las anclas más cercanas que 0,001 R, o un sistema con un pivote menor que 10⁻⁵, se rechazan con un error en lugar de resolverse mal.
Límites
- Todo aquí selecciona en el espacio de color, no en la imagen. Una pared beige y una mejilla beige reciben el mismo tratamiento. Ese mismo hecho permite exportarlo: cada herramienta de esta parte es una función del RGB de entrada únicamente y se hornea en un
.cube, que es la parte 6. - No hay máscara de piel semántica, ni panel HSL de ocho rangos con los límites de rango de un editor comercial. Color Mixer y Point Color de Lightroom Classic me enseñaron lo útil que es mostrar el rango afectado junto al control. Tomé esa idea para la interfaz, no una fórmula.
- La ruta es sRGB SDR, con Oklab calculado desde sRGB lineal en D65. No se admiten Log, HDR ni ICC arbitrario de entrada.
- Un campo de pines muy movido puede todavía plegar dos colores en uno. El limitador del Grid Lab certifica el campo frente a la saturación y el emparejamiento, pero todavía no frente a los pesos de protección; ese caso es un ticket abierto, no un caso resuelto.
La siguiente parte es el mezclador de canales: una matriz 3×3 sobre canales RGB, donde nada de esta maquinaria perceptual se aplica y las matemáticas son más simples y más directas.