1 points par GN⁺ 7 시간 전 | 1 commentaires | Partager sur WhatsApp
  • Pour la normalisation RGB, dans le cas courant où l’on traite un fichier image inconnu avant de le réenregistrer en 8 bits, la méthode standard consistant à diviser par 255 est appropriée
  • La méthode 255 mappe 0 vers 0.0 et 255 vers 1.0, ce qui facilite la manipulation directe du noir et du blanc, et correspond aussi à la conversion UNORM-vers-float des GPU
  • La méthode 256 utilise (img + 0.5) / 256.0 pour placer chaque valeur au centre de son intervalle, ce qui peut simplifier la gestion des bords dans des opérations comme le dithering, mais comme 0 n’est pas égal à 0.0, la logique de traitement reste liée à une entrée 8 bits
  • Avec la méthode 255, les intervalles aux deux extrémités ont une largeur effective de moitié ; ainsi, si l’on réarrondit un nombre aléatoire uniforme dans [0, 1] vers 8 bits, 0 et 255 apparaissent deux fois moins souvent que les autres valeurs, mais les conversions aller-retour d’images réelles restent sans perte
  • En théorie, la méthode 256 donne une erreur absolue moyenne de 1 / 1024, plus faible que le 1 / 1020 de la méthode 255, mais si l’on lit une image déjà quantifiée selon la méthode 255 avec une mauvaise échelle, on ajoute au contraire de l’erreur

Cadre du problème

Un programme de traitement d’image convertit une image 8 bits en flottants, effectue son traitement, puis la réenregistre en couleur 8 bits.

Les deux méthodes de conversion sont les suivantes :

# Standard : division par 255
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)


# Alternative : ajout de 0.5 puis division par 256
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)

Dans les deux cas, les valeurs sont limitées à l’intervalle 0–255 avant la conversion finale :

output_8bit = output.clip(0, 255).astype(np.uint8)

La méthode standard mappe l’entier 0 vers 0.0 et 255 vers 1.0, et correspond au mode de conversion UNORM-vers-float des GPU.

La méthode alternative mappe 0 vers 0.5 / 256 = 0.001953125, donc pour détecter un pixel noir, il faut connaître cette constante.

Caractéristiques de la méthode standard par division par 255

Avec la méthode standard, dans l’intervalle [0, 1], les intervalles associés aux deux valeurs extrêmes ont en pratique une largeur moitié moindre que les autres.

Si l’on génère un nombre aléatoire uniforme dans [0, 1] et qu’on l’arrondit avec trunc(result * 255 + 0.5), 0 et 255 apparaissent deux fois moins souvent que les autres entiers.

Mais une image 8 bits d’origine revient sans perte après un aller-retour uint8 → float → uint8.

De plus, même si le résultat du traitement sort légèrement de 0.0 ou 1.0, le clamp et l’arrondi peuvent encore le ramener dans le bon intervalle d’entiers.

Par exemple, si l’on soustrait 0.005 à une couleur flottante, le noir devient négatif avec la méthode standard, mais le résultat final reste malgré tout l’entier 0.

trunc(255 * (-0.005) + 0.5) = 0

Précision flottante et placement au centre des intervalles

Certaines valeurs de la méthode 255 ne sont pas représentées exactement.

Par exemple, 128 / 255.0 ≈ 0.501961, alors que 128 / 256.0 = 0.5.

Cet écart correspond à une erreur d’arrondi au niveau du bit de poids faible dans la mantisse sur 23 bits d’un flottant 32 bits, et sa taille est inférieure à 2^-23.

Cette imprécision relève donc davantage d’une question esthétique que d’un véritable problème technique.

La méthode 256 place chaque valeur flottante exactement au centre entre deux entiers.

On peut voir cette propriété comme un compromis : lorsqu’on ne sait pas exactement quelle était la valeur quantifiée d’origine, on choisit le point moyen entre deux entiers consécutifs.

