Devo normalizar valores RGB por 255 ou por 256?
(30fps.net)- Na normalização de RGB, no caso comum de processar um arquivo de imagem desconhecido e salvá-lo novamente em 8 bits, a abordagem padrão de dividir por 255 é a mais adequada
- O método com 255 mapeia 0 para 0.0 e 255 para 1.0, facilitando lidar diretamente com preto e branco, além de corresponder ao modo de conversão UNORM-para-float das GPUs
- O método com 256 usa
(img + 0.5) / 256.0para posicionar cada valor no centro do intervalo, o que pode simplificar o tratamento de bordas em tarefas como dithering, mas 0 deixa de ser 0.0, fazendo a lógica de processamento ficar presa à entrada de 8 bits - No método com 255, os intervalos das extremidades têm metade da largura dos demais, então ao arredondar de volta para 8 bits a partir de números aleatórios uniformes em
[0, 1], 0 e 255 aparecem com metade da frequência dos outros valores, embora a conversão de ida e volta de imagens reais continue funcionando sem perdas - Em teoria, o método com 256 tem erro absoluto médio de
1 / 1024, menor que1 / 1020do método com 255, mas se uma imagem já quantizada no esquema 255 for lida com a escala errada, isso acaba introduzindo ainda mais erro
Definição do problema
Programas de processamento de imagem convertem imagens de 8 bits para ponto flutuante, fazem o processamento e depois salvam novamente em cores de 8 bits
As duas formas de conversão são as seguintes
# Padrão: dividir por 255
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)
# Alternativa: somar 0.5 e dividir por 256
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)
Em ambos os casos, os valores são limitados a 0~255 antes da conversão final
output_8bit = output.clip(0, 255).astype(np.uint8)
O método padrão mapeia o inteiro 0 para 0.0 e 255 para 1.0, e é igual ao método de conversão UNORM-para-float da GPU
A alternativa mapeia 0 para 0.5 / 256 = 0.001953125, então para detectar um pixel preto é preciso conhecer essa constante
Características do método padrão de dividir por 255
No método padrão, dentro do intervalo [0, 1], os intervalos dos valores nas extremidades têm, na prática, metade da largura dos demais
Se você gerar números aleatórios uniformes em [0, 1] e arredondar com trunc(result * 255 + 0.5), 0 e 255 aparecem com metade da frequência dos outros inteiros
Mas a imagem original em 8 bits retorna sem perdas na conversão de ida e volta uint8 → float → uint8
Além disso, mesmo que o resultado do processamento ultrapasse um pouco 0.0 ou 1.0, o clamp e o arredondamento ainda podem colocá-lo no intervalo inteiro correto
Por exemplo, se você subtrair 0.005 de uma cor em ponto flutuante, o preto no método padrão se torna negativo, mas o resultado final ainda continua sendo o inteiro 0
trunc(255 * (-0.005) + 0.5) = 0
Precisão em ponto flutuante e posicionamento no centro do intervalo
Alguns valores do método 255 não são representados exatamente
Por exemplo, 128 / 255.0 ≈ 0.501961, enquanto 128 / 256.0 = 0.5
Essa diferença é um erro de arredondamento no nível do bit menos significativo dentro da mantissa de 23 bits do ponto flutuante de 32 bits, com magnitude menor que 2^-23
Portanto, essa imprecisão está mais para uma questão estética do que para um problema técnico real
No método 256, cada valor em ponto flutuante fica exatamente no centro entre dois inteiros
Essa propriedade pode ser vista como um compromisso que usa o ponto médio entre dois inteiros consecutivos quando não se sabe exatamente qual era o valor originalmente quantizado
No texto de 2015 de Andrew Kesler, “Converting Color Depth”, argumenta-se que esse método exige menos cuidado com o tratamento de bordas ao adicionar ruído em dithering
Em contrapartida, no método padrão, os intervalos das extremidades exigem tratamento cuidadoso para manter a distribuição de ruído consistente
Perspectiva de quantização
As duas abordagens podem ser vistas como quantizadores escalares uniformes (uniform scalar quantizer)
A explicação sobre quantização na Wikipedia) divide principalmente os quantizadores uniformes para dados de entrada com sinal em mid-riser e mid-tread
O mid-tread tem nível de reconstrução no valor 0, enquanto o mid-riser tem limiar de classificação no valor 0
As fórmulas correspondem da seguinte forma
| Método | Codificação | Decodificação |
|---|---|---|
| mid-tread | k = trunc(x L + 0.5) |
y_k = k / L |
| mid-riser | k = trunc(x L) |
y_k = (k + 0.5) / L |
O método padrão é uma forma mid-tread com L=255, e a alternativa é uma forma mid-riser com L=256
O método padrão ganha praticidade de programação ao alinhar as extremidades em 0.0 e 1.0, em troca de uma disposição de intervalos que não é a ideal para entrada de 8 bits
Erro de reconstrução e processamento real de imagens
Se você estivesse projetando do zero um sistema que codifica um número real x ∈ [0, 1] em um inteiro de 8 bits e depois o reconstrói como número real, o método com 256 seria teoricamente mais preciso
No método padrão, a faixa representável vira [-0.5 / 255, 255.5 / 255], então o espaçamento entre intervalos fica mais amplo do que o estritamente necessário para [0, 1]
Segundo o cálculo do usuário do StackOverflow Peter Mudrievskij, o erro absoluto médio é 1 / 1020 ao dividir por 255 e 1 / 1024 ao dividir por 256
Mas, ao ler uma imagem RGB de 8 bits já armazenada e processá-la, a informação perdida no momento do salvamento não é restaurada
Se a imagem foi quantizada multiplicando por 255 e arredondando, dividi-la por 256 no carregamento não recupera precisão
Como a maior parte das imagens criadas por outras pessoas provavelmente foi quantizada pelo método padrão, lê-las com a fórmula alternativa significa, em teoria, usar um fator de escala incorreto
Na prática, como a cor não se comporta como uma medida absoluta, isso resulta em processar os dados numa faixa um pouco menor e com um pequeno deslocamento
Misturar a etapa de codificação de um quantizador com a etapa de decodificação de outro produz código quebrado
Conclusão
Se você vai processar uma imagem fornecida por terceiros, os valores RGB devem ser normalizados por 255
O fato de os valores em ponto flutuante não serem exatos, ou a sensação abstrata de que o erro de reconstrução é maior, não é motivo forte para escolher o método com 256
Se você controla tanto o salvamento quanto o carregamento da imagem, não precisa que 0 seja mapeado para 0, e não se importa que o código de processamento fique preso ao intervalo dinâmico de 8 bits, então pode dividir por 256 para buscar uma precisão teórica ligeiramente maior
1 comentários
Opiniões no Lobste.rs
Se isso não parecer intuitivo, dá para ver pelo caso degenerado de 2 bits. Quando os únicos valores inteiros possíveis são 0, 1, 2 e 3, se você calcular toda a conversão de inteiro→ponto flutuante, o resultado será 0.0, 0.33..., 0.66..., 1.0 para evitar o comportamento estranho em que preto/branco não são preto/branco ou em que os intervalos ficam claramente desiguais
Portanto, a conversão inversa acaba sendo multiplicar por 3, não por 4 (2^2)
A conversão inversa exige quantização (arredondamento), e esse é justamente o ponto central em que a simetria se quebra
Se você criar um gradiente uniforme de números reais no intervalo 0..=1 e quantizá-lo para 0, 1, 2, 3, verá que multiplicar por 3 produz um resultado desigual.
round()após ×3 super-representa 1 e 2, efloorouceilapós ×3 tratam 0 ou 3 como singularidades, fazendo o gradiente parecer usar apenas 3 das 4 coresA lógica de
/3e×3parece aceitável ao converter números exatos em ida e volta, mas os valores intermediários são muito afetados pela escolha do arredondamento, e isso passa a importar assim que você começa a processar os dadosAs proporções inteiras só ficam uniformes ao multiplicar por (4-ε) e aplicar floor, o que equivale a ×4,
floor()eclamp(). Parece um erro estranho de diferença de 1 ou de ε, mas intuitivamente é a solução com melhor aparênciaPara mim a resposta sempre foi “obviamente” [0.0..255.0], mas talvez isso não seja óbvio para todo mundo
O texto diz que os intervalos “extremos” têm só metade da capacidade dos outros, mas também não acho esse enquadramento correto
Se não existem valores fora de [0..1], o fato de parecer um intervalo mais estreito é um artefato de renderização. Ele só foi renderizado mais estreito porque você cortou os buckets sabendo que não existem valores fora do intervalo
Por outro lado, se existem valores fora de [0..1], esse intervalo é infinito. O texto reconhece o segundo caso, mas não o primeiro
No momento em que se aceita o primeiro, o comportamento correto parece claro, mas o simples fato de este texto existir também mostra objetivamente que não é uma questão “clara” :D
Se 0..<1 vai para o inteiro 0, e 254>..255.0 vai para o inteiro 255, então o 128 some do mapa. Você provavelmente quer que 127.5..128.5 vá para 128, mas então para onde essas metades deveriam ir?
Se você deslocar tudo um pouco para acertar o 128, então 0..0.99609375 será mapeado para o inteiro 0
round()Como essa forma parece bastante natural para as pessoas, provavelmente virou padrão por simplicidade
pngcrush. Ou quer dizer que há algo errado com o conteúdo da imagem?