Curvas: spline natural vs PCHIP, overshoot, luma e baking em LUT
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
Um editor de curvas parece uma ferramenta de desenho, mas por baixo é uma decisão só, com consequências visíveis: como desenhar uma linha suave passando pelos pontos que você colocou. Meu motor de cor oferece três respostas e usa duas delas em lugares diferentes. Esta parte explica o porquê, o que o overshoot faz com uma imagem, em que uma curva RGB mestra difere de uma curva de luma (com medições do próprio motor) e o que acontece com uma curva quando ela é gravada num LUT.
O que é uma curva neste motor
Uma curva tem de 2 a 16 pontos, com ambos os eixos em 0…1, e x estritamente crescente. As extremidades são mantidas; clicar adiciona um ponto, as setas movem o ponto selecionado 1/255 (10/255 com Shift), e Delete remove um ponto interno. No pipeline da parte 1, a curva mestra é aplicada primeiro a cada canal RGB codificado, depois as curvas de R, G e B, e a curva de luma mais tarde, na etapa de foto. Há três modos de interpolação: linear, smooth (PCHIP) e spline (cúbica natural). Um único modo serve a mestra, R, G e B juntas; a curva de luma é sempre PCHIP. Um projeto salvo antes de o campo de modo existir mantém o linear, porque é com ele que foi feito.
Os coeficientes são compilados uma vez por grade. No laço de pixels há uma busca de segmento em no máximo 15 intervalos e uma avaliação de Horner, sem alocação e sem validação:
// native/src/color.cpp
double sample(double input) const {
if (input <= x.front()) return y.front();
if (input >= x.back()) return y.back();
std::size_t index = 0;
while (input > x[index + 1]) ++index;
if (input == x[index + 1]) return y[index + 1];
const double t = (input - x[index]) / (x[index + 1] - x[index]);
if (mode == CurveMode::linear) return y[index] + (y[index + 1] - y[index]) * t;
const double value = ((d[index] * t + c[index]) * t + b[index]) * t + y[index];
return mode == CurveMode::spline ? clamp(value) : value;
}
A última linha é a história inteira deste artigo numa expressão: só a spline precisa de clamp.
A spline cúbica natural, e seu preço
O modo spline resolve as segundas derivadas M_i nos pontos. Com h_i = x_(i+1) − x_i e as inclinações secantes d_i, o sistema é tridiagonal:
h_(i−1)·M_(i−1) + 2(h_(i−1) + h_i)·M_i + h_i·M_(i+1) = 6(d_i − d_(i−1)), M_0 = M_(n−1) = 0
O motor resolve isso com o algoritmo de Thomas quando os pontos mudam e depois guarda coeficientes cúbicos por segmento em t local. Valor, primeira derivada e segunda derivada são contínuos em cada ponto, o que a torna a forma mais suave que você pode colocar através de um conjunto de pontos. Curvas RGB novas usam essa por padrão. Verifiquei contra a CubicSpline natural do SciPy como oráculo externo: a cópia em JavaScript concorda até 6,7e-16, e o motor em C++ concorda com a cópia em JavaScript até 3e-13, inclusive numa curva forte o bastante para cortar.
O preço é o overshoot. Uma spline natural não sabe que a saída precisa ficar dentro de 0…1, nem que uma curva que passa por pontos crescentes deveria continuar crescendo.

Estes são os quatro pontos que usei na verificação no navegador do modo spline: (0; 0), (0,36; 0,2), (0,52; 0,68), (1; 1). Antes do clamp, a spline chega a −0,037 e 1,041. Numa rampa densa de 10.001 entradas, ela fica abaixo de 0 para entradas até 0,235 e acima de 1 a partir de 0,714. Depois do clamp SDR isso vira um preto chapado nos cerca de 60 primeiros códigos de entrada e um branco chapado nos 73 do topo. Dentro dessas áreas chapadas a curva deixa de ser C², e também deixa de ser estritamente crescente: o detalhe de sombra abaixo do código 60 vira um só valor. Nessa curva, a spline e o PCHIP diferem em até 31 códigos de saída, na entrada 0,70. Isso não é uma diferença de estilo na aparência da linha; é outra fotografia.
PCHIP: sem novos extremos, uma derivada a menos
O modo smooth é o PCHIP, o interpolante cúbico de Hermite por partes com tangentes de Fritsch–Butland (Fritsch e Butland, 1984), que satisfazem as condições de monotonicidade de Fritsch e Carlson, 1980. Cada tangente interna é uma média harmônica ponderada das duas secantes vizinhas, e é zerada quando as secantes têm sinais diferentes ou uma delas é zero:
m_k = (w1 + w2) / (w1/d_(k−1) + w2/d_k), w1 = 2h_k + h_(k−1), w2 = h_k + 2h_(k−1)
m_k = 0 if d_(k−1)·d_k <= 0
As tangentes das extremidades usam uma estimativa unilateral de três pontos com limites de sinal e tamanho, a mesma regra que o PchipInterpolator do SciPy usa. O resultado passa por todos os pontos, tem primeira derivada contínua e nunca cria um novo extremo entre pontos vizinhos. Se os pontos sobem, a curva sobe. A segunda derivada pode saltar em um ponto, que é a derivada a menos mencionada no título; na prática, isso aparece como uma curva um pouco menos arredondada.
A suíte matemática amostra uma curva PCHIP monótona em 1.025 entradas e verifica que nenhum passo desce; o menor passo medido é 2,2e-6. Na curva da figura, o menor passo do PCHIP é 1,5e-6, enquanto a spline com clamp tem passos de exatamente zero. No editor, o modo PCHIP está rotulado como "sem overshoot".
Então por que manter os dois? Porque eles atendem pedidos diferentes. Para uma curva suave que passa por pontos bem dentro da faixa, a spline dá a forma mais macia e a diferença é pequena. Para uma curva em S forte perto do preto e do branco, o PCHIP mantém a gradação das sombras e dos realces que a spline descarta. O modo é um único interruptor, e fica salvo com o projeto.
Curva RGB mestra versus curva de luma
Uma curva RGB mestra aplica a mesma função a R, G e B codificados. Um cinza continua cinza. Uma cor não mantém as proporções dos seus canais, então uma curva de contraste também aumenta a saturação e desloca um pouco o matiz. A curva de luma responde a "mude o tom e deixe a cor em paz". Ela trabalha sobre luminância linear com pesos BT.709, Y = 0.2126R + 0.7152G + 0.0722B, e reconstrói o RGB de modo que a nova luminância seja exatamente f(Y):
// native/src/photo_color.cpp
std::array<double, 3> linear{srgbToLinear(levelled[0]), srgbToLinear(levelled[1]), srgbToLinear(levelled[2])};
// Luma curve: RGB' = RGB * (f(Y) - f(0)) / Y + f(0), so Y' = f(Y) exactly (see photo-color.mjs).
const double y = lumaOf(linear);
const double mappedY = luma.sample(y);
if (y > kLumaFloor) {
for (double& channel : linear) channel = channel * (mappedY - lumaBlack) / y + lumaBlack;
linear = clipToLuminance(linear, mappedY);
} else {
linear = {mappedY, mappedY, mappedY};
}
As duas linhas em volta do ganho são correções. A primeira versão escalava o RGB por f(Y)/Y. Com um preto elevado, f(0) > 0, esse ganho explode perto do preto: um azul com um código acima de zero virou 255. Tratar o levantamento como um offset neutro, somando f(0) aos três canais, mantém-no como levantamento. A segunda correção é para canais que caem fora de 0…1. Cortá-los um a um errava a luminância-alvo em até 0,137 e girava o matiz em até 13,6°. O motor agora usa a construção ClipColor da ISO 32000-1 §11.3.5.3, parte da especificação PDF para modos de mistura não separáveis: canais fora da faixa são puxados em direção ao cinza da mesma luminância, de modo que Y' = f(Y) vale mesmo onde um canal cortaria. A suíte matemática mede o erro de luminância da curva de luma abaixo de 1e-15.
Para ver o que isso traz, rodei uma mesma curva em S pelo motor nos dois modos. A curva mestra era uma S do PCHIP sobre valores codificados; a curva de luma foi construída para que os cinzas saíssem iguais, dentro de 0,25 de código.

Em seis cores de teste, a curva RGB mestra elevou a croma Oklab em 15 a 31% e girou o matiz em até 4,0°. A curva de luma alterou a croma entre −8% e +7% e girou o matiz em 0,0°. A croma ainda se move um pouco, porque a croma Oklab acompanha a luminosidade mesmo com cromaticidade constante. A Capture One documenta a mesma intenção para a sua Curva de luma: contraste sem aumento de saturação; a curva de ponto do Camera Raw da Adobe oferece curvas compostas e por canal. Nenhuma das duas publica uma fórmula, e a minha é própria.
Uma consequência para a interface: a curva de luma vive numa escala de luminância linear, então as mesmas coordenadas de ponto significam tons diferentes da curva mestra. O editor a mostra na própria escala e não desenha sobreposições de canais para ela.
Curvas no eixo perceptual: projetadas, não construídas
Uma ferramenta de curvas candidata está escrita, mas não está no painel nem no motor. Ela coloca três curvas em eixos Oklab, L2 = fL(L), a2 = fa(a), b2 = fb(b), com os centros travados, fa(0) = 0 e fb(0) = 0, de modo que qualquer neutro permaneça neutro; a trava só pode ser liberada por uma ação explícita com um aviso de que os neutros vão se deslocar. O resultado é misturado por um peso de máscara m e uma intensidade s, out = (1 − s·m)·source + s·m·OklabToRgb(L2, a2, b2), e duas verificações são mostradas antes de aplicar: uma trava de neutros quebrada e cores que saem da gamute. A compressão de gamute ali precisa ser um modo explícito, nunca um clamp silencioso.
Gravando curvas num LUT (baking)
Tudo que é global num grade (tom, cor, curvas, o LUT de entrada, a proteção de cores) depende só do RGB de entrada, então pode ser exportado como um 3D CUBE ou uma imagem Hald. Edições espaciais, como clareza ou dehaze, dependem de vizinhos e da posição, e o motor se recusa a gravá-las num LUT; o editor exporta a parte apenas de cor de um grade desse tipo somente depois que você confirma explicitamente que as edições espaciais ficarão de fora. O baking amostra a transformação completa em N³ pontos da malha, com N de 2 a 65, vermelho variando mais rápido.
Nos pontos da malha, o CUBE coincide com o motor: 3e-8 para um grade só de spline em 33³, que é precisão de float, e 1,7e-6 para um grade que usa todos os controles globais, dentro do limiar de 2e-6 da revisão. Entre os pontos é interpolação, e é honesto dizer quanto. Nesse grade completo, 4.096 amostras fora da malha deram um erro RMS de 0,010 (cerca de 2,6 códigos) em 33³ e 0,0054 em 65³. A pior amostra isolada foi muito pior: em 33³, uma entrada quase amarela saiu com o canal azul 0,53 alto demais, e em 65³ a pior amostra ainda ficou 0,27 fora. A revisão marcou a exportação como HOLD, o que significa que o CUBE é uma aproximação deste grade, não uma cópia; a imagem nativa ou o projeto é a saída fiel. O motor tem uma chamada de verificação que devolve exatamente esses dois números, máximo e RMS sobre 4.096 amostras, para qualquer grade que esteja sendo exportado. A parte 2 mostra o mesmo efeito num único conjunto de Levels, em que uma malha 65³ errou cerca de 23 códigos perto do preto.
Precisão
A transformação de cor roda em double e arredonda uma vez, na hora de gravar a saída de 8 ou 16 bits. As curvas são avaliadas analiticamente por pixel, não por meio de uma tabela 1D interna. A janela principal trabalha sobre o canvas sRGB de 8 bits do navegador, e uma curva acentuada sobre uma origem de 8 bits espalha 256 níveis de entrada por uma faixa maior, e é daí que vem o banding. A janela de laboratório lê PNG de 16 bits sem passá-lo pelo canvas, exporta PNG de 16 bits e pode aplicar um dither ordenado 8×8 quando precisa reduzir para 8 bits. O dither depende da posição do pixel, então nunca faz parte de um LUT. Nada disso restaura gradação que um JPEG nunca teve; apenas impede que a edição destrua mais do que já destruiu.
O arrasto da curva tinha um problema próprio, medido em tempo e não em códigos. Na primeira versão, um arrasto de 61 eventos desenhava a curva uma vez, 3,56 s depois do primeiro movimento. Agora o gráfico é redesenhado a cada quadro de animação, independentemente do processamento; o editor envia uma prévia por vez ao motor em C++ e guarda só os parâmetros mais recentes à espera; e o histograma da curva fica em cache por imagem de origem. O mesmo arrasto desenha 61 quadros de curva, o primeiro após 33,9 ms, e a primeira imagem processada após 267 ms. Um único Undo ainda desfaz o arrasto inteiro.
Limites
- A spline natural é o padrão para curvas RGB e pode cortar nas extremidades; o PCHIP é o modo para curvas fortes perto do preto e do branco.
- O luma é sempre PCHIP, sobre luminância linear, e não afirma paridade com a Capture One nem com a Adobe.
- Curvas no eixo perceptual são um projeto, não um recurso.
- Um CUBE gravado de um grade complexo é uma aproximação; verifique o erro máximo e o RMS antes de enviá-lo a qualquer lugar.
Próxima parte: matiz, saturação e luminosidade por cor. Se o lado nativo te interessa mais do que a cor, o plugin de retoque em C++ que roda no próprio dispositivo está em happyin.ai.