- Ao converter cores inteiras de 8 bits para ponto flutuante, há diferença entre o método padrão de dividir por 255 e o método alternativo de dividir por 256 após um viés de 0,5
- O método com 255 mapeia o inteiro 0 para 0.0 e 255 para 1.0, facilitando lidar diretamente com preto e branco, além de coincidir com a conversão UNORM-para-float da GPU
- O método com 256 usa
(img + 0.5) / 256.0para colocar 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, então a lógica de processamento fica presa à entrada de 8 bits - No método com 255, os intervalos das extremidades têm metade da largura, então ao arredondar de volta para 8 bits números aleatórios uniformes em
[0, 1], os valores 0 e 255 aparecem com metade da frequência dos demais, mas a conversão de ida e volta de imagens reais continua funcionando sem perdas - Se você estiver processando imagens de terceiros, a resposta correta é normalizar por 255; o método com 256 só vale a pena se você controlar tanto o salvamento quanto o carregamento
Definição do problema
- Em um programa que recebe uma imagem, converte para ponto flutuante, processa e depois salva novamente como cor de 8 bits, a questão é como fazer a conversão entre inteiro e ponto flutuante
- Existem duas abordagens
- Método padrão (divide por 255):
pixels = img / 255.0→ processamento →output = np.trunc(result * 255 + 0.5) - Método alternativo (divide por 256):
pixels = (img + 0.5) / 256.0→ processamento →output = np.trunc(result * 256) - Em ambos os casos, o valor é limitado a 0~255 antes da conversão final:
output.clip(0, 255).astype(np.uint8)
- Método padrão (divide por 255):
- O método padrão mapeia o inteiro 0 para 0.0 e 255 para 1.0, igual à conversão UNORM-para-float da GPU
- O método alternativo adiciona um viés de 0,5, fazendo com que o inteiro 0 seja mapeado para
0.5/256 = 0.001953125- Por causa disso, sem conhecer essa constante, não é possível detectar pixels pretos
- Mesmo usando cálculo em ponto flutuante, a lógica continua presa à entrada de 8 bits
- No método padrão, sempre é possível assumir preto como 0.0
Objeções ao 255.0
- Quando o método padrão é desenhado numa reta numérica, ele parece um pouco estranho
-
Existem bins menores nas duas extremidades
- Os bins das extremidades da fórmula padrão se projetam para fora do intervalo [0,1], em uma forma "esticada"
- Ao converter de volta de ponto flutuante para inteiro, a largura dos bins das extremidades é apenas metade da dos demais
- Isso torna "mais difícil" para o algoritmo produzir valores extremos
- Se você gerar ruído uniforme em [0,1] e arredondar com a fórmula padrão, os valores 0 e 255 ocorrem com metade da frequência dos outros inteiros
- Um histograma de 1 milhão de números aleatórios uniformes confirma que os bins de 0 e 255 têm metade da altura dos demais
- Ainda assim, é difícil imaginar uma situação prática em que esse viés de evitar extremos realmente seja um problema
- A imagem original continua fazendo ida e volta sem perda (
uint8 → float → uint8) - Resultados um pouco abaixo de 0.0 ou acima de 1.0 ainda são arredondados para o bin correto, igualando a distribuição de saída
- Exemplo: se o processamento subtrai 0.005 da cor, no método padrão o preto cai abaixo de 0, enquanto no método alternativo permanece positivo, mas ambos acabam produzindo o inteiro 0 no final
- A imagem original continua fazendo ida e volta sem perda (
-
Imprecisão
- Os valores em ponto flutuante do método padrão não são exatos; por exemplo,
128/255.0 ≈ 0.501961, enquanto128/256.0 = 0.5 - O erro de arredondamento faz a distância entre valores em ponto flutuante variar ligeiramente, mas o erro é tão pequeno que não causa problema prático
- Ponto flutuante de 32 bits tem mantissa de 23 bits, e o erro fica no nível do bit menos significativo, abaixo de
2⁻²³ - Um erro relativo de 0,00001% é irrelevante até em processamento de imagem sofisticado; a imprecisão é um problema estético, não técnico
- Ponto flutuante de 32 bits tem mantissa de 23 bits, e o erro fica no nível do bit menos significativo, abaixo de
- Os valores em ponto flutuante do método padrão não são exatos; por exemplo,
-
Valores que não pertencem ao intervalo inteiro
- O método alternativo coloca cada valor em ponto flutuante exatamente no meio entre dois inteiros
- Como o valor quantizado original não pode ser conhecido, o ponto médio entre dois inteiros consecutivos é um bom compromisso como estimativa
- Há quem diga que isso facilita o dithering (post de blog de Andrew Kesler, de 2015, "Converting Color Depth")
- Dá para adicionar ruído sem se preocupar com casos de borda
- Já os valores extremos estranhos da fórmula padrão exigem cuidado extra para manter a consistência da distribuição do ruído
- O método alternativo coloca cada valor em ponto flutuante exatamente no meio entre dois inteiros
Dois tipos de quantizador
- As duas abordagens podem ser vistas como dois tipos de quantizador escalar uniforme (uniform scalar quantizer)
- Segundo o artigo da Wikipedia sobre quantização, quantizadores uniformes para dados de entrada com sinal são classificados em dois tipos
- mid-tread: mapeia 0 para o nível de reconstrução de valor 0 (equivalente ao patamar da escada)
- mid-riser: mapeia 0 para o limiar de classificação do valor 0 (equivalente ao espelho do degrau)
- A Wikipedia cita como fonte o artigo de 1977 de Allen Gresho, "Quantization"
- Fórmulas do quantizador (L é o número de níveis de saída, por exemplo 256)
- Quantizador em escada mid-tread: codificação
k = trunc(xL + 0.5), decodificaçãoyₖ = k/L - Quantizador em escada mid-riser: codificação
k = trunc(xL), decodificaçãoyₖ = (k+0.5)/L
- Quantizador em escada mid-tread: codificação
- Aplicando aos dois métodos
- Fórmula padrão = mid-tread (L=255)
- Fórmula alternativa = mid-riser (L=256)
- O método padrão usa mid-tread para entrada sem sinal com código L=255, uma combinação que não é ideal para entradas de 8 bits
- Essa escolha foi feita pela conveniência de programação de mapear as extremidades para 0.0 e 1.0
-
Erro de quantização maior, mas na prática não
- Se o sistema fosse codificar um número real
x∈\[0,1\]distribuído uniformemente em um inteiro de 8 bits e depois reconstruí-lo como real, a fórmula padrão desperdiçaria faixa dinâmica- O intervalo representável no método padrão é
[-0.5/255, 255.5/255], mais amplo do que o necessário para entradas em [0,1], aumentando o erro de reconstrução - Segundo o cálculo do usuário Peter Mudrievskij no StackOverflow, o erro absoluto médio é
1/1020com divisor 255 e1/1024com divisor 256, portanto dividir por 256 é teoricamente um pouco mais preciso
- O intervalo representável no método padrão é
- Mas, na prática, não é isso que está sendo feito
- A premissa é carregar uma imagem RGB de 8 bits, processá-la e salvá-la de novo; ao salvar, você não controla necessariamente o método de quantização, e a informação perdida desaparece para sempre
- Se a imagem foi salva multiplicando e arredondando com a fórmula padrão, carregá-la dividindo por 256 não recupera precisão nenhuma
- A alegação de menor erro de reconstrução só faz sentido quando você controla tanto o salvamento quanto o carregamento
- Carregar a imagem de outra pessoa com a fórmula alternativa pode, na verdade, introduzir mais erro
- É muito provável que ela tenha sido quantizada com a fórmula padrão, então decodificar em outra escala é teoricamente incorreto
- Na prática, como cor não é uma medição absoluta, isso só significa processar em uma faixa um pouco menor com um pequeno deslocamento
- Não se deve misturar etapas de codificação e decodificação dos dois quantizadores; isso gera código quebrado com facilidade
- Se o sistema fosse codificar um número real
Conclusão
- Se você estiver processando imagens fornecidas por outras pessoas, deve normalizar os valores RGB por 255
- Valores em ponto flutuante imprecisos ou preocupações abstratas com erro de reconstrução não são bons motivos para escolher a alternativa
- Se você controla totalmente o salvamento e o carregamento da imagem, não precisa mapear 0 para 0 e não se importa em vincular o código de processamento à faixa dinâmica de 8 bits, então dividir por 256 pode render um pequeno ganho de precisão
- Só tenha em mente que um colega pode carregar a imagem com a fórmula padrão e arruinar o plano
Outras visões
- O texto de Jonathan Blow de 2002 discute os quantizadores mid-riser e mid-tread sem usar esses nomes, e é a fonte da ideia dos diagramas
- O post de blog de Andrew Kesler, de 2015 defende a fórmula alternativa
- Mas a comparação é feita contra a fórmula padrão sem arredondamento, o que invalida grande parte da análise
2 comentários
Comentários do Hacker News
O que exatamente os valores de cor significam em geral não muda muita coisa com 8 bits por componente. O erro gerado pela diferença entre usar 255 ou 256 no denominador é muito pequeno, e para perceber a diferença seria preciso ter boa sensibilidade a cores e olhar a tela bem de perto, além de que monitores e telas de celular normalmente nem estão calibrados
Mas se você estiver gerando sinal VGA com um microcontrolador e só tiver 8 pinos de saída de cor (3 para vermelho, 3 para verde, 2 para azul), isso vira um problema bem chato. Nesse caso, o valor da cor é exatamente o nível de tensão de 0V~0.7V que precisa ser enviado ao monitor VGA
O canal azul é mapeado como 0→0V, 1→0.23V, 2→0.47V, 3→0.7V, enquanto vermelho/verde são mapeados como 0→0V, 1→0.1V, …, 7→0.7V. Tirando as extremidades, as tensões do azul não coincidem em nada com as de vermelho/verde, então não dá para ver um cinza puro, e até a cor mais próxima acaba com um leve tom azulado ou amarelado, dependendo da direção da diferença
Além disso, quase todos os gradientes que misturam azul com outros canais também parecem desalinhados. Por exemplo, as cores mais próximas na linha que vai do vermelho puro ao branco puro parecem um pouco alaranjadas ou arroxeadas
O código para saída VGA colorida de 8 bits no Raspberry Pi Pico 2 com framebuffer duplo de 320x240 está aqui: https://github.com/moefh/pico-vga-8bit-demo
Isso faz a diferença entre valores pequenos e grandes ficar muito mais acentuada: 2^2.2 = 4.595, 255^2.2 = 196,964.699
Se mudar a 30Hz, parece difícil para uma pessoa distinguir entre um leve tom azulado e um leve tom amarelado
Um argumento a favor de 255 é olhar para o caso extremo de uma imagem em preto e branco. Com um único bit, 0 é preto e 1 é branco
Parece bastante claro que 0 deve mapear para 0.0 e 1 deve mapear para 1.0. Afinal, é preto e branco, não cinza claro (0.25) e cinza escuro (0.75). Ou seja, uma imagem em preto e branco é normalizada por 1, não por 2
Com 2 bits, normalmente temos 0=preto, 1=cinza claro, 2=cinza escuro, 3=branco, então é natural mapear para 0.0, 0.33, 0.66, 1.0. Preto deve ser preto, branco deve ser branco, e os intervalos devem ser iguais, então a normalização é por 3
Levando essa lógica até 8 bits, chegamos à normalização por 255. Mesmo que a diferença fique muito pequena com 8 bits, preto ainda deve ser 0.0 e branco deve ser 1.0
A outra abordagem, usar normalização por 256 com 8 bits, faz a faixa de saída variar conforme o número de bits. Com 1 bit seria [0.25, 0.75], com 2 bits seria [0.125, 0.875], e assim por diante. Em geral, o que se quer ao aumentar o número de bits é mais nuance, não uma mudança no contraste
Foi um texto que realmente deu o que pensar e me fez reexaminar algumas premissas pessoais
Vendo pela perspectiva da engenharia elétrica, é difícil concordar com a apresentação de “dois tipos de quantizadores” no texto. Matematicamente ela é rigorosa, mas não é uma explicação baseada em sistemas reais
Em ADCs sempre existe, de forma inerente, uma incerteza de quantização de ±1/2 LSB. A característica de transferência é sempre uma amostragem mid-tread, ou pelo menos nunca vi um contraexemplo. Isso vale tanto para ADCs bipolares quanto unipolares
O menor código corresponde à tensão negativa de referência e o maior código à tensão positiva de referência. O gráfico da característica de transferência mostra que, como no texto, as faixas máxima e mínima têm efetivamente largura de 1/2 LSB
Em sistemas unipolares, não é possível representar exatamente as tensões intermediárias; em outras palavras, surge o problema do cinza. Em sistemas bipolares, 0V é o valor N/2 do mid-tread, mas isso não significa que existam “256 intervalos”
Por isso vou continuar usando (VREF+ - VREF-) * k / (2^N - 1). Ou seja, concordo com a normalização por 255. No fim, isso é igual ao erro de contagem de postes de cerca: há N valores, mas N-1 intervalos. Se há menos intervalos que valores, então um intervalo precisa ser dividido entre dois valores, e é por isso que surgem intervalos de 1/2 LSB nas extremidades
A transição de 126 para 127 acontece a um ponto que está a 1.5 LSB da escala positiva total. Uma diferença de 1 LSB significa 1/128=0.00781V, não 2/255=0.00784V
Mas, na prática, se tensão e incerteza realmente importam, essa diferença quase nunca tem relevância. Há viés na tensão de referência e também erros de linearidade. 1 LSB não corresponde exatamente nem a 1/128 nem a 2/255, e acabam sendo necessários parâmetros de calibração
Isso se parece com a diferença, em computação científica, entre amostras centradas no nó e amostras centradas na célula, vista em uma dimensão. É preciso decidir se o valor está no meio do intervalo (ou do triângulo/tetraedro) ou na fronteira do intervalo (ou nos vértices do triângulo/tetraedro)
Em computação científica, não faz sentido começar a processar dados sem saber como os valores devem ser interpretados. Em processamento de áudio também: se você só recebeu um fluxo de inteiros, precisa saber qual era a intenção de representação desses inteiros, por exemplo se era codificação mu-law ou linear, para poder calcular algo sobre o sinal original. Espera-se que os metadados associados aos valores tragam essa resposta
Mas, com valores de pixel de 8 bits, se não houver metadados adequados no formato de arquivo para transmitir essa intenção de representação, fica-se à deriva e não existe resposta certa. Como diz o autor, não dá para criticar alguém por escolher o método que dá melhor resultado para seu próprio uso, mas vale apontar que bits sem contexto têm seu significado degradado
É mais ou menos assim: o Número Digital DN=0 fica reservado como valor “NO_DATA”, e quando o DN está no intervalo [1; 1;215-1], o valor de refletância L2A SR é dado por L2A_SRi = (L2A_DNi + BOA_ADD_OFFSETi) / QUANTIFICATION_VALUE
https://sentiwiki.copernicus.eu/web/s2-products
Há um erro em supor que existem 256 níveis de 0 a 255. Na verdade, há 256 valores que podem ser representados em 8 bits, e existem 255 intervalos entre 0 (preto) e 255 (branco puro)
Portanto, dividir por 255 não é um problema. Claro, 128 não é exatamente um cinza de meio tom, e os valores quantizados de 8 bits de 0~255 quase sempre estão em sRGB, não em um espaço perceptual linear
Em APIs modernas, uma confusão parecida também acontece ao lidar com posições de amostragem. Isso ocorre porque a posição é especificada como coordenada, não como centro do pixel
Algebricamente, a resposta é clara: f(x) -> [0, 255]
Se f(n * 0) == n * f(0) não vale, coisas estranhas acontecem. Por exemplo, se f(x) -> [0, 255], então f(0) + f(0) + f(0) = 0 + 0 + 0 = 0 = f(0)
Por outro lado, se f(x) -> [0.5/8, 7.5/8], então f(0) + f(0) + f(0) = 0.5/8 + 0.5/8 + 0.5/8 = 1.5/8 != f(0)
Se você escolhe a segunda opção, não dá para esperar que um cálculo feito no lado de x e um cálculo feito no lado de f(x) coincidam entre si. Ou seja, a correspondência algébrica se rompe
Quero defender a solução do +0.5. Primeiro, não gosto dos intervalos de meia largura nas bordas; segundo, a representação baseada em 255 normalmente é de imagens SDR, não HDR
Os valores RGB representam luminância para algum estado de adaptação, e o “0” de uma cena diurna não é “luminância 0”. É apenas cerca de 0,001 da região mais brilhante, e ainda são milhões de fótons, muito mais do que 0
Em certo sentido, o olho percebe contraste em uma escala deslizante, e não existe um 0 absoluto no sistema. Por exemplo, historicamente os sistemas de transmissão usaram 16~235 como faixa de luminância SDR. Considero que a lógica de “precisa existir um 0” introduz viés, e acho que na maioria dos casos o 0 não é necessário
Além disso, boa parte dos fluxos de trabalho de processamento e composição de imagens, certos ou errados, assume que 0 significa 0. Então, em 8 bits, muita gente considera que 0u mapeia para 0.0f, e 255 para 1.0f. Se um valor 0 em máscara ou alfa ficar um pouco acima de 0.0, algum código em algum lugar vai mascarar outras operações com um limiar rígido de 0.0, gerando artefatos. Por outro lado, se 255 em alfa deixa de ser 1.0f, então depois da pré-multiplicação o objeto fica ligeiramente transparente
A mesma coisa pode acontecer quando, por causa do +0.5, 254 passa a ser 1.0f em mascaramento
O ponto principal não é representar 0 fótons, e sim maximizar a informação armazenada em 1 byte. Idealmente, você não deve usar menos o valor 0 do byte, nem adicionar viés aos dados que deveriam cair no bucket 0. Mesmo em um espaço de cor que vá de claro a muito claro, todos os bytes devem representar fatias de mesmo tamanho da faixa de brilho
Se a régua vai até 12 polegadas, então você deve normalizar pelo comprimento L, não pelo número de pontos na régua, que seria 13
>> 8é muito mais rápidoFoi um texto prazeroso de ler por tratar de um tema em que eu não pensava havia um bom tempo. Fez lembrar momentos no desenvolvimento de jogos em que a lógica do jogo usava matemática de ponto flutuante, mas a pixel art precisava ser desenhada em coordenadas inteiras
Em alguns lugares, tentamos algo parecido com +0.5 para fazer as coisas parecerem menos estranhas. Isso acontecia especialmente quando havia uma câmera em movimento, e a câmera também precisava ser truncada
O texto de 2002 do Jonathan Blow linkado abaixo [1] também foi interessante. A visualização do primeiro texto ajuda bastante quando você aprofunda mais no assunto
[1] https://web.archive.org/web/20240706043551/https://number-no...
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?