- En la normalización de RGB, si el caso habitual es procesar un archivo de imagen desconocido y volver a guardarlo en 8 bits, conviene usar el método estándar de dividir entre 255
- El método de 255 mapea 0 a 0.0 y 255 a 1.0, por lo que es fácil trabajar directamente con negro y blanco, y además coincide con la conversión UNORM a float de 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 manejo 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 de ancho, así que si se redondea de vuelta 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; aun así, la conversión de ida y vuelta de una imagen real funciona sin pérdida - En teoría, el método de 256 tiene un error absoluto medio de
1 / 1024, menor que el1 / 1020del método de 255, pero si una imagen ya cuantizada con 255 se lee con la escala equivocada, en realidad se introduce más error
Planteamiento del problema
Un programa de procesamiento de imágenes convierte una imagen de 8 bits a punto flotante, aplica el procesamiento y luego la vuelve a guardar como color de 8 bits
Los dos métodos de conversión son los siguientes
# Estándar: dividir entre 255
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)
# Alternativa: sumar 0.5 y dividir entre 256
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)
En ambos métodos, los valores se limitan a 0~255 antes de la conversión final
output_8bit = output.clip(0, 255).astype(np.uint8)
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 UNORM a float de la GPU
La alternativa mapea 0 a 0.5 / 256 = 0.001953125, así que para detectar un píxel negro hay que conocer esa constante
Características del método estándar de dividir entre 255
En el método estándar, dentro del rango [0, 1], los intervalos de los valores en los extremos tienen en la práctica la mitad del ancho que los demás
Si se genera un número aleatorio uniforme en [0, 1] y se redondea con trunc(result * 255 + 0.5), 0 y 255 aparecen con la mitad de frecuencia que los otros enteros
Sin embargo, una imagen original de 8 bits vuelve sin pérdida en la conversión de ida y vuelta uint8 → float → uint8
Además, aunque el resultado del procesamiento se salga ligeramente de 0.0 o 1.0, el clamp y el redondeo permiten que entre en el intervalo entero correcto
Por ejemplo, si se resta 0.005 a un color en punto flotante, el negro del método estándar se vuelve negativo, pero el resultado final sigue siendo el entero 0
trunc(255 * (-0.005) + 0.5) = 0
Precisión de punto flotante y ubicación en el centro del intervalo
Algunos valores del método de 255 no se representan exactamente
Por ejemplo, 128 / 255.0 ≈ 0.501961, mientras que 128 / 256.0 = 0.5
Esta diferencia es un error de redondeo al nivel del bit menos significativo dentro de la mantisa de 23 bits del punto flotante de 32 bits, y su magnitud es menor que 2^-23
Por eso, esta inexactitud es más una cuestión estética que un problema técnico real
El método de 256 coloca cada valor de punto flotante exactamente en el centro entre dos enteros
Esta propiedad puede verse como un compromiso de usar el punto medio entre dos enteros consecutivos cuando no se sabe cuál era exactamente el valor cuantizado original
El texto de 2015 de Andrew Kesler “Converting Color Depth” considera que este método hace menos necesario preocuparse por el manejo de bordes al añadir ruido en el dithering
En cambio, en el método estándar, los intervalos de los extremos requieren un tratamiento cuidadoso para mantener una distribución de ruido consistente
Perspectiva de cuantización
Ambos métodos pueden verse como cuantizadores escalares uniformes (uniform scalar quantizer)
La explicación de quantization en Wikipedia) divide principalmente los cuantizadores uniformes para datos de entrada signed en mid-riser y mid-tread
mid-tread tiene un nivel de reconstrucción para el valor 0, mientras que mid-riser tiene un umbral de clasificación en el valor 0
Las fórmulas corresponden así
| Método | Codificación | Decodificación |
|---|---|---|
| mid-tread | k = trunc(x L + 0.5) |
y_k = k / L |
| mid-riser | k = trunc(x L) |
y_k = (k + 0.5) / L |
El método estándar es una forma mid-tread que usa L=255, y la alternativa es una forma mid-riser que usa L=256
El método estándar gana la conveniencia de programación de alinear los extremos con 0.0 y 1.0, a cambio de una distribución de intervalos que no es la óptima para entradas de 8 bits
Error de reconstrucción y procesamiento real de imágenes
Si se diseñara directamente un sistema que codifica un valor real uniforme x ∈ [0, 1] como entero de 8 bits y luego lo reconstruye otra vez como valor real, el método de 256 sería teóricamente más preciso
El rango representable del método estándar pasa a ser [-0.5 / 255, 255.5 / 255], por lo que el espaciado entre intervalos se vuelve más amplio de lo estrictamente necesario para [0, 1]
Según el cálculo del usuario de StackOverflow Peter Mudrievskij, el error absoluto medio es 1 / 1020 al dividir entre 255 y 1 / 1024 al dividir entre 256
Pero en una situación en la que se lee y procesa una imagen RGB de 8 bits ya almacenada, la información perdida al guardarla originalmente no se recupera
Si la imagen fue cuantizada multiplicando por 255 y redondeando, dividir entre 256 al cargarla no devuelve la precisión perdida
Como es muy probable que las imágenes creadas por otras personas hayan sido cuantizadas con el método estándar, leerlas con la fórmula alternativa implica usar, en teoría, un factor de escala incorrecto
En la práctica, eso hace que los colores se procesen en un rango ligeramente más pequeño y con un pequeño offset, en lugar de comportarse como mediciones absolutas
Si se mezclan las etapas de codificación y decodificación de ambos cuantizadores, el código queda roto
Conclusión
Si vas a procesar imágenes proporcionadas por otra persona, debes normalizar los valores RGB con 255
No hay una base sólida para elegir el método de 256 solo porque los valores de punto flotante no sean exactos o porque dé la impresión abstracta de tener un mayor error de reconstrucción
Si controlas tanto el guardado como la carga de la imagen, no necesitas que 0 se mapee a 0 y te parece aceptable que el código de procesamiento quede atado al rango dinámico de 8 bits, entonces puedes dividir entre 256 para buscar una precisión teórica ligeramente mayor
1 comentarios
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?