LUTs 3D: gravando um grade de cor em .cube e Hald, e o que se perde entre os nós
Autora: Anastasiia Butova, engenheira de ComfyUI e modelos de difusão, Belgrado · publicado em 2026-09-23
Tradução da versão em inglês: English
Cada ferramenta das partes anteriores desta série, da exposição ao mixer de canais, calcula uma cor nova a partir da cor antiga e de mais nada. Uma função assim pode ser guardada como uma tabela: amostre-a numa grade de entradas RGB, guarde as saídas e interpole entre elas. Essa tabela é um LUT 3D, e é assim que um color grading sai do meu estúdio de cor (use 'grading'/'color grading' throughout, including 'grade-o' → 'faça o grading dele') para outro programa. Esta parte cobre os dois formatos, a interpolação, o tamanho da tabela, o que a exportação se recusa a levar e o que medi quando verifiquei uma gravação real contra o motor.
O que um LUT consegue guardar
Um LUT 3D é uma função amostrada T: [0,1]³ → [0,1]³. Ele responde do mesmo modo para o mesmo RGB onde quer que o pixel esteja: num rosto, numa parede, em outra fotografia. Então ele pode guardar exposição, balanço de brancos, curvas, Níveis, as ferramentas de faixa do Oklab, travas de matiz, cores protegidas e o mixer. Não consegue guardar nada que olhe a posição de um pixel ou seus vizinhos: Textura, Clareza, Nitidez, Dehaze, máscaras por coordenada.
O motor não confia na interface para lembrar disso. O manipulador de exportação em main.cpp se recusa por conta própria:
if (type == "cube" || type == "hald" || type == "verify") {
if (atelier::hasSpatialEdits(spatial)) fail("spatial edits cannot be exported as a 3D LUT");
Com edições espaciais ativas, o diálogo de exportação lista o que ficará de fora e deixa desmarcada a opção de apenas cor; só um clique explícito envia um contexto espacial identidade. A recusa nativa é a rede de segurança, então um bug no diálogo não consegue produzir um LUT que descarte a nitidez em silêncio.
O arquivo .cube, lido com rigor
O formato é a Cube LUT Specification 1.0 da Adobe, de 2013 (cópia arquivada do PDF da Adobe): um arquivo de texto com TITLE, LUT_3D_SIZE, DOMAIN_MIN e DOMAIN_MAX opcionais, e depois N³ linhas de três números com o vermelho variando mais rápido, de modo que o índice da linha é r + N·g + N²·b. A especificação permite N de 2 a 256. Meu parser aceita de 2 a 65, apenas tabelas 3D. Ele recusa arquivos 1D e shaper, diretivas desconhecidas, números não finitos e um número errado de linhas, com uma mensagem que nomeia o problema, e toma a entrada como sRGB codificado em vez de adivinhar um perfil Log ou de câmera a partir do nome do arquivo.
Cinco revisões independentes e somente leitura, feitas em 22 de setembro, encontraram três bugs no parser; o primeiro apareceu quando o estúdio leu de volta os próprios arquivos:
- Um
#dentro de umTITLEentre aspas era tratado como comentário e removido, então o estúdio não conseguia reimportar alguns dos próprios exports. - Os parsers em JavaScript e C++ discordavam em 9 de 50 arquivos de um corpus de teste. Agora eles compartilham uma única gramática, escrita nas duas linguagens com a mesma definição regular de número.
- O gravador passava bytes não ASCII para o
TITLE. A especificação só permite caracteres do espaço ao til, sem as aspas. Agora o gravador mantém esses caracteres e troca todo o resto por um espaço.
Hald: um LUT disfarçado de imagem
Um Hald CLUT guarda o mesmo cubo como imagem. No nível L, o cubo tem N = L² valores por eixo e a imagem tem L³ pixels de lado; lidos como uma única linha longa de pixels, o vermelho avança mais rápido, depois o verde, depois o azul. A seção sobre Hald do ImageMagick é a descrição primária que segui: o nível 8 é uma imagem de 512 × 512 que guarda 64³ cores.

O fluxo de trabalho é simples: salve um Hald neutro, grade-o em qualquer editor com operações apenas de cor, mantenha o tamanho e abra-o de novo como LUT. O estúdio também grava seu próprio grade atual como Hald. O formato não tem como provar que é um Hald, então o importador verifica apenas a forma: um PNG quadrado cuja aresta é um cubo perfeito, nível de 2 a 8, totalmente opaco. Qualquer foto quadrada de 512 × 512 passa nessa verificação e vira um disparate, por isso a documentação diz claramente para não importar uma foto comum.
Dois defeitos estavam no caminho da imagem, não na matemática. A importação de Hald passava pelo Canvas do navegador, e o Canvas faz conversão de cor num PNG cujos blocos gAMA, cHRM ou iCCP descrevem algo diferente de sRGB. Um Hald com tags saiu deslocado em até 136 códigos antes que um único valor da tabela fosse lido. Agora a importação decodifica o PNG ela mesma e toma as amostras de 8 ou 16 bits como números. A exportação era só de 8 bits; agora é de 16 bits. A diferença é visível antes de qualquer gradação: 64 níveis não caem em códigos inteiros de 8 bits, então um Hald identidade em 8 bits já está errado em até 0,0019 nos seus nós, em unidades de 0..1. Em 16 bits, o mesmo erro é 7,3 × 10⁻⁶.
Trilinear ou tetraédrico
Entre os nós, o motor interpola de um dos dois jeitos. O trilinear mistura os oito cantos da célula. O tetraédrico ordena as três coordenadas fracionárias, escolhe um de seis tetraedros dentro da célula e mistura quatro cantos. Do 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};
Os dois reproduzem exatamente qualquer função afim. Em tabelas não lineares eles diferem, e nenhum vence em todos os casos. Numa tabela 2³ de f = r·g·b no ponto (0,2; 0,5; 0,8), o trilinear devolve o exato 0,08 e o tetraédrico 0,2. Então "o tetraédrico é mais preciso" não é uma lei.
O tetraédrico continua como padrão, e os argumentos para ele não são sobre precisão. A especificação da Adobe pede que os leitores usem interpolação tetraédrica para tabelas 3D, e as enumerações de interpolação do OpenColorIO mapeiam "best" para um LUT 3D como tetraédrico; então o estúdio amostra um arquivo do jeito que o formato espera que ele seja amostrado. O segundo argumento é geométrico. Cada um dos seis tetraedros tem a diagonal da célula, de c000 a c111, como aresta, então uma entrada cinza é interpolada apenas a partir de nós cinzas. Se o grade mantém os cinzas neutros nos nós, o tetraédrico mantém os cinzas neutros também entre os nós. O trilinear mistura os cantos coloridos.
Para ver o tamanho do efeito, gravei um look sintético (uma curva em S sobre o L do Oklab, croma × 1,5, matiz +12°, cortado para sRGB) numa tabela 17³ e a amostrei com um port em Python do amostrador do motor. Isto é uma figura, não uma medição do motor.

O trilinear tinge a rampa de cinzas em até 1,74 código; o tetraédrico fica em 1,4 × 10⁻⁵ código, e esse resíduo vem da matriz Oklab de dez dígitos, não da interpolação. Ao longo de um gradiente colorido os dois ficam próximos, 15,1 e 13,9 códigos no pior caso, e ambos atingem o pico exatamente onde um canal do look cruza zero e começa a cortar.
Qual o tamanho que a tabela precisa ter
A exportação oferece 17³, 33³ e 65³. Elas são rotuladas como compacta, padrão e densa. A opção 65³ antes dizia "exata"; uma revisão da gravação mediu o contrário, e o rótulo mudou.
A própria verificação do motor, "Verify export", grava a tabela, serializa, reanalisa e compara com a transformação direta em 4096 cores determinísticas que não estão na grade:
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;
}
}
Ela informa o erro máximo e o RMS. É um teste amostrado, não um limite, e não é um ΔE perceptual.
Em 12 de setembro rodei a verificação sobre um grade deliberadamente pesado, com todos os controles globais ligados ao mesmo tempo, desde um LUT importado e um pin até uma trava de matiz quente e o Selective Color. Isso foi o motor 0.3.0, antes das correções de 22 e 23 de setembro descritas na parte 4.
| Tabela | Tamanho do arquivo | Erro nos nós | Máximo fora da malha | RMS fora da malha |
|---|---|---|---|---|
| 33³ | 1,16 MB | 1,7 × 10⁻⁶ | 0,529 | 0,0102 |
| 65³ | 9,13 MB | 1,7 × 10⁻⁶ | 0,267 | 0,0054 |
Nos nós (todos os 35.937 de 33³, e a cada quarto nó em cada eixo de 65³), o arquivo reproduz o motor com precisão de float. Entre eles, não. A pior sonda foi um quase amarelo na borda da gamute, cujo canal azul deveria ser 0,002; a tabela 33³ devolveu 0,531. Uma tabela com nós a 1/32 de distância não consegue representar uma mudança mais estreita que esse espaçamento, e uma borda de proteção ou um corte são exatamente essas mudanças. Dobrar a resolução reduziu o erro pela metade e não o eliminou. O registro marca os nós como PASS e o espaço entre eles como HOLD, e registra a conclusão que uso agora para a exportação: é uma aproximação RGB global, e quando um grade assim precisa ser reproduzido com exatidão, o veículo é o PNG ou o projeto salvo.
O look sintético se comporta do mesmo modo. Seu erro RMS cai cerca de metade a cada vez que N dobra, enquanto o máximo, guiado pelo corte, cai mais devagar.

Gravando a cadeia inteira
A gravação em si são três laços aninhados. bakeCube em cube.cpp recebe um Transform compilado, em que o sistema de pins já está resolvido, as curvas compiladas e o grade validado, e o avalia em N³ nós na ordem em que o vermelho varia mais rápido, guardando floats. A mesma classe Transform renderiza a prévia, o PNG em tamanho real e a tabela, então a prévia, o PNG em tamanho real e a tabela compartilham uma única implementação C++ do grade. Uma cópia em JavaScript (compileTransform, bakeCube) ainda serve as miniaturas, a visualização do cubo e o Grid Lab.
Uma lição foi no sentido contrário: nem tudo deve passar por uma tabela dentro do motor. Os Níveis calculados pelo solver do ColorChecker costumavam ser aplicados por meio de um LUT 65³ amostrado, o que colocava até 16,3 códigos de erro nas sombras. Agora são avaliados exatamente na entrada, e a verificação mede a diferença em 1,1 × 10⁻¹⁶.
Gerenciamento de cor: o que a exportação não faz
Os LUTs do estúdio vão de sRGB codificado para sRGB codificado, SDR, e nada além disso. A janela principal decodifica JPEG, WebP e PNG de 8 bits pelo Canvas do navegador como sRGB do navegador. O caminho de PNG de 16 bits existe só no Grid Lab: ele trata um PNG sem marcação como sRGB e diz isso, e recusa um perfil não suportado em vez de convertê-lo em silêncio. Não há leitura de ICC, nem perfil ICC gravado no PNG (uma exportação de 16 bits carrega apenas os blocos sRGB e gAMA), nem entrada Log, HDR ou RAW, e a dica de exportação informa que o perfil do monitor não está incluído.
Entrada e exportação conscientes de perfil estão descritas como uma ferramenta candidata com um contrato de aceitação, e não estão implementadas. O contrato: ler o perfil ICC embutido ou parar e perguntar, nunca assumir sRGB em silêncio; converter uma vez para um espaço de trabalho linear declarado; gravar o perfil de saída escolhido no PNG e lê-lo de volta num teste; manter a transformação de exibição separada dos pixels de trabalho; e nunca deixar um LUT de calibração do monitor vazar para um CUBE, um Hald ou um projeto. O Photoshop pode exportar uma tabela de consulta como perfil ICC, além de CUBE, 3DL e CSP (Adobe: exportar tabelas de consulta de cor). Meu exportador grava apenas CUBE e Hald.
Levando um look para outro quadro
Um LUT move a função, não a intenção. Se o segundo quadro foi fotografado sob luz mais quente, a mesma pele chega com outro RGB, e o LUT responde de forma diferente. Minhas notas de pesquisa para a próxima versão descrevem a correção como duas etapas: uma normalização por quadro N_i, ajustada sobre patches correspondentes e congelada, e depois o look compartilhado T, de modo que o quadro i recebe T(N_i(x)). No motor atual, o LUT importado vem primeiro e o balanço de brancos e a exposição vêm depois dele, então essa ordem ainda não está disponível.
Recuperar um LUT a partir de uma única fotografia antes e depois tem um limite rígido. Se toda cor da foto tiver R abaixo de 0,6, então T e T + δ·max(R − 0,6, 0)² concordam em todo pixel observado e discordam em todo o resto. A foto não diz nada sobre a parte do cubo que ela não contém. Por isso um Hald é o veículo confiável: ele contém todos os nós. Isso também explica as regras para gradear um. Sem redimensionar, sem JPEG, sem máscaras locais nem nitidez, e sem correção automática que se recalcule sozinha sobre o Hald; congele os parâmetros automáticos na foto e aplique a mesma função ao Hald. Uma conversão de perfil de cor oculta no outro editor também vira parte do LUT extraído.
Onde um LUT para
Tudo que depende da posição fica fora da tabela por projeto, e uma boa parte do retoque real é exatamente esse tipo de trabalho: dodge e burn, volume, pele. É a outra metade do que construo. Os modelos por trás do Happyin.ai produzem mapas por pixel na própria máquina do retocador, e um plugin C++ para Photoshop os compõe como camadas. Nada disso poderia viajar como LUT.
Isso encerra a série. Ela começou com exposição e balanço de brancos, a primeira etapa da mesma cadeia que esta parte transforma em tabela.