- Diferencia entre el método estándar de dividir entre 255 y el método alternativo de sumar un sesgo de 0.5 y dividir entre 256 al convertir colores enteros de 8 bits a punto flotante
- El método de 255 mapea el entero 0 a 0.0 y 255 a 1.0, por lo que es fácil manejar directamente el negro y el blanco, y además coincide con la conversión de UNORM a float usada por la GPU
- El método de 256 usa
(img + 0.5) / 256.0para colocar cada valor en el centro de su intervalo, lo que puede simplificar el tratamiento de bordes en tareas como el dithering, pero como 0 no es 0.0, la lógica de procesamiento queda atada a entradas de 8 bits - En el método de 255, los intervalos de los extremos tienen la mitad del ancho, así que si vuelves a redondear a 8 bits un número aleatorio uniforme en
[0, 1], 0 y 255 aparecen con la mitad de frecuencia que los demás valores, aunque la conversión real de ida y vuelta de imágenes sigue funcionando sin pérdidas - Si vas a procesar imágenes de terceros, la respuesta correcta es normalizar con 255; solo vale la pena considerar el método de 256 si controlas tanto el guardado como la carga
Planteamiento del problema
- En un programa que recibe una imagen, la convierte a punto flotante, la procesa y luego la vuelve a guardar como color de 8 bits, el punto en discusión es el método de conversión entre enteros y punto flotante
- Existen dos enfoques
- Método estándar (dividir entre 255):
pixels = img / 255.0→ procesamiento →output = np.trunc(result * 255 + 0.5) - Método alternativo (dividir entre 256):
pixels = (img + 0.5) / 256.0→ procesamiento →output = np.trunc(result * 256) - En ambos casos, antes de la conversión final de tipo, el valor se limita al rango 0~255:
output.clip(0, 255).astype(np.uint8)
- Método estándar (dividir entre 255):
- El método estándar mapea el entero 0 a 0.0 y 255 a 1.0, y es igual al método de conversión de UNORM a float de la GPU
- El método alternativo suma un sesgo de 0.5, de modo que el entero 0 se mapea a
0.5/256 = 0.001953125- Por eso, si no conoces esa constante, no puedes detectar píxeles negros
- Incluso si haces cálculos en punto flotante, la lógica queda atada a entradas de 8 bits
- En el método estándar, siempre se puede asumir que el negro es 0.0
Objeciones a 255.0
- Si dibujas el método estándar sobre la recta numérica, se ve algo extraño
-
Hay bins más pequeños en ambos extremos
- Los bins de los extremos en la fórmula estándar sobresalen fuera del rango [0,1], dando una forma “estirada” al rango
- Al convertir de punto flotante de vuelta a entero, el ancho de los bins de los extremos es solo la mitad del de los demás
- Eso hace que al algoritmo le cueste más “producir” valores extremos
- Si generas ruido uniforme en [0,1] y lo redondeas con la fórmula estándar, los valores 0 y 255 aparecen con la mitad de frecuencia que otros enteros
- Si observas el histograma de un millón de números aleatorios uniformes, se puede verificar que la frecuencia de 0 y 255 es la mitad de la de los otros bins
- Aun así, es difícil imaginar una situación real en la que ese sesgo para evitar extremos sea un problema
- La imagen original sigue haciendo el recorrido de ida y vuelta sin pérdidas (
uint8 → float → uint8) - Incluso resultados que se salen ligeramente de 0.0 o 1.0 se redondean al bin correcto y la distribución de salida se equilibra
- Ejemplo: si durante el procesamiento se resta 0.005 al color, con el método estándar el negro cae por debajo de 0, mientras que con el alternativo sigue siendo positivo, pero en ambos casos el resultado final es el entero 0
- La imagen original sigue haciendo el recorrido de ida y vuelta sin pérdidas (
-
Inexactitud
- Los valores en punto flotante del método estándar no son exactos; por ejemplo,
128/255.0 ≈ 0.501961, mientras que128/256.0 = 0.5 - El error de redondeo hace que la distancia entre valores de punto flotante varíe ligeramente, pero el error es tan pequeño que no representa un problema real
- Un flotante de 32 bits tiene una mantisa de 23 bits, y el error está en el nivel del bit menos significativo, por debajo de
2⁻²³ - Un error relativo de 0.00001% no tiene importancia ni siquiera en procesamiento de imagen sofisticado; la inexactitud es un problema estético, no técnico
- Un flotante de 32 bits tiene una mantisa de 23 bits, y el error está en el nivel del bit menos significativo, por debajo de
- Los valores en punto flotante del método estándar no son exactos; por ejemplo,
-
Valores que no pertenecen al rango entero
- El método alternativo coloca cada valor en punto flotante exactamente en medio de dos enteros
- Como no se puede saber el valor cuantizado original, tomar el punto medio entre dos enteros consecutivos es un compromiso razonable como estimación
- Existe el argumento de que esto facilita el dithering (la entrada de blog de Andrew Kesler de 2015, "Converting Color Depth")
- Se puede agregar ruido sin preocuparse por casos límite
- En cambio, los valores extremos incómodos de la fórmula estándar requieren un tratamiento cuidadoso para mantener la consistencia en la distribución del ruido
- El método alternativo coloca cada valor en punto flotante exactamente en medio de dos enteros
Dos tipos de cuantizadores
- Ambos enfoques pueden verse como dos tipos de cuantizador escalar uniforme
- Según el artículo de Wikipedia sobre cuantización, los cuantizadores uniformes para datos de entrada con signo se clasifican en dos tipos
- mid-tread: mapea 0 al nivel de reconstrucción con valor 0 (la huella del escalón)
- mid-riser: mapea 0 al umbral de clasificación con valor 0 (la contrahuella del escalón)
- Wikipedia cita como fuente un artículo de 1977 (Allen Gresho, "Quantization")
- Fórmulas del cuantizador (L es el número de niveles de salida, por ejemplo 256)
- Cuantizador escalonado mid-tread: codificación
k = trunc(xL + 0.5), decodificaciónyₖ = k/L - Cuantizador escalonado mid-riser: codificación
k = trunc(xL), decodificaciónyₖ = (k+0.5)/L
- Cuantizador escalonado mid-tread: codificación
- Aplicado a estos dos métodos
- La fórmula estándar = mid-tread (L=255)
- La fórmula alternativa = mid-riser (L=256)
- El método estándar usa mid-tread para una entrada sin signo junto con el código L=255, una combinación que no es óptima para entradas de 8 bits
- Es una elección por conveniencia de programación para mapear los extremos a 0.0 y 1.0
-
Mayor error de cuantización, pero en la práctica no
- Si fuera un sistema que codifica un número real con distribución uniforme x∈[0,1] como entero de 8 bits y luego lo reconstruye como real, la fórmula estándar desperdicia ancho de banda
- El rango representable del método estándar es
[-0.5/255, 255.5/255], más amplio de lo necesario para una entrada en [0,1], lo que incrementa el error de reconstrucción - Según el cálculo del usuario de StackOverflow Peter Mudrievskij, el error absoluto medio es
1/1020con divisor 255 y1/1024con divisor 256, por lo que dividir entre 256 es teóricamente un poco más preciso
- El rango representable del método estándar es
- Pero en la práctica no se está haciendo ese tipo de reconstrucción
- La premisa es cargar una imagen RGB de 8 bits, procesarla y volver a guardarla; al guardarla no se puede controlar el método de cuantización, y la información perdida desaparece para siempre
- Si una imagen se guardó multiplicando y redondeando con la fórmula estándar, cargarla luego dividiendo entre 256 no recupera precisión
- La supuesta ventaja de menor error de reconstrucción solo tiene sentido cuando controlas tanto el guardado como la carga
- Si cargas una imagen de otra persona con la fórmula alternativa, en realidad introduces más error
- Lo más probable es que haya sido cuantizada con la fórmula estándar, así que decodificarla con una escala incorrecta es teóricamente inexacto
- En la práctica, como el color no es una medición absoluta, esto equivale a procesar dentro de un rango un poco más pequeño y con un pequeño desplazamiento
- No se deben mezclar las etapas de codificación y decodificación de ambos cuantizadores; es una forma común de terminar con código roto
- Si fuera un sistema que codifica un número real con distribución uniforme x∈[0,1] como entero de 8 bits y luego lo reconstruye como real, la fórmula estándar desperdicia ancho de banda
Conclusión
- Si vas a procesar imágenes que te dio otra persona, debes normalizar los valores RGB con 255
- Los temores sobre valores de punto flotante inexactos o sobre un error de reconstrucción abstracto no son una buena razón para elegir la alternativa
- Si controlas tanto el guardado como la carga de la imagen, no necesitas mapear 0 a 0 y no te molesta atar el código de procesamiento al rango dinámico de 8 bits, entonces puedes dividir entre 256 para ganar un poco más de precisión
- Eso sí, ten en cuenta que un colega podría cargar la imagen con la fórmula estándar y arruinar el plan
Otras opiniones
- El texto de Jonathan Blow de 2002 trata los cuantizadores mid-riser y mid-tread sin nombrarlos, y es la fuente de la idea de los diagramas
- La entrada de blog de Andrew Kesler de 2015 defiende la fórmula alternativa
- Sin embargo, compara contra una fórmula estándar sin redondeo, así que la mayor parte del análisis queda invalidada
2 comentarios
Opiniones de Hacker News
Qué significan exactamente los valores de color casi no importa cuando son 8 bits por componente. El error que aparece por usar 255 o 256 como denominador es muy pequeño, y para notar la diferencia habría que tener muy buena percepción del color y pegarse mucho a la pantalla; además, los monitores y las pantallas de los teléfonos normalmente ni siquiera están calibrados.
Pero si generas una señal VGA con un microcontrolador y solo tienes 8 pines de salida de color (3 para rojo, 3 para verde y 2 para azul), sí se vuelve bastante problemático. Ahí el valor de color es literalmente el nivel de voltaje de 0V a 0.7V que hay que enviar al monitor VGA.
El canal azul se mapea como 0→0V, 1→0.23V, 2→0.47V, 3→0.7V, y rojo/verde como 0→0V, 1→0.1V, …, 7→0.7V. Si excluyes los extremos, los voltajes del azul no coinciden en absoluto con los de rojo/verde, así que no puedes ver un gris puro, y hasta el color más cercano queda con un ligero tinte azul o amarillo según la dirección de la diferencia.
Además, casi todos los gradientes que mezclan azul con otros canales también se ven desalineados. Por ejemplo, los colores más cercanos sobre la línea que va de rojo puro a blanco puro se ven un poco anaranjados o morados.
Aquí está el código para salida VGA de color de 8 bits con framebuffer doble de 320x240 en Raspberry Pi Pico 2: https://github.com/moefh/pico-vga-8bit-demo
Eso hace que la diferencia entre valores pequeños y grandes se note muchísimo más: 2^2.2 = 4.595, 255^2.2 = 196,964.699
Si cambia a 30Hz, a la gente probablemente le cueste distinguir entre un azul leve y un amarillo leve.
Como argumento a favor de 255, basta ver el caso extremo de una imagen en blanco y negro. Con un solo bit, 0 es negro y 1 es blanco.
Parece bastante claro que 0 debe mapearse a 0.0 y 1 a 1.0. Es blanco y negro, no gris claro (0.25) y gris oscuro (0.75). Es decir, una imagen en blanco y negro se normaliza por 1, no por 2.
Con 2 bits, normalmente 0=negro, 1=gris claro, 2=gris oscuro, 3=blanco, así que lo natural es mapearlos a 0.0, 0.33, 0.66 y 1.0. El negro debe ser negro, el blanco debe ser blanco y el espaciado debe ser uniforme, así que se normaliza por 3.
Si extiendes esa lógica a 8 bits, terminas normalizando por 255. Aunque la diferencia sea muy pequeña con 8 bits, negro debe ser 0.0 y blanco debe ser 1.0.
Si en cambio usas normalización por 256 con 8 bits, el rango de salida pasa a depender de la cantidad de bits. Con 1 bit sería [0.25, 0.75], con 2 bits [0.125, 0.875], etc. Normalmente lo que quieres es más matices al aumentar los bits, no cambiar el contraste.
Fue un texto que realmente da para pensar, y en lo personal me hizo replantear algunas suposiciones que tenía.
Desde una formación en ingeniería eléctrica, me cuesta aceptar la presentación de “dos tipos de cuantizadores” del artículo. Es rigurosa en lo matemático, pero no es una explicación basada en sistemas reales.
En un ADC siempre existe inherentemente una incertidumbre de cuantización de ±1/2 LSB. La característica de transferencia siempre es de muestreo mid-tread, y al menos yo no he visto contraejemplos. Esto vale tanto para ADC bipolares como unipolares.
El código más bajo corresponde al voltaje negativo de referencia y el más alto al positivo. La gráfica de transferencia muestra, como en el artículo, que las bandas extrema superior e inferior tienen efectivamente un ancho de 1/2 LSB.
En sistemas unipolares no puedes representar exactamente el voltaje intermedio; en otras palabras, aparece el problema del gris. En sistemas bipolares, 0V es el valor N/2 del mid-tread, pero eso no significa que existan “256 intervalos”.
Por eso yo seguiría usando (VREF+ - VREF-) * k / (2^N - 1). O sea, estoy de acuerdo con normalizar por 255. Al final es como el error de los postes de una cerca: hay N valores, pero N-1 intervalos. Si hay menos intervalos que valores, entonces un intervalo tiene que repartirse entre dos valores, y por eso aparece un intervalo de 1/2 LSB en los extremos.
La transición de 126 a 127 ocurre a una distancia de 1.5 LSB del extremo positivo del rango completo. Una diferencia de 1 LSB implica 1/128=0.00781V, no 2/255=0.00784V.
Pero en la práctica, si lo importante son el voltaje y la incertidumbre, casi nunca importa esa diferencia. Hay sesgo en el voltaje de referencia y también errores de linealidad. Un LSB no coincide exactamente ni con 1/128 ni con 2/255, y entonces hacen falta parámetros de calibración.
Esto se parece a la diferencia, en cálculo científico, entre muestras centradas en nodo y centradas en celda, visto en una dimensión. Hay que decidir si el valor está en el centro del intervalo (o del triángulo/tetraedro) o en el borde del intervalo (o en el vértice del triángulo/tetraedro).
En cálculo científico no tiene sentido empezar a procesar datos sin saber cómo deben interpretarse los valores. En procesamiento de audio también, si solo recibes un flujo de enteros, necesitas saber qué intención de representación tienen esos enteros —por ejemplo, si usan codificación mu-law o lineal— para poder hacer cálculos sobre la señal original. Uno esperaría que los metadatos asociados al valor dieran esa respuesta.
Pero con valores de píxel de 8 bits, si no hay metadatos adecuados en el formato de archivo para transmitir esa intención de representación, quedas a la deriva y no existe una respuesta correcta. Como dice el autor, no se le puede reprochar que elija lo que le da mejores resultados en su caso de uso, pero sí se puede señalar que bits sin contexto terminan perdiendo significado.
A grandes rasgos es algo así: el número digital DN=0 se reserva como valor “NO_DATA”, y cuando DN está en el rango [1; 1;215-1], el valor de reflectancia L2A SR es L2A_SRi = (L2A_DNi + BOA_ADD_OFFSETi) / QUANTIFICATION_VALUE.
https://sentiwiki.copernicus.eu/web/s2-products
Aquí hay un error al asumir que existen 256 niveles de 0 a 255. En realidad, hay 256 valores que pueden representarse con 8 bits, y hay 255 intervalos entre 0 (negro) y 255 (blanco puro)
Así que dividir entre 255 no tiene nada de malo. Claro, 128 no es exactamente un gris medio, y los valores cuantizados de 8 bits entre 0 y 255 casi siempre están en sRGB, no en un espacio perceptual lineal
En las APIs modernas también aparece una confusión parecida al manejar posiciones de muestreo, porque la posición se especifica como coordenadas y no como centro de píxel
Visto algebraicamente, la respuesta es clara: f(x) -> [0, 255]
Si no se cumple f(n * 0) == n * f(0), empiezan a pasar cosas raras. Por ejemplo, si f(x) -> [0, 255], entonces f(0) + f(0) + f(0) = 0 + 0 + 0 = 0 = f(0)
En cambio, si f(x) -> [0.5/8, 7.5/8], entonces f(0) + f(0) + f(0) = 0.5/8 + 0.5/8 + 0.5/8 = 1.5/8 != f(0)
Si eliges lo segundo, no puedes esperar que un cálculo hecho del lado de x y otro hecho del lado de f(x) coincidan. Es decir, se rompe la correspondencia algebraica
Quiero defender la solución de +0.5. Primero, no me gustan los intervalos de medio tamaño en los bordes, y segundo, una representación basada en 255 normalmente no es HDR sino una imagen SDR
Los valores RGB representan luminancia respecto a un estado de adaptación, y el “0” de una escena diurna no es “luminancia 0”. Es apenas cerca de 0.001 veces el punto más brillante, y aun así son millones de fotones, muchísimo más que 0
En cierto sentido, el ojo experimenta el contraste como una escala deslizante, y no existe un 0 absoluto dentro del sistema. Por ejemplo, los sistemas de broadcast históricamente usaron 16~235 como rango de luminancia SDR. Considero que la lógica de “tiene que existir un 0 sí o sí” introduce sesgo, y creo que en la mayoría de los casos no hace falta un 0
Además, una gran parte de los flujos de trabajo de procesamiento y composición de imágenes, estén bien o mal, asumen que 0 significa 0. Por eso muchas veces se considera que en 8 bits, 0u se mapea a 0.0f y 255 a 1.0f. Si en una máscara o un alfa el valor 0 queda apenas por encima de 0.0, algún código en alguna parte terminará enmascarando otras operaciones con un umbral duro de 0.0 y aparecerán artefactos. A la inversa, si en el alfa 255 deja de ser exactamente 1.0f, después de la premultiplicación el objeto quedará apenas transparente
Lo mismo puede pasar cuando, por culpa del +0.5, en el enmascarado 254 pasa a ser 1.0f
La clave no es si se representan 0 fotones, sino si se maximiza la información almacenada en 1 byte. Idealmente, no deberíamos usar menos el valor de byte 0, ni tampoco introducir un sesgo en los datos que debían caer en el bucket 0. Incluso en un espacio de color que va de brillante a muy brillante, todos los bytes deberían representar fragmentos del mismo tamaño del rango de brillo
Si una regla llega hasta las 12 pulgadas, deberías normalizar por la longitud L, no por la cantidad de puntos sobre la regla, que serían 13
>> 8es mucho más rápidoFue un artículo agradable de leer porque trata un tema en el que no pensaba desde hacía tiempo. Me hizo recordar momentos en desarrollo de juegos donde la lógica del juego usaba matemáticas de punto flotante pero el pixel art había que dibujarlo en coordenadas enteras
En varios lugares intentamos usar algo parecido a +0.5 para que se viera menos raro. Sobre todo cuando había una cámara en movimiento, y la cámara también había que recortarla
También fue interesante el texto de Jonathan Blow de 2002 enlazado abajo [1]. La visualización del primer artículo ayuda mucho cuando uno quiere profundizar más
[1] https://web.archive.org/web/20240706043551/https://number-no...
Opiniones en Lobste.rs
Si no resulta intuitivo, se puede ver con un caso degenerado de 2 bits. Si los únicos valores enteros posibles son 0, 1, 2 y 3, al calcular toda la conversión entero→punto flotante, para evitar comportamientos raros donde negro/blanco no sean negro/blanco o donde los intervalos sean claramente desiguales, el resultado sería 0.0, 0.33..., 0.66..., 1.0
Por lo tanto, la conversión inversa no consiste en multiplicar por 4 (2^2), sino en multiplicar por 3
La conversión inversa requiere cuantización (redondeo), y justo ahí es donde se rompe la simetría
Si se crea un gradiente uniforme de números reales en el rango 0..=1 y se cuantiza a 0, 1, 2 y 3, se puede ver que multiplicar por 3 produce un resultado desigual. Con ×3 y
round(), 1 y 2 quedan sobrerrepresentados; con ×3 y luegoflooroceil, 0 o 3 se pliegan como singularidades, haciendo que el gradiente parezca usar solo 3 de los 4 coloresLa lógica de
/3y×3puede parecer razonable al convertir valores exactos de ida y vuelta, pero los valores intermedios dependen mucho de la elección del redondeo, y eso importa en cuanto se empieza a procesar datosLa única forma de lograr proporciones enteras uniformes es multiplicar por (4-ε) y truncar hacia abajo, lo cual equivale a ×4,
floor()yclamp(). Se siente como un error raro de diferencia de 1 o de ε, pero intuitivamente es la solución que mejor se vePara mí la respuesta siempre fue “obviamente” [0.0..255.0], pero quizá no sea algo obvio para todo el mundo
En el artículo se dice que los intervalos “extremos” tienen solo la mitad de capacidad que los demás, pero tampoco creo que ese encuadre sea correcto
Si no existen valores fuera de [0..1], que se vea como un intervalo más angosto es un artefacto del renderizado. Solo se renderiza más angosto porque se recortaron los buckets sabiendo que no hay valores fuera del rango
En cambio, si sí existen valores fuera de [0..1], entonces ese rango es infinito. El artículo reconoce esto último, pero no lo primero
En cuanto se acepta lo primero, el comportamiento correcto parece evidente, aunque el simple hecho de que exista un artículo así también significa objetivamente que no es un tema tan “evidente” :D
Si 0..<1 va al entero 0 y 254>..255.0 va al entero 255, entonces 128 queda absorbido. Probablemente querrías que 127.5..128.5 se convierta en 128, pero entonces ¿a dónde deberían ir esas mitades?
Si desplazas un poco todo el rango para que 128 encaje, entonces 0..0.99609375 se mapea al entero 0
round()Como a la gente ese método le resulta bastante natural, parece haberse vuelto el estándar por su simplicidad
pngcrush. ¿O quieres decir que hay algo mal con el contenido de la imagen?