L’article de 2015 d’Andrew Kesler, “Converting Color Depth”, estime que cette approche simplifie la gestion des bords lorsqu’on ajoute du bruit pour le dithering.

À l’inverse, avec la méthode standard, les intervalles d’extrémité demandent un traitement attentif si l’on veut conserver une distribution de bruit cohérente.

Point de vue de la quantification

Les deux méthodes peuvent être vues comme des quantificateurs scalaires uniformes.

L’explication de Wikipedia sur la quantification) distingue principalement, pour les données d’entrée signées, les quantificateurs uniformes de type mid-riser et mid-tread.

Le mid-tread possède un niveau de reconstruction pour la valeur 0, tandis que le mid-riser possède un seuil de classification à la valeur 0.

Les formules correspondantes sont les suivantes :

Méthode Encodage Décodage
mid-tread k = trunc(x L + 0.5) y_k = k / L
mid-riser k = trunc(x L) y_k = (k + 0.5) / L

La méthode standard correspond à une forme mid-tread avec L=255, et la méthode alternative à une forme mid-riser avec L=256.

La méthode standard offre la commodité de programmation d’aligner les deux extrémités sur 0.0 et 1.0, au prix d’un placement des intervalles qui n’est pas optimal pour une entrée 8 bits.

Erreur de reconstruction et traitement d’image réel

Si l’on conçoit directement un système qui encode des réels uniformément distribués x ∈ [0, 1] en entiers 8 bits, puis les reconstruit ensuite en réels, la méthode 256 est en théorie plus précise.

Avec la méthode standard, la plage représentable devient [-0.5 / 255, 255.5 / 255], ce qui élargit l’espacement des intervalles au-delà de ce qui est strictement nécessaire pour [0, 1].

D’après le calcul de l’utilisateur StackOverflow Peter Mudrievskij, l’erreur absolue moyenne est de 1 / 1020 avec la division par 255, contre 1 / 1024 avec la division par 256.

Mais lorsqu’on lit une image RGB 8 bits déjà stockée pour la traiter, l’information perdue au moment de l’enregistrement ne peut pas être restaurée.

Si l’image a été quantifiée en multipliant par 255 puis en arrondissant, la diviser par 256 au chargement ne redonne pas de précision.

Comme les images créées par d’autres sont très probablement quantifiées selon la méthode standard, les lire avec la formule alternative revient théoriquement à utiliser un mauvais facteur d’échelle.

En pratique, la couleur ne se comporte pas comme une mesure absolue, si bien que le résultat revient à traiter les données dans une plage légèrement plus petite avec un léger décalage.

Mélanger l’étape d’encodage d’un quantificateur et l’étape de décodage de l’autre aboutit à un code incorrect.

Conclusion

Si vous traitez des images fournies par des tiers, il faut normaliser les valeurs RGB par 255.

Le fait que les valeurs flottantes ne soient pas exactes, ou l’impression abstraite d’une erreur de reconstruction un peu plus élevée, ne constitue pas un argument solide pour choisir la méthode 256.

Si vous contrôlez à la fois l’enregistrement et le chargement des images, que 0 n’a pas besoin d’être mappé sur 0, et qu’un code de traitement lié à la plage dynamique 8 bits ne pose pas problème, alors vous pouvez diviser par 256 pour viser une précision théorique légèrement supérieure.

1 commentaires

 
GN⁺ 7 시간 전
Avis sur Lobste.rs
  • C’est moche, mais correct : la bonne valeur est 255
    Si ce n’est pas intuitif, on peut regarder le cas dégénéré sur 2 bits. Quand les seules valeurs entières possibles sont 0, 1, 2 et 3, si on calcule toutes les conversions entier → virgule flottante, on obtient 0.0, 0.33..., 0.66..., 1.0 pour éviter des comportements bizarres où le noir/blanc ne seraient pas vraiment noir/blanc, ou où les intervalles seraient manifestement irréguliers
    Donc la conversion inverse consiste à multiplier par 3, pas par 4 (2^2)
    • Le début est juste, mais ça n’implique pas pour autant que « la conversion inverse doit multiplier par 3 et non par 4 »
      La conversion inverse nécessite une quantification (arrondi), et c’est précisément là que la symétrie se casse
      Si on crée un gradient réel uniforme dans l’intervalle 0..=1 puis qu’on le quantifie en 0, 1, 2, 3, on voit qu’avec ×3 le résultat n’est pas uniforme. Avec round() après ×3, 1 et 2 sont surreprésentés ; avec floor ou ceil après ×3, 0 ou 3 deviennent des singularités, ce qui donne l’impression que le gradient n’utilise que 3 couleurs sur 4
      La logique /3 et ×3 semble correcte quand on fait un aller-retour sur des nombres exacts, mais les valeurs intermédiaires dépendent fortement du choix d’arrondi, et cela devient important dès qu’on commence à traiter les données
      La seule façon d’obtenir des proportions entières uniformes est de multiplier par (4-ε) puis de tronquer, ce qui revient à ×4, floor() et clamp(). Ça ressemble à une erreur bizarre de 1 ou de ε, mais intuitivement c’est la solution la plus satisfaisante visuellement
  • Le titre m’a beaucoup embrouillé. Je ne sais pas si c’était volontaire, mais au fond ça ressemble plutôt à « est-ce que 0..1 correspond à [0..255.0] ou à [0.5..255.5] ? »
    Pour moi, la réponse a toujours été « évidemment » [0.0..255.0], mais apparemment ce n’est pas évident pour tout le monde
    L’article dit aussi que les intervalles « extrêmes » n’ont que la moitié de la capacité des autres, mais je ne pense pas que ce cadrage soit juste
    S’il n’existe pas de valeurs en dehors de [0..1], le fait que ces intervalles paraissent plus étroits est un artefact de rendu. Ils sont juste rendus plus étroits parce qu’on a découpé les buckets en sachant qu’il n’existe pas de valeurs hors plage
    À l’inverse, s’il existe des valeurs en dehors de [0..1], alors cette plage est infinie. L’article reconnaît le second point, mais pas le premier
    Dès qu’on accepte le premier, le comportement correct semble clair, mais le simple fait que cet article existe montre aussi que ce n’est pas une question objectivement « claire » :D
    • Si 0…255.0 est vraiment si évident, alors quelle plage de valeurs flottantes doit revenir à l’entier 0, et quelle plage doit revenir à l’entier 255 ?
      Si 0..<1 va vers l’entier 0 et que 254>..255.0 va vers l’entier 255, alors 128 se fait manger. On voudra probablement que 127.5..128.5 aille vers 128, mais alors où doivent aller ces moitiés ?
      Si on décale un peu tout l’ensemble pour faire tomber juste 128, alors 0..0.99609375 est mappé vers l’entier 0
  • L’approche standard semble aussi venir du fait que les gens appellent naturellement round()
    Comme cette manière de faire paraît assez naturelle, elle est sans doute devenue la norme par simplicité
  • Je me demande si l’approche opposée à ce qu’on cherchait à obtenir avec 256 pourrait aussi être utile. Autrement dit, envoyer 0.0 vers 0, 1.0 vers 255, et mapper toutes les autres valeurs flottantes entre 1 et 254
    uint8_t output = 0.0f >= result
                     ? 0
                     : 1.0f <= result
                     ? 255
                     : 1 + 253*result;
    
    Ce serait bien que, même pendant le traitement, le noir reste noir et le blanc reste blanc
    • Avec cette méthode, 0 et 255 reçoivent une part plus grande que les autres nombres dans l’intervalle unitaire. Environ 0,8 %, soit 255/253
  • La première image s’affiche de façon cassée chez moi
    • Auteur de l’article ici. Vous voulez dire que le fichier image est corrompu ? Je l’ai bien compressé avec pngcrush. Ou bien vous voulez dire que le contenu de l’image lui-même a un problème ?