Matiz, saturação e luminosidade por faixa de cor, calculados em Oklab em vez de HSL
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
As curvas mudam todas as cores que compartilham um mesmo tom. As ferramentas desta parte mudam só algumas cores: empurrar os verdes para o amarelo, escurecer o céu, deixar a pele em paz. Editores de foto geralmente chamam essa família de HSL. No meu estúdio de cor, nada disso roda em HSL. Abaixo está como uma faixa de cor é selecionada, por que a edição acontece em Oklab, como a luminosidade se move sem arrastar o matiz junto, como pele e cinzas são protegidos e dois bugs que a primeira versão da matemática tinha.
De onde vem o código
O motor é o LUT Atelier, um editor anterior meu do repositório privado do hub, fixado no estúdio no commit fd96f16 com um SHA-256 para cada um dos seus 87 arquivos (um deles é o json.hpp do nlohmann vendorizado); a parte 1 conta essa história. A matemática de cor é C++20 sobre a biblioteca padrão. Quando um produto público me deu uma ideia de interface, eu o cito e não reivindico o algoritmo dele.
Por que não HSL
HSL e HSV servem bem para um seletor de cor e mal para edição. A luminosidade do HSL é aritmética sobre canais codificados, (max + min) / 2, então vermelho puro, amarelo, verde, ciano, azul e magenta têm todos luminosidade HSL 50%. Em Oklab, a luminosidade deles vai de 0,45 para o azul até 0,97 para o amarelo. Um controle que promete "luminosidade +10" em HSL faz coisas visivelmente diferentes em matizes diferentes, e uma rotação de matiz com luminosidade HSL constante transforma um amarelo claro em um azul escuro.

O motor contém uma função rgbToHsl. O único módulo que a importa é código de visualização, e nenhuma edição a chama. Toda ferramenta de faixa trabalha em Oklab, o espaço de Björn Ottosson: L é a luminosidade, e (a, b) dão a croma C = √(a² + b²) e o matiz h = atan2(b, a). Ottosson lista exatamente essa tarefa entre seus objetivos, aumentar a saturação enquanto o matiz e a luminosidade percebidos permanecem estáveis, e aponta que o CIELAB prevê mal os matizes de azul. O azul é onde uma edição de matiz costuma mostrar suas costuras.
Uma propriedade que uso diretamente. Oklab é uma matriz, uma raiz cúbica e uma segunda matriz, então multiplicar o RGB linear por k multiplica L, a e b pela raiz cúbica de k. A razão C/L e o matiz não mudam com a exposição. Verifiquei isso numa cor quente com k = 2: cada coordenada foi multiplicada por 1,2599, C/L ficou em 0,1531 e o matiz em 53,42°. É por isso que o peso da pele, mais adiante, usa C/L e não C.
Seis setores de matiz com borda suave
A ferramenta de faixas tem seis controles para luminosidade e seis para matiz. Seus centros são os ângulos Oklab 0°, 60° e assim por diante até 300°. São ângulos, não nomes de cores: o vermelho sRGB puro fica em 29° no Oklab, o amarelo em 110°, o azul em 264°. Então a interface desenha uma faixa com os matizes reais sob cada controle, em vez de chamar um deles de "vermelho".
Cada setor tem um peso de cosseno levantado que cai até zero a 60° do centro:
w_i = 0.5 · (1 + cos(π · d_i / 60)) para d_i ≤ 60°, senão 0
onde d_i é a distância circular entre o matiz do pixel e o centro. Dois pesos vizinhos sempre somam 1, então uma cor entre dois setores recebe exatamente uma edição completa, dividida entre eles. O corte em 60° importa: cos(π·d/60) se repete a cada 120°, e sem o corte o setor de 0° também atuaria com peso total em 120° e 240°.

Perto do cinza, o ângulo do matiz é ruído, então a edição também é multiplicada por um portão de croma q(C) = smoothstep(0.015, 0.05, C). Um cinza com uma leve tonalidade acidental não é clareado como se fosse laranja. O laço na etapa criativa de color.cpp:
if (hasBands) {
const double chromaWeight = smoothstep(.015, .05, chroma);
double deltaHue = 0.;
for (int i = 0; i < 6; ++i) {
const double distance = hueDistance(hue, i * 60.);
if (distance > 60.) continue;
const double weight = .5 * (1. + std::cos(kPi * distance / 60.)) * chromaWeight * amount;
out[0] += grade.bands[i] * weight;
deltaHue += grade.hueBands[i] * weight;
}
if (deltaHue != 0.) {
const double angle = deltaHue * kPi / 180., cosine = std::cos(angle), sine = std::sin(angle);
const double a = out[1], b = out[2];
out[1] = a * cosine - b * sine; out[2] = a * sine + b * cosine;
}
}
Duas decisões aparecem aqui. hue e chroma vêm da cor-base, antes de qualquer outra edição criativa, então uma rotação de matiz anterior não pode mover um pixel para sob outro controle. E os deslocamentos de matiz dos seis setores são somados primeiro e aplicados como uma única rotação, o que deixa L e C intocados nesta etapa.
Luminosidade sem deslocamento de matiz
Um controle de luminosidade soma até ±0,2 ao L do Oklab e deixa a e b quietos. Um controle de matiz (±30°) gira (a, b) e deixa L quieto. Os controles globais de matiz e saturação seguem a mesma regra: a saturação escala a distância ao eixo neutro, e o matiz gira o ângulo. A verificação matemática (npm run check:math) mede isso: depois de uma edição global de matiz e croma, a variação de L do Oklab é 0 e o erro de croma é 2,7 × 10⁻¹⁵.
Isso vale na etapa criativa, e não afirmo mais do que isso. Depois dela vêm a compressão suave de gamute, o contraste, as curvas, a etapa de foto e o mixer de canais. Quando a compressão suave está ligada (o Grid Lab a ativa para grades novos), ela move uma cor fora da gamute em direção ao cinza do mesmo L no RGB linear, porque o raio de Oklab com L e matiz fixos é inseguro perto do azul; a parte 1 explica por quê. Pelo menos os primeiros 80% da distância do cinza até a borda da gamute permanecem exatos. Depois desse joelho, a compressão desloca um pouco o L e o matiz, e a documentação diz isso em vez de fingir que o invariante sobrevive.
Protegendo pele e cinzas
Há três proteções, e elas não são igualmente fortes.
Os controles suaves enfraquecem apenas a etapa criativa. Seus pesos são:
neutral = 1 − smoothstep(0.015, 0.12, C)
skin = exp(−(d(h, 35°) / 28°)²) · smoothstep(0.015, 0.06, C/L) · smoothstep(0.005, 0.025, L)
e a edição criativa é escalada por 1 − max(neutralStrength · neutral, skinStrength · skin). A versão anterior fazia o portão da pele com um limiar em L = 0,35, o que deixava a pele escura desprotegida sem alarde. Trocar para C/L eliminou o limiar; só a zona logo ao lado do preto, onde o matiz não é confiável, se apaga. Projetos salvos com proteção de pele agora protegem cores escuras com mais força, e a mudança foi feita de propósito. Esses números são heurísticas de engenharia minhas, não um modelo de pele publicado e não um classificador. Um sofá marrom quente cai dentro do lóbulo, e pele sob luz colorida pode cair fora dele. O teste cobre a matemática em cores de amostra, não o reconhecimento em um conjunto de retratos.
As travas de matiz são mais fortes. Cada uma tem um centro, uma meia-largura de proteção total, uma borda suave (feather) e uma croma mínima, até doze delas. As predefinições do Grid Lab são pele em 50° ±25° com borda de 15°, verdes em 145° e céu em 250°; o conta-gotas cria uma trava estreita de ±12° com borda de 8°. O peso é tirado do pixel original, e a mistura de volta acontece uma vez, no fim:
out = original + (processed − original) · strength · (1 − maxProtection)
Assim, uma cor travada volta ao seu RGB de origem exato, não importa o que o LUT, o balanço de brancos ou as curvas tenham feito, e o LUT importado não consegue mudar quais pixels contam como protegidos.
O primeiro bug morava aqui. Convertido de sRGB para Oklab, um cinza puro saía com uma croma minúscula, 3,7 × 10⁻⁸ no branco e menos nos cinzas mais escuros, e um matiz de 89,9°. Qualquer trava com croma mínima 0 cuja faixa cobrisse 90° protegia todos os cinzas do quadro. A correção pega o limiar do CSS Color 4, que trata o matiz de OkLCh como indefinido (powerless, no termo da especificação) quando C ≤ 0,000004. Abaixo disso, um pixel não tem matiz, e uma trava de matiz não pode reivindicá-lo. A verificação agora converte 256 cinzas sRGB: a maior croma é 8,5 × 10⁻¹¹ e todos os pesos de trava são 0.
A corda que atravessou o cinza
Tanto a proteção quanto as seleções com borda suave aplicam uma edição "parcialmente". A primeira versão fez o óbvio: calcular o (a, b) totalmente girado e depois misturá-lo com o (a, b) original pelo peso. Essa mistura percorre uma corda do círculo de matiz, e não o arco. Com peso 0,5, uma rotação de 180° cai exatamente no cinza, e uma rotação de 90° perde 29% da sua croma, porque o comprimento da corda no meio do caminho é |cos(θ/2)|.

Cinco revisões independentes e somente leitura do motor, feitas em 22 de setembro, encontraram isso duas vezes: na ponderação de proteção do matiz e da saturação globais, e na borda com feather do Selective Color. Ambas agora escalam a própria edição em forma polar, ângulo A·θ e fator de croma 1 + A·(s − 1). A versão do Selective Color de photo_color.cpp:
// The feather scales the edit in polar form; a Cartesian mix cut across the hue circle.
const double newChroma = std::hypot(lab[1], lab[2]) * (1. + membership * selection.saturation);
const double angle = std::atan2(lab[2], lab[1]) + membership * selection.hue * kPi / 180.;
lab = {lab[0] + membership * selection.lightness, newChroma * std::cos(angle), newChroma * std::sin(angle)};
A verificação afirma que, com pertencimento 0,5, o erro de croma é 3,9 × 10⁻¹⁶ e o matiz gira exatamente a metade do pedido. As implementações em JavaScript e em C++ são comparadas com as mesmas entradas: a proteção polar concorda até 6,1 × 10⁻¹⁵, e a etapa de foto com o Selective Color polar até 1,1 × 10⁻¹⁴.
Escolher uma cor em vez de um setor
Os setores são grosseiros de propósito. Para "este verde, e não todos os verdes" há duas ferramentas com amostragem.
O Selective Color aceita até oito amostras. Cada uma tem um raio em Oklab que conta L além de a e b, pertencimento total dentro de um raio interno definido por um controle de feather (metade do raio por padrão), e um desvanecimento smoothstep até zero no raio. O pertencimento é calculado uma vez, a partir da cor de origem intocada, então uma edição anterior não consegue mudar quais pixels pertencem. A edição é L + m·ΔL, C vezes 1 + m·Δs, h mais m·Δh, com um puxão opcional em direção a uma cor-alvo. Uma cor protegida é uma amostra parecida sem edição: dentro do raio interno, o RGB original volta exatamente.
Os pins de cor no grid A/B vão além: até 24 âncoras no plano a/b, cada uma movida ou travada. O mock-up de design interativo das páginas do estúdio acrescentou um núcleo compacto por âncora e somou os resultados. Com duas âncoras a meio raio uma da outra, A pedindo Δa = 0,10 e B pedindo para ficar, B se move 0,10 × φ(0,5) = 0,019. O motor resolve o sistema de núcleos: K c = d com K_ij = φ(‖p_i − p_j‖ / R) e o núcleo Wendland C² φ(t) = (1 − t)⁴ (4t + 1). O campo então passa exatamente por todos os deslocamentos pedidos, e uma trava é um deslocamento zero. A solução é O(n³) para n ≤ 24 e roda uma vez por edição, não por pixel. Âncoras mais próximas que 0,001 R, ou um sistema com um pivô abaixo de 10⁻⁵, são recusadas com um erro em vez de resolvidas mal.
Limites
- Tudo aqui seleciona no espaço de cor, não na imagem. Uma parede bege e uma bochecha bege recebem o mesmo tratamento. O mesmo fato torna isso exportável: toda ferramenta desta parte é uma função apenas do RGB de entrada e grava num
.cube, que é a parte 6. - Não há máscara semântica de pele, nem um painel HSL de oito faixas com as fronteiras de faixa de um editor comercial. O Color Mixer e o Point Color do Lightroom Classic me mostraram o quanto é útil exibir a faixa afetada ao lado do controle. Peguei essa ideia para a interface, não uma fórmula.
- O caminho é sRGB SDR, com Oklab calculado a partir do sRGB linear em D65. Log, HDR e ICC arbitrário não são suportados.
- Um campo de pins fortemente movido ainda pode fundir duas cores numa só. O limitador do Grid Lab certifica o campo contra saturação e correspondência, mas ainda não contra os pesos de proteção; esse caso é um ticket aberto, não um problema resolvido.
A próxima parte é o mixer de canais: uma matriz 3×3 sobre canais RGB, onde nada dessa maquinaria perceptual se aplica e a matemática fica mais simples e mais bruta.