1 pontos por GN⁺ 7 시간 전 | 1 comentários | Compartilhar no WhatsApp
  • 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.0 para 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 que 1 / 1020 do 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

 
GN⁺ 7 시간 전
Opiniões no Lobste.rs
  • Parece bagunçado, mas está certo: o valor correto é 255
    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 primeira parte está certa, mas daí não se segue que “a conversão inversa deve multiplicar por 3, não por 4”
      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, e floor ou ceil após ×3 tratam 0 ou 3 como singularidades, fazendo o gradiente parecer usar apenas 3 das 4 cores
      A lógica de /3 e ×3 parece 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 dados
      As proporções inteiras só ficam uniformes ao multiplicar por (4-ε) e aplicar floor, o que equivale a ×4, floor() e clamp(). Parece um erro estranho de diferença de 1 ou de ε, mas intuitivamente é a solução com melhor aparência
  • O título me confundiu bastante. Não sei se foi de propósito, mas no fim das contas parece ser algo como “0..1 corresponde a [0..255.0] ou a [0.5..255.5]?”
    Para 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…255.0 é mesmo tão óbvio, então qual faixa de valores de ponto flutuante deveria voltar para o inteiro 0, e qual deveria voltar para o inteiro 255?
      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
  • A abordagem padrão também parece ter surgido porque as pessoas naturalmente acabam chamando round()
    Como essa forma parece bastante natural para as pessoas, provavelmente virou padrão por simplicidade
  • Fico me perguntando se a abordagem oposta ao que se queria obter com 256 também teria utilidade. Ou seja, mandar 0.0 para 0, 1.0 para 255 e mapear os outros valores de ponto flutuante para 1 até 254
    uint8_t output = 0.0f >= result  
                     ? 0  
                     : 1.0f <= result  
                     ? 255  
                     : 1 + 253*result;  
    
    Seria bom se, mesmo durante o processamento, preto continuasse preto e branco continuasse branco
    • Assim, 0 e 255 acabam ficando com uma fatia maior do intervalo unitário do que os outros números. Aproximadamente 0,8%, ou seja, 255/253
  • A primeira imagem aparece quebrada no meu ambiente
    • Autor do texto aqui. Você quer dizer que o arquivo de imagem está corrompido? Eu o comprimi com pngcrush. Ou quer dizer que há algo errado com o conteúdo da imagem?