1 puntos por GN⁺ 7 시간 전 | 1 comentarios | Compartir por WhatsApp
  • 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.0 para 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 el 1 / 1020 del 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

 
GN⁺ 7 시간 전
Opiniones en Lobste.rs
  • Se ve desprolijo, pero es correcto: el valor correcto es 255
    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 primera parte es correcta, pero de ahí no se sigue que “la conversión inversa debe multiplicar por 3 y no por 4”
      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 luego floor o ceil, 0 o 3 se pliegan como singularidades, haciendo que el gradiente parezca usar solo 3 de los 4 colores
      La lógica de /3 y ×3 puede 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 datos
      La única forma de lograr proporciones enteras uniformes es multiplicar por (4-ε) y truncar hacia abajo, lo cual equivale a ×4, floor() y clamp(). Se siente como un error raro de diferencia de 1 o de ε, pero intuitivamente es la solución que mejor se ve
  • El título me confundió bastante. No sé si fue a propósito, pero al final parece más bien “¿0..1 corresponde a [0..255.0] o a [0.5..255.5]?”
    Para 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 de verdad te parece obvio 0…255.0, entonces ¿qué rango de valores de punto flotante debería volver al entero 0 y qué valores deberían volver al entero 255?
      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
  • El enfoque estándar también parece haber surgido porque la gente naturalmente termina llamando a round()
    Como a la gente ese método le resulta bastante natural, parece haberse vuelto el estándar por su simplicidad
  • Me pregunto si podría servir el enfoque opuesto a lo que se intentaba lograr con 256. Es decir, enviar 0.0 a 0, 1.0 a 255, y mapear los demás valores de punto flotante entre 1 y 254
    uint8_t output = 0.0f >= result  
                     ? 0  
                     : 1.0f <= result  
                     ? 255  
                     : 1 + 253*result;  
    
    Estaría bien que durante el procesamiento también se mantuvieran negro como negro y blanco como blanco
    • Si haces eso, 0 y 255 se quedan con una porción mayor del intervalo unitario que los demás números. Aproximadamente 0.8%, es decir, 255/253
  • La primera imagen se ve rota en mi entorno
    • Soy el autor del artículo. ¿Quieres decir que el archivo de imagen está dañado? Lo comprimí con pngcrush. ¿O quieres decir que hay algo mal con el contenido de la imagen?