Exposição e balanço de brancos: três multiplicadores em luz linear
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
No estúdio de cor que estou construindo, exposição e balanço de brancos são uma única operação: três multiplicadores sobre RGB linear. Tudo que é interessante fica em volta dessa multiplicação. Quais valores contam como lineares, a que os ganhos são normalizados para que um cinza mantenha seu brilho e o que o motor faz quando um canal passa de 1. Esta é a primeira parte de uma série sobre como as ferramentas básicas de cor são implementadas, com a matemática, o C++ e os erros que os testes pegaram.
O motor e o espaço em que ele trabalha
O estúdio é um editor local construído sobre o LUT Atelier, um editor de cor que escrevi antes no meu hub de pesquisa e depois fixei no estúdio pelo SHA-256 de cada arquivo-fonte. Várias correções descritas abaixo entraram no motor depois do fixamento. O lado nativo é C++20 e usa apenas a biblioteca padrão; o único arquivo de terceiros é o nlohmann/json 3.12.0, vendorizado para o protocolo de linha de comando. A cópia em JavaScript da matemática continua viva como oráculo independente: uma execução semeada sobre 600 grades aleatórias compara 196.200 valores de saída entre os dois motores, e a maior diferença é 8,7e-14.
A entrada é honesta sobre o que é. JPEG, WebP e PNG de 8 bits chegam pelo canvas do navegador como sRGB de 8 bits. Uma janela de laboratório separada lê PNG de 16 bits sem deixar o canvas arredondá-lo. Não há decodificador de RAW, nem HDR, nem perfil ICC além do sRGB. A transformação de cor calcula em double e arredonda apenas quando escreve a saída.
Os valores sRGB são codificados, não lineares. O motor decodifica cada canal pela curva IEC 61966-2-1 antes de qualquer multiplicação:
c_lin = c / 12.92 if c <= 0.04045
c_lin = ((c + 0.055) / 1.055)^2.4 otherwise
Um cinza médio de 0,5 decodifica para 0,214. Esse número é a razão da maior parte deste artigo.
A ordem de um grade dentro do motor, a partir de native/src/color.cpp:
- Etapa de entrada: Levels do checker ou um LUT importado, misturados por um único controle de intensidade.
- Decodificação para linear, depois multiplicação pelos ganhos de balanço de brancos, exposição e temperatura/tonalidade.
- Edições opcionais em Oklab (matiz, saturação, pins de cor, match de referência), depois de volta ao RGB codificado.
- Contraste, curva mestra, curvas R/G/B.
- Etapa de foto: Levels, curva de luma, controles de tom, vibrance, rodas de gradação, Selective Color.
- Mixer de canais, proteção de cores, intensidade global.
Exposição é uma multiplicação, então vive na luz linear
Exposição é 2^EV aplicado ao RGB linear. +1 EV dobra a luz, −1 a reduz à metade, e o controle vai de −3 a +3. A razão para isso acontecer em valores lineares é física: o dobro da luz é o dobro do sinal linear. Não é o dobro do valor de código codificado.

A figura é amostrada do próprio motor. Em +1 EV, o código de entrada 128 sai em 175,6, e o branco é atingido a partir do código de entrada 188. Se você multiplicar o valor codificado por dois, tudo a partir do código 128 vira branco e a metade superior da rampa desaparece. A Adobe documenta o ajuste de Exposição do Photoshop como um cálculo em espaço de cor linear, e o Lightroom descreve +1,00 como aproximadamente um stop. Não reivindico a fórmula deles; a multiplicação é simplesmente o que a palavra significa.
Aqui está o caminho completo em C++. Exposição e temperatura/tonalidade são combinadas num único vetor de ganho uma vez por grade, e o laço de pixels faz três multiplicações:
// native/src/color.cpp
Vec3 linearGains(const Grade& grade) {
const Vec3 warm{1. + grade.temperature * .4, 1. + grade.tint * .25, 1. - grade.temperature * .4};
const double exposure = std::exp2(grade.exposure), y = linearLuminance(warm);
return {exposure * warm[0] / y, exposure * warm[1] / y, exposure * warm[2] / y};
}
Vec3 workLinear(Vec3 original, const Grade& grade, const std::optional<Cube>& lut, Vec3 gains) {
// ... input stage: checker Levels, then the imported LUT, blended by importedStrength ...
return {srgbToLinear(original[0]) * grade.wb[0] * gains[0],
srgbToLinear(original[1]) * grade.wb[1] * gains[1],
srgbToLinear(original[2]) * grade.wb[2] * gains[2]};
}
Os invariantes documentados são simples. EV 0 é um multiplicador exatamente igual a 1, e a suíte matemática verifica que +1 EV dobra o RGB linear com erro zero. +1 seguido de −1 devolve a entrada onde nada cortou: na rampa de cinzas medi um erro de ida e volta de 4e-14 de código.
Balanço de brancos: a mesma multiplicação, por canal
O balanço de brancos aqui é um escalonamento diagonal do RGB linear, o modelo de von Kries: um ganho por canal. Os ganhos vêm de dois lugares. O conta-gotas neutro escreve wb, e os controles de temperatura e tonalidade produzem multiplicadores relativos:
w = (1 + 0.4·T, 1 + 0.25·t, 1 − 0.4·T) / Y(w)
Y(w) = 0.2126·w_R + 0.7152·w_G + 0.0722·w_B
A divisão por Y(w) foi a parte que errei primeiro. Sem ela, tonalidade +1 multiplica o verde por 1,25, e um cinza fica mais claro em log2(1,179), cerca de 0,24 EV; tonalidade −1 o escurece em 0,28 EV. Ou seja, um controle que deveria mudar a cor também estava mudando a exposição. Com a divisão, temperatura e tonalidade recolorem um neutro sem mudar a luminância linear dele. Passei um cinza médio pela tonalidade +1: ele sai como (118,1; 131,0; 118,1), e o Y linear dele é igual ao da entrada até o último bit.
A bússola que comanda esses controles é a imagem de dois números limitados, não um termômetro. O marcador fica em x = cx + R·tanh(temperature), y = cy − R·tanh(tint), e arrastar inverte isso com atanh, limitado a 0,7615, logo abaixo de tanh(1), o fim da faixa do controle. Não há Kelvin nela. O Lightroom mostra Kelvin para arquivos RAW, mas uma escala relativa de −100 a +100 para outros arquivos; o Capture One mostra Kelvin e tonalidade. Um JPEG normalmente já foi balanceado uma vez, na câmera ou no conversor de RAW, e sem um perfil de câmera não há como converter a partir de Kelvin. Uma escala relativa é a honesta.
O conta-gotas neutro mantém o brilho do que você clicou
Clique num patch cinza, e o motor decodifica o ponto para (r, g, b) linear e define
g_c = Y / c, Y = 0.2126·r + 0.7152·g + 0.0722·b
Depois da multiplicação, os três canais são iguais a Y, então o ponto clicado vira um cinza da sua própria luminância. A versão que eu tinha fixado normalizava pela média geométrica (r·g·b)^(1/3). Isso mantém o produto dos ganhos em 1 e soa elegante, mas o ponto clicado ficava mais claro ou mais escuro: 0,30 EV para a amostra de teste (0,6; 0,45; 0,3), e cerca de 1,06 EV para uma amostra esverdeada como (0,3; 0,6; 0,3). Uma ferramenta de cor estava movendo a exposição em silêncio de novo. A versão pela luminância entrou em 22 de setembro, e os motores em JS e C++ concordam com ela bit a bit no conjunto de verificação.
// native/src/color.cpp
Vec3 neutralGains(Vec3 rgb) {
if (!finite3(rgb) || rgb[0] <= 0. || rgb[1] <= 0. || rgb[2] <= 0. || rgb[0] > 1. || rgb[1] > 1. || rgb[2] > 1.) throw std::range_error("neutralGains requires finite encoded sRGB channels in (0, 1]");
const Vec3 linear{srgbToLinear(rgb[0]), srgbToLinear(rgb[1]), srgbToLinear(rgb[2])};
// g_i = Y / c_i: the pick becomes a grey of its own luminance (see color-core.mjs neutralGains).
const double y = linearLuminance(linear);
Vec3 gains{y / linear[0], y / linear[1], y / linear[2]};
for (double gain : gains) if (!std::isfinite(gain) || gain > 8.) throw std::range_error("neutralGains pick needs a gain above grade.wb limit 8: it is too dark or too far from neutral");
return gains;
}
Um clique bem-sucedido também zera temperatura e tonalidade, então os ganhos na tela são o balanço inteiro. Um clique que exigiria um ganho acima de 8 é recusado com essa mensagem, e um clique com um canal zero recebe uma mensagem própria. Prefiro uma recusa a um ganho limitado, porque um ganho limitado produz um resultado que parece que a ferramenta funcionou quando não funcionou. O caso de teste da suíte é uma amostra fortemente tingida que precisa de ganhos (4,40; 1,10; 0,24); depois da multiplicação os canais são iguais, com dispersão exatamente zero.
O que você clica importa mais do que a fórmula. Uma única fotografia não consegue separar a cor da luz da cor do objeto: o sinal linear é I_c = k·∫ E(λ)·R(λ)·S_c(λ) dλ, e multiplicar a luz E por qualquer função espectral e dividir a reflectância R pela mesma função dá o mesmo pixel. Por isso o conta-gotas pertence a um cartão cinza ou a uma superfície que você sabe ser neutra, nunca à pele nem a uma camiseta "branca" de tingimento desconhecido. Pelo mesmo motivo, ainda não há balanço de brancos automático no motor. Todo método automático repousa sobre uma suposição, e a clássica suposição do "mundo cinza" transforma uma cena que é de fato verde em cinza; as verificações de pesquisa mantêm esse contraexemplo de propósito.
O que está implementado para neutros conhecidos é um painel de Chart Check: um patch neutro conhecido, seu RGB codificado e linear, os ganhos e o resíduo depois deles. Para o próprio patch, o resíduo é zero por construção, então a verificação útil são os outros neutros do quadro, lidos no parade RGB. Um solver para vários patches (a mediana das razões por patch) está escrito e não construído. Patches que discordam não entram numa média para formar um balanço "ideal" escondido; a discordância continua visível no parade.
Uma ordem que ainda não mudei
No motor atual, o LUT importado roda antes do balanço de brancos e da exposição. Para um LUT de look, isso está ao contrário: você quer normalizar o quadro primeiro e aplicar o look depois, senão o mesmo look cai de forma diferente em cada quadro de uma série. Ainda não a inverti, porque inverter silenciosamente mudaria todos os projetos salvos. Isso precisa de uma receita versionada, em que projetos antigos mantêm a ordem antiga e os novos recebem a nova. Até lá, o conta-gotas neutro lê o pixel depois da etapa de entrada, então seus ganhos pelo menos se referem ao que a multiplicação de fato recebe.
O que acontece depois de 1.0
O motor é SDR. Com exposição e balanço de brancos apenas, cada canal é codificado e limitado a [0, 1] logo depois da multiplicação; com edições Oklab ativas, o clamp vem depois, no máximo na hora de gravar o resultado. Duas coisas decorrem disso, e ambas são visíveis.
Primeiro, o detalhe cortado não volta. Baixar uma curva depois da exposição apenas escurece um 255 chapado; os valores acima dele foram descartados uma etapa antes, e a documentação da ferramenta de exposição diz isso.
Segundo, um corte por canal muda o matiz. Quando o canal vermelho chega a 1 e o verde e o azul continuam crescendo, a cor desliza em direção ao amarelo.

Os números vêm do motor. Um sRGB quente (204, 133, 87) tem matiz Oklab de 52,9°. Seu canal vermelho chega a 255 em +0,73 EV. Em +1 EV a saída é (255, 182, 121) e o matiz é 60,4°; em +2 EV é (255, 248, 166), matiz 103,9°. Um laranja virou um amarelo pálido.
Existe uma alternativa opt-in, a compressão suave de gamute. Em vez de cortar cada canal, ela move uma cor fora da faixa em direção ao cinza da mesma luminância Oklab, ao longo de um segmento reto no sRGB linear, até que ela caiba no cubo. Depois de uma edição criativa em Oklab numa cor que começou dentro do cubo, pelo menos os primeiros 80% da distância até a borda ficam exatos, e só o resto é comprimido com um ombro exponencial. Com exposição apenas, o ponto de referência é a própria cor exposta, já fora, então ali é uma projeção simples sobre a superfície. Na mesma cor quente, +1 EV dá (255, 192, 159) com matiz 48,5°: o matiz se move menos, mas a croma cai de 0,115 (o resultado cortado de forma rígida; a cor de origem tem 0,107) para 0,085, e a partir de cerca de +1,67 EV, onde a luminosidade Oklab chega a 1, o resultado é branco puro. Nenhuma das duas opções é recuperação de realces.
A primeira versão foi a clássica: manter luminosidade e matiz Oklab fixos e reduzir a croma ao longo desse raio. Ela falha perto do azul. No lado azul, um raio de L e matiz fixos pode sair da gamute e voltar a entrar, e um mapa que precisa ser contínuo não consegue atravessar esse vão. A pequena reentrada perto de 264° é uma medição minha, com as matrizes do motor, e não dele: o artigo sobre Oklab de Björn Ottosson diz que um ajuste inicial dobrava os azuis para dentro, e que ele restringiu o Oklab final para que isso não aconteça. O enquadramento em Oklab da opção suave, e seu aviso contra o corte rígido de RGB, vêm das notas sobre clipagem de gamute dele. O segmento em RGB linear em direção a um cinza de mesma luminância tem um único cruzamento porque o cubo RGB é convexo, então a continuidade vem de graça. Ela tem um custo que medi: numa rampa quente e clara em +0,8 EV, códigos de entrada de 8 bits vizinhos podem cair a 19 códigos de saída de distância um do outro. Isso é uma resposta íngreme, não um salto; o teste permite até 20 e falha em 21.
O Capture One documenta a recuperação de realces que pode usar os canais que não cortaram. Isso funciona sobre dados RAW com folga. Com entrada sRGB de 8 bits não há nada acima de 255 para recuperar, então prefiro mostrar o corte com honestidade a inventar detalhe.
Limites, e o que eu faria de novo
- SDR sRGB de 8 bits na janela principal, PNG de 16 bits apenas na janela de laboratório. Sem RAW, sem Kelvin, sem ICC.
- Balanço de brancos a partir de um único patch conhecido; vários patches e estimativa automática estão projetados, não construídos.
- O LUT importado ainda roda antes do balanço de brancos. A correção precisa de uma receita versionada.
O que eu faria desde o primeiro dia da próxima vez: dividir temperatura e tonalidade pela sua própria luminância, manter uma segunda implementação da matemática como oráculo e deixar o conta-gotas recusar uma amostra com um motivo, em vez de limitar seus ganhos.
Próxima parte: Níveis, histogramas e pontos de preto e branco por carta, em que o mesmo motor trabalha em valores codificados de propósito. O plugin de retoque em C++ no próprio dispositivo que eu entrego para o Photoshop está descrito em happyin.ai.