happyin.work / blog

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

Níveis: pontos de preto e branco, gama, canais e uma carta

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

Os Níveis (Levels) são a ferramenta tonal com menos mistério: um clamp, uma reta e uma potência. No meu estúdio de cor, ela trabalha de propósito sobre valores sRGB codificados, depois da multiplicação em luz linear da parte 1. A fórmula cabe em três linhas. Este artigo é sobre tudo o que existe em volta dela: a ordem dos conjuntos mestre e por canal, o que o histograma de fato mede, os solvers dos conta-gotas, os pontos de preto e branco tirados de uma carta de cor e um LUT que custou dezesseis códigos perto do preto.

A fórmula

Para um valor de canal x em 0…1, com preto de entrada b, branco de entrada w, gama γ e preto e branco de saída ob, ow:

u   = clamp((x − b) / (w − b), 0, 1)
out = ob + (ow − ob) · u^(1/γ)

b cai em ob, w cai em ow, e tudo fora da faixa de entrada é cortado. Com b = 0, w = 1, γ = 1, ob = 0, ow = 1, a função é a identidade. O painel mostra os pontos de entrada e saída como 0…255 e a gama como 0,2…5. O editor mantém pelo menos 1/255 entre o branco e o preto de entrada, e o próprio modelo recusa w − b < 1e-6. O par de saída não tem essa regra: preto de saída acima do branco de saída é uma inversão, e uma inversão é uma coisa legítima de pedir.

O controle cinza fica onde a curva dá o meio da faixa de saída:

m = b + (w − b) · 0.5^γ
γ = ln((m − b) / (w − b)) / ln 0.5

Assim, uma gama acima de 1 move o controle em direção ao preto e clareia os meios-tons; uma gama abaixo de 1 faz o contrário.

Curvas de transferência dos Níveis amostradas do motor: gama 0,5, 1 e 2 com preto de entrada 0,10 e branco 0,90; uma faixa de saída de 40 a 215 e sua inversão

A Adobe documenta os mesmos controles para os Níveis do Photoshop, e o Capture One vai além: sua página de Níveis dá um exemplo de remapeamento linear, entrada 0–220 para saída 0–244, e descreve o controle do meio como gama. Nenhuma das duas publica uma implementação, então não reivindico paridade de bits com nenhuma delas. Reivindico que a função acima é a que os dois motores do estúdio executam.

Por que valores codificados e não luz linear? Exposição e balanço de brancos modelam luz, então multiplicam o RGB linear. Os Níveis são um remapeamento tonal que as pessoas controlam olhando um histograma de valores de código, e um controle de gama em luz linear ficaria num lugar que ninguém espera. Também é por isso que o conta-gotas cinza dos Níveis e o seletor neutro do balanço de brancos são operações separadas: um dobra a gama de cada canal em valores codificados, o outro multiplica canais lineares.

Mestre primeiro, depois cada canal

Uma gradação tem um conjunto mestre e três conjuntos por canal. O conjunto mestre é guardado nos mesmos campos planos de sempre, então um projeto salvo antes de existirem conjuntos por canal fica idêntico byte a byte. Cada canal passa pelo mestre primeiro e depois pelo seu próprio conjunto:

// native/src/photo_color.cpp
double applyLevels(const LevelsSet& set, double x) {
  const double normalized = clamp((x - set.blackPoint) / (set.whitePoint - set.blackPoint));
  return set.outputBlack + (set.outputWhite - set.outputBlack) * std::pow(normalized, 1. / set.gamma);
}
// ... inside CompiledPhoto::apply
const LevelsSet master = masterLevels(grade);
for (int c = 0; c < 3; ++c) levelled[c] = applyLevels(grade.channelLevels[c], applyLevels(master, encoded[c]));
// Levels alone never needs Oklab: skipping the round trip keeps exact zeros and ones exact.
if (levelsOnly) return levelled;

y_c = f_c(f_master(x_c)) é uma decisão minha para este motor, não um fato documentado sobre a ordem interna do Photoshop.

A diferença entre os dois tipos de conjunto é o motivo de os dois existirem. A mesma função nos três canais mantém um cinza como cinza, mas não mantém a proporção R:G:B de uma cor, então a saturação se move junto com o contraste. Um conjunto por canal separa os canais, que é exatamente o que remove uma dominante, e exatamente o que cria uma quando usado mal.

O comentário nesse trecho esconde uma armadilha que só entendi com números. Dentro da etapa de foto, os Níveis rodam antes de qualquer trabalho em Oklab, mas a transformação global de cor que vinha antes deles mandava todo pixel para o Oklab e de volta, e esse ida e volta só é a identidade em aritmética exata. A gama dos Níveis então amplificava o resíduo perto do zero: com γ = 5, um canal que deveria ser 0 mas volta como 1e-6 sai dos Níveis como 0,063, dezesseis códigos de 8 bits. A primeira inversa do Oklab no motor usava as matrizes publicadas de dez dígitos e devolvia a identidade só com precisão de 1,18e-6. Agora é uma inversa exata (1,9e-14), uma gradação cujos controles globais não precisam de Oklab pula essa passagem inteiramente, e um caso com γ = 5 na suíte mostra diferença zero entre JS e C++.

A segunda armadilha foi mais silenciosa. Por um tempo, o motor em JavaScript aplicava os Níveis por canal, e o motor em C++, que renderiza a prévia e todas as exportações, descartava-os em silêncio. Uma auditoria independente encontrou diferenças de até 0,1875. Uma correção de conta-gotas cinza no pixel (158, 128, 97) saiu como (127,5; 127,5; 127,5) em JS e sem mudança em C++. Agora os dois aplicam os conjuntos, o parser nativo rejeita nomes de canal desconhecidos em vez de pulá-los, e a verificação de paridade sobre 257 amostras mostra 1,1e-16.

O que o histograma mede

O histograma acima dos controles de Níveis tem 256 bins. R, G e B são contados separadamente nas mesmas faixas, com cada pixel ponderado pelo seu alfa, e pixels totalmente transparentes são ignorados. Ele amostra no máximo 32.768 pixels por passo, desenha a raiz quadrada da frequência para que os picos não achatem todo o resto, e sombreia as faixas abaixo do ponto de preto e acima do ponto de branco.

O que importa é onde ele é medido: na entrada real dos Níveis, tirada do próprio pipeline de renderização. A primeira versão refazia um grade reduzido para o histograma e as leituras das curvas, e a cópia mantinha algumas etapas posteriores e deixava de fora partes dos Níveis. Uma auditoria pegou um amarelo puro (255, 255, 0) plotado no histograma "antes dos Níveis" em (229, 217, 102), e uma leitura do vermelho com o mixer de canais ativo que discordava da entrada real: 139,2 contra 158,1. Agora um único pipeline compilado relata as próprias entradas intermediárias, e as duas leituras usam esse pipeline. Nos 21 casos da suíte de regressão, o erro é 0.

Conta-gotas: três solvers que recusam

Os três conta-gotas resolvem parâmetros por canal a partir de um único pixel clicado. Por canal é o ponto: o mesmo ajuste nos três canais não consegue tornar iguais canais desiguais, então não consegue remover uma dominante. Para um alvo t, a posição que ele precisa dentro da faixa de entrada é u = ((t − ob)/(ow − ob))^γ, e então

black eyedropper:  b = (s − u·w) / (1 − u)
white eyedropper:  w = b + (s − b) / u
grey eyedropper:   γ_c = ln(u_c) / ln(v_c),  u_c = (s_c − b)/(w − b),  v_c = (t − ob)/(ow − ob)

Para a solução do cinza, u_c e v_c devem estar estritamente dentro de (0, 1). Num extremo não existe gama, então um pixel cortado ou esmagado é recusado com o motivo, nunca substituído por gama 1, o que pareceria que a ferramenta não fez nada. Uma gama fora de 0,2…5 também é recusada. O alvo cinza padrão é a média da própria amostra, então o pixel mantém aproximadamente o brilho e perde só a dominante.

Esses solvers são exatos até 6,7e-16 na auditoria. O status honesto: eles vivem em levels-picker.mjs e ainda não estão ligados ao painel. O painel edita o conjunto mestre, e conjuntos por canal chegam ao motor, por enquanto, só a partir de projetos salvos. O caminho da carta, mais abaixo, tem seu próprio registro de Níveis por canal na entrada da gradação.

Pontos de preto e branco a partir de uma carta

A primeira versão dos conta-gotas mandava o pixel clicado para preto puro e branco puro, o que bate com os padrões do Photoshop e está errado para um ColorChecker. Os valores sRGB publicados do Classic24, atribuídos à X-Rite, colocam o quadrado branco em 243 e o quadrado preto em 52. Mandar o quadrado branco para 255 joga fora tudo o que a cena guarda entre 243 e 255; mandar o quadrado preto para 0 esmaga 52 níveis de sombra. O próprio guia de exposição da X-Rite diz o mesmo em palavras simples: o quadrado branco deve ser bem claro, mas não branco puro, e o quadrado preto não deve parecer preto puro. O quadrado branco é um refletor difuso de cerca de 90%, então um brilho especular no quadro deve ser mais claro do que ele.

O Photoshop permite editar as cores-alvo dos seus conta-gotas, o que mostra que 0 e 255 são uma convenção, e não uma propriedade da ferramenta. Então os solvers recebem um alvo, e há uma predefinição de carta com os valores publicados (para cartas feitas depois de novembro de 2014; cartas mais antigas têm números diferentes).

Clicar primeiro no preto e depois no branco tem um segundo problema: o segundo clique move o branco de entrada, e todas as posições dentro da faixa se movem com ele, então o quadrado preto sai do seu alvo. Um clique livre não consegue evitar isso, porque cada clique conhece apenas a sua própria referência. Com uma carta, as duas referências são conhecidas ao mesmo tempo, então o par é resolvido em conjunto:

w − b = (s_w − s_b) / (u_w − u_b),   b = s_b − u_b · (w − b)

O caminho que está ligado de ponta a ponta começa num modelo UNet/VAE separado do meu projeto de carta de cor. Ele prevê como um Classic24 apareceria num ponto que você escolhe no quadro, o que dá 24 patches virtuais, em vez de uma carta física detectada. Um solver em Python então transforma a carta prevista em Níveis por canal:

# packages/checker/vendor/checker_levels.py, nested inside solve(); docstring omitted.
# black_target and white_target are the chart's published values for patches 24 and 19.
def ends(gamma: np.ndarray):
    low, high = black_target ** gamma, white_target ** gamma
    span = (white_sample - black_sample) / (high - low)
    return black_sample - low * span, span

A gama vem dos quatro cinzas entre as extremidades. Cada cinza pede a sua própria gama por canal, encontrada por 60 passos de bisseção dentro de 0,2…5, e vence a resposta mediana. Um ajuste por mínimos quadrados deixa um patch ruim arrastar tudo: com três cinzas pedindo gama 1 e um quarto esmagado para 0,02, o ajuste pedia 2,74. Um cinza sozinho também é frágil de outra forma: na primeira fotografia real que medi, o patch 20 sozinho era inalcançável em três posições de cinco, enquanto a rampa em volta dele era normal.

O solver permite pontos de entrada fora de 0…1, porque um ponto de preto abaixo de 0 é o que um levantamento parece pelo lado da entrada. Recusar esses pontos custou quatro posições de cinco nessa primeira fotografia. Um teste dos conta-gotas em JS também corrigiu uma afirmação minha: ele dizia que os alvos da carta não cortavam nada, e falhou, porque com luz quente o branco de entrada do canal verde caiu em 0,949. Realces podem ser mais claros do que um patch branco difuso, então a afirmação agora diz o que os alvos da carta realmente dão: o patch deixa de ser o teto. Os conta-gotas JS e este solver em Python vivem em repositórios diferentes, então um teste fixa fixtures com as quais os dois devem concordar.

O LUT que custou dezesseis códigos

O resultado da carta é guardado como um pequeno registro inputLevels e avaliado exatamente, por canal, na entrada do grade. Até 23 de setembro, ele era gravado num LUT de entrada 65³. Como os Níveis são separáveis, a amostragem trilinear e a tetraédrica se reduzem a interpolação linear entre nós a 1/64 de distância, e x^(1/γ) é mais íngreme justamente ao lado do ponto de preto. A auditoria mediu 13,8 códigos de erro ali, e a nova verificação encontrou 16,3 códigos nas suas amostras com gama 3. A avaliação exata leva isso a 1,1e-16.

Um conjunto de Níveis por canal com gama 3 avaliado exatamente e por um LUT 65³ gravado: o LUT erra cerca de 23 códigos perto do ponto de preto

Reproduzi a armadilha com o motor para este artigo: preto 0,10, branco 0,95, gama 3, gravado num CUBE 65³, reimportado e comparado numa rampa densa. Numa rampa de 2.049 pontos, o pior erro foi 21,3 códigos logo abaixo do ponto de preto, onde o resultado exato é 0 e a malha ainda interpola em direção ao próximo nó, e 10,7 logo acima dele; amostrado com fineza suficiente, o pico fica em cerca de 22,7 códigos dos dois lados do ponto de preto. Acima da entrada 0,16 o erro fica em cerca de um quarto de código (0,251 no máximo). Uma função separável por canal não pertence a uma malha 3D quando pode ser avaliada exatamente.

Níveis automáticos e cor automática: projetados, não construídos

Ainda não existe um botão Auto no motor. O projeto está escrito: histogramas da entrada inalterada da etapa e da região escolhidas, frações de corte visíveis em cada extremidade e pontos de preto e branco nesses quantis. O modo por canal recebe um par por canal e muda a cor; o modo composto recebe um único par e mantém o eixo neutro. Uma faixa mais estreita que um limiar significa "indefinido", não "identidade", e os parâmetros encontrados são salvos, então reabrir um projeto nunca recalcula algo silenciosamente. As frações precisam ser visíveis porque nem a referência está assentada: a página da Adobe sobre ajustes tonais rápidos menciona tanto 0,5% quanto 0,1% como padrões de clipagem em seções diferentes.

Limites

  • Os Níveis rodam em sRGB SDR codificado de 8 bits na janela principal. Sem prévia de clipagem sobre a imagem; apenas as extremidades sombreadas no histograma.
  • O painel edita o conjunto mestre. Os conta-gotas por canal existem como solvers testados, mas ainda não estão ligados a ele.
  • Os alvos da carta são valores publicados de uma geração de cartas, não uma leitura de espectrofotômetro da sua carta.
  • Sem níveis automáticos nem cor automática.

Próxima parte: Curvas: escolha da spline, overshoot, luma e baking em LUT. Meu trabalho de retoque no próprio dispositivo, um plugin C++ para Photoshop com modelos próprios, está em happyin.ai.

Colour tools, built from scratch

  1. Exposição e balanço de brancos: três multiplicadores em luz linear
  2. Níveis: pontos de preto e branco, gama, canais e uma carta
  3. Curvas: spline natural vs PCHIP, overshoot, luma e baking em LUT
  4. Matiz, saturação e luminosidade por faixa de cor, calculados em Oklab em vez de HSL
  5. O mixer de canais, uma matriz 3×3: o que preserva, onde corta e onde roda
  6. LUTs 3D: gravando um grade de cor em .cube e Hald, e o que se perde entre os nós

Mais artigos

Mais neste site

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