LUT 3D: hornear un gradado en .cube y Hald, y qué se pierde entre los nodos
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
Cada herramienta de las partes anteriores de esta serie, desde la exposición hasta el mezclador de canales, calcula un color nuevo a partir del color antiguo y de nada más. Una función así puede guardarse como una tabla: se muestrea en una malla de entradas RGB, se guardan las salidas y se interpola entre ellas. Esa tabla es un LUT 3D, y es como un gradado sale de mi estudio de color hacia otro programa. Esta parte cubre los dos formatos, la interpolación, el tamaño de la tabla, lo que la exportación se niega a llevar y lo que medí cuando comprobé un horneado real contra el motor.
Qué puede guardar un LUT
Un LUT 3D es una función muestreada T: [0,1]³ → [0,1]³. Responde igual para el mismo RGB dondequiera que esté el píxel: en una cara, en una pared, en otra fotografía. Así que puede guardar exposición, balance de blancos, curvas, Niveles, las herramientas de rango Oklab, bloqueos de tono, colores protegidos y el mezclador. No puede guardar nada que mire la posición o los vecinos de un píxel: Textura, Claridad, Enfoque, Neblina, máscaras por coordenadas.
El motor no confía en que la interfaz lo recuerde. El manejador de exportación en main.cpp se niega por su cuenta:
if (type == "cube" || type == "hald" || type == "verify") {
if (atelier::hasSpatialEdits(spatial)) fail("spatial edits cannot be exported as a 3D LUT");
Con ediciones espaciales activas, el diálogo de exportación enumera lo que se dejará fuera y mantiene desmarcada la opción de solo color; solo una marca explícita envía un contexto espacial identidad. El rechazo nativo es la red de seguridad, así que un error en el diálogo no puede producir un LUT que deje caer en silencio el enfoque.
El archivo .cube, leído estrictamente
El formato es Cube LUT Specification 1.0 de Adobe, de 2013 (copia archivada del PDF de Adobe): un archivo de texto con TITLE, LUT_3D_SIZE, DOMAIN_MIN y DOMAIN_MAX opcionales, y luego N³ filas de tres números con el rojo cambiando más rápido, de modo que el índice de fila es r + N·g + N²·b. La especificación permite N de 2 a 256. Mi parser acepta de 2 a 65, solo tablas 3D. Rechaza archivos 1D y de shaper, directivas desconocidas, números no finitos y un número de filas incorrecto, con un mensaje que nombra el problema, y toma la entrada como sRGB codificado en lugar de adivinar un perfil Log o de cámara a partir del nombre del archivo.
Cinco revisiones independientes de solo lectura el 22 de septiembre encontraron tres errores en el parser; el primero apareció cuando el estudio leyó de vuelta sus propios archivos:
- Un
#dentro de unTITLEentre comillas se eliminaba como comentario, así que el estudio no podía reimportar algunas de sus propias exportaciones. - Los parsers de JavaScript y C++ no coincidían en 9 de 50 archivos de un corpus de prueba. Ahora comparten una sola gramática, escrita en ambos lenguajes con la misma definición regular de número.
- El escritor pasaba bytes no ASCII al
TITLE. La especificación solo permite caracteres desde el espacio hasta la tilde, sin la comilla. Ahora el escritor conserva solo esos y convierte todo lo demás en un espacio.
Hald: un LUT disfrazado de imagen
Un CLUT Hald guarda el mismo cubo como una imagen. En el nivel L, el cubo tiene N = L² valores por eje y la imagen tiene L³ píxeles de lado; leída como una sola fila larga de píxeles, el rojo es el que avanza más rápido, luego el verde, luego el azul. La sección Hald de ImageMagick es la descripción primaria que seguí: el nivel 8 es una imagen de 512 × 512 que contiene 64³ colores.

El flujo de trabajo es simple: guarda un Hald neutro, etalónalo en cualquier editor con operaciones solo de color, mantén su tamaño y ábrelo de nuevo como LUT. El estudio también escribe su gradado actual como Hald. El formato no tiene forma de demostrar que es un Hald, así que el importador comprueba solo la forma: un PNG cuadrado cuyo lado es un cubo perfecto, de nivel 2 a 8, totalmente opaco. Cualquier foto cuadrada de 512 × 512 pasa esa comprobación y se convierte en un disparate, por eso la documentación dice claramente que no se debe importar una foto corriente.
Dos defectos estaban en la ruta de la imagen, no en las matemáticas. La importación de Hald pasaba por el Canvas del navegador, y Canvas convierte el color de un PNG cuyos fragmentos gAMA, cHRM o iCCP describen algo distinto de sRGB. Un Hald etiquetado volvía desplazado hasta 136 códigos antes de leer un solo valor de la tabla. Ahora la importación decodifica el PNG por sí misma y toma sus muestras de 8 o 16 bits como números. La exportación era solo de 8 bits; ahora es de 16. La diferencia es visible antes de cualquier gradado: 64 niveles no caen en códigos enteros de 8 bits, así que un Hald de identidad en 8 bits ya está desviado hasta 0,0019 en sus nodos, en unidades de 0 a 1. En 16 bits, el mismo error es 7,3 × 10⁻⁶.
Trilineal o tetraédrica
Entre nodos, el motor interpola de una de dos formas. La trilineal mezcla las ocho esquinas de la celda. La tetraédrica ordena las tres coordenadas fraccionarias, elige uno de seis tetraedros dentro de la celda y mezcla cuatro esquinas. De cube.cpp:
if (fr >= fg && fg >= fb) { p0=c000; p1=c100; p2=c110; p3=c111; t1=fr; t2=fg; t3=fb; }
else if (fr >= fg && fr >= fb) { p0=c000; p1=c100; p2=c101; p3=c111; t1=fr; t2=fb; t3=fg; }
else if (fr >= fg) { p0=c000; p1=c001; p2=c101; p3=c111; t1=fb; t2=fr; t3=fg; }
else if (fr >= fb) { p0=c000; p1=c010; p2=c110; p3=c111; t1=fg; t2=fr; t3=fb; }
else if (fg >= fb) { p0=c000; p1=c010; p2=c011; p3=c111; t1=fg; t2=fb; t3=fr; }
else { p0=c000; p1=c001; p2=c011; p3=c111; t1=fb; t2=fg; t3=fr; }
return {p0[0] + (p1[0] - p0[0]) * t1 + (p2[0] - p1[0]) * t2 + (p3[0] - p2[0]) * t3,
p0[1] + (p1[1] - p0[1]) * t1 + (p2[1] - p1[1]) * t2 + (p3[1] - p2[1]) * t3,
p0[2] + (p1[2] - p0[2]) * t1 + (p2[2] - p1[2]) * t2 + (p3[2] - p2[2]) * t3};
Ambas reproducen exactamente cualquier función afín. En tablas no lineales difieren, y ninguna gana siempre. En una tabla 2³ de f = r·g·b en el punto (0,2, 0,5, 0,8), la trilineal devuelve el 0,08 exacto y la tetraédrica 0,2. Así que "la tetraédrica es más precisa" no es una ley.
La tetraédrica sigue siendo el valor por defecto, y los argumentos a su favor no son de precisión. La especificación de Adobe pide a los lectores usar interpolación tetraédrica para las tablas 3D, y los enums de interpolación de OpenColorIO asignan "best" para un LUT 3D a tetraédrica, así que el estudio muestrea un archivo del modo en que el formato espera que se muestree. El segundo argumento es geométrico. Cada uno de los seis tetraedros tiene la diagonal de la celda, de c000 a c111, como arista, así que un valor de entrada gris se interpola solo a partir de nodos grises. Si el gradado mantiene los grises neutros en los nodos, la tetraédrica también los mantiene neutros entre nodos. La trilineal mezcla las esquinas coloreadas.
Para ver el tamaño del efecto horneé un look sintético (una curva en S sobre la L de Oklab, croma × 1,5, tono +12°, recortado a sRGB) en una tabla de 17³ y la muestreé con un port en Python del muestreador del motor. Esto es una figura, no una medición del motor.

La trilineal tiñe la rampa de grises hasta 1,74 códigos; la tetraédrica se queda en 1,4 × 10⁻⁵ códigos, y ese residuo viene de la matriz Oklab de diez dígitos, no de la interpolación. A lo largo de un degradado coloreado las dos están cerca, 15,1 y 13,9 códigos en el peor caso, y ambas alcanzan su pico justo donde un canal del look cruza cero y empieza a recortar.
Cuánto debe medir la tabla
La exportación ofrece 17³, 33³ y 65³. Se etiquetan compacta, estándar y densa. La opción 65³ decía antes "exacta"; una revisión del horneado midió otra cosa, y la etiqueta cambió.
La propia comprobación del motor, "Verificar exportación", hornea la tabla, la serializa y la vuelve a analizar, y luego la compara con la transformación directa sobre 4096 colores deterministas que no están en la malla:
for (int index = 0; index < 4096; ++index) {
const atelier::Vec3 rgb{((index * 73 + 17) % 4099) / 4098., ((index * 193 + 47) % 4093) / 4092., ((index * 503 + 29) % 4091) / 4090.};
const atelier::Vec3 direct = transform(rgb);
const atelier::Vec3 sampled = atelier::sampleCube(decoded, rgb, grade.interpolation);
for (std::size_t channel = 0; channel < 3; ++channel) {
const double error = std::abs(direct[channel] - sampled[channel]);
maximum = std::max(maximum, error);
sum += error * error;
}
}
Informa del error máximo y del RMS. Es una prueba muestreada, no una cota, y no es un ΔE perceptual.
El 12 de septiembre lo ejecuté sobre un gradado deliberadamente pesado con todos los controles globales encendidos a la vez, desde un LUT importado y un pin hasta un bloqueo de tono cálido y Selective Color. Esto fue con el motor 0.3.0, antes de las correcciones del 22 y 23 de septiembre descritas en la parte 4.
| Tabla | Tamaño del archivo | Error en los nodos | Máximo fuera de malla | RMS fuera de malla |
|---|---|---|---|---|
| 33³ | 1,16 MB | 1,7 × 10⁻⁶ | 0,529 | 0,0102 |
| 65³ | 9,13 MB | 1,7 × 10⁻⁶ | 0,267 | 0,0054 |
En los nodos (los 35.937 de 33³, y cada cuarto nodo en cada eje en 65³) el archivo reproduce el motor con precisión de float. Entre ellos, no. La peor sonda fue un casi amarillo en el borde de la gama cuyo canal azul debería ser 0,002; la tabla de 33³ devolvió 0,531. Una tabla con nodos separados 1/32 no puede representar un cambio más estrecho que ese espaciado, y un borde de protección o un recorte son exactamente ese tipo de cambio. Duplicar la resolución redujo el error a la mitad y no lo eliminó. El recibo marca los nodos como PASS y el espacio entre ellos como HOLD, y registra la conclusión que ahora uso para la exportación: es una aproximación RGB global, y cuando un gradado así tiene que reproducirse con exactitud, el soporte es el PNG o el proyecto guardado.
El look sintético se comporta igual. Su error RMS cae a la mitad aproximadamente con cada duplicación de N, mientras que el máximo, impulsado por el recorte, cae más lentamente.

Hornear la cadena
El horneado en sí son tres bucles anidados. bakeCube en cube.cpp toma un Transform compilado, donde el sistema de pines ya está resuelto, las curvas compiladas y el gradado validado, y lo evalúa en N³ nodos en orden de rojo más rápido, guardando floats. La misma clase Transform renderiza la vista previa, el PNG a tamaño completo y la tabla, así que la vista previa, el PNG a tamaño completo y la tabla comparten una única implementación C++ del gradado. Una copia en JavaScript (compileTransform, bakeCube) sigue sirviendo las miniaturas, la vista del cubo y el Grid Lab.
Una lección fue en sentido contrario: no todo debe pasar por una tabla dentro del motor. Los Niveles calculados por el solucionador de la ColorChecker se aplicaban antes mediante un LUT muestreado de 65³, lo que metía hasta 16,3 códigos de error en las sombras. Ahora se evalúan exactamente en la entrada, y la comprobación mide la diferencia en 1,1 × 10⁻¹⁶.
Gestión de color: lo que la exportación no hace
Los LUT del estudio van de sRGB codificado a sRGB codificado, SDR, y nada más. La ventana principal decodifica JPEG, WebP y PNG de 8 bits a través del Canvas del navegador como sRGB del navegador. La ruta PNG de 16 bits existe solo en el Grid Lab: trata un PNG sin etiquetar como sRGB y lo dice, y rechaza un perfil no soportado en lugar de convertirlo en silencio. No hay lectura ICC, ni perfil ICC escrito en el PNG (una exportación de 16 bits solo lleva los fragmentos sRGB y gAMA), ni entrada Log, HDR o RAW, y la sugerencia de exportación indica que el perfil del monitor no se incluye.
La entrada y exportación con reconocimiento de perfil está descrita como una herramienta candidata con un contrato de aceptación, y no está implementada. El contrato: leer el perfil ICC incrustado o detenerse y preguntar, nunca asumir sRGB en silencio; convertir una vez a un espacio de trabajo lineal declarado; escribir el perfil de salida elegido en el PNG y leerlo de vuelta en una prueba; mantener la transformación de visualización aparte de los píxeles de trabajo; y nunca dejar que un LUT de calibración de monitor se filtre en un CUBE, un Hald o un proyecto. Photoshop puede exportar una tabla de búsqueda de color como perfil ICC además de CUBE, 3DL y CSP (Adobe: exportar tablas de búsqueda de color). Mi exportador solo escribe CUBE y Hald.
Llevar un look a otro fotograma
Un LUT mueve la función, no la intención. Si el segundo fotograma se tomó con luz más cálida, la misma piel llega con un RGB distinto, y el LUT responde de otra forma. Mis notas de investigación para la siguiente versión describen la solución en dos etapas: una normalización por fotograma N_i, ajustada con parches coincidentes y congelada, y luego el look compartido T, de modo que el fotograma i obtiene T(N_i(x)). En el motor actual, el LUT importado va primero y el balance de blancos y la exposición después, así que ese orden todavía no está disponible.
Recuperar un LUT a partir de una única fotografía de antes y después tiene un límite duro. Si todos los colores de la foto tienen R por debajo de 0,6, entonces T y T + δ·max(R − 0,6, 0)² coinciden en cada píxel observado y discrepan en todo lo demás. La foto no dice nada sobre la parte del cubo que no contiene. Por eso un Hald es el soporte fiable: contiene todos los nodos. También explica las reglas para gradarlo. Nada de redimensionar, nada de JPEG, nada de máscaras locales ni enfoque, y ninguna corrección automática que se recalcule sola sobre el Hald; congela los parámetros automáticos en la foto y aplica la misma función al Hald. Una conversión de perfil de color oculta en el otro editor se convierte también en parte del LUT extraído.
Dónde se detiene un LUT
Todo lo que depende de la posición se queda fuera de la tabla por diseño, y buena parte del retoque real es exactamente ese tipo de trabajo: sobreexponer y subexponer, volumen, piel. Esa es la otra mitad de lo que construyo. Los modelos detrás de Happyin.ai producen mapas por píxel en la propia máquina del retocador, y un plugin de Photoshop en C++ los compone como capas. Nada de eso podría viajar como LUT.
Esto cierra la serie. Empezó con exposición y balance de blancos, la primera etapa de la misma cadena que esta parte convierte en una tabla.