happyin.work / blog

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

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.

Quatro pontos de controle desenhados pela spline natural e pelo PCHIP: a spline cai abaixo de 0 nas sombras e sobe acima de 1 nos realces, e o clamp transforma os dois em áreas chapadas

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.

Seis cores sob a mesma curva em S: a curva RGB mestra eleva a croma Oklab em 15 a 31% e gira o matiz em até 4 graus; a curva de luma deixa o matiz inalterado

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.

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