- Bei der RGB-Normierung ist in der üblichen Situation, in der man eine fremde Bilddatei verarbeitet und anschließend wieder als 8-Bit speichert, die Standardmethode mit Division durch 255 angemessen
- Die 255-Methode bildet 0 auf 0.0 und 255 auf 1.0 ab, sodass sich Schwarz und Weiß direkt behandeln lassen, und sie entspricht auch der UNORM-zu-Float-Umwandlung der GPU
- Die 256-Methode setzt mit
(img + 0.5) / 256.0jeden Wert in die Mitte seines Intervalls und kann dadurch bei Aufgaben wie Dithering die Behandlung von Grenzen vereinfachen, aber 0 ist dann nicht 0.0, sodass die Verarbeitungslogik an 8-Bit-Eingaben gebunden bleibt - Bei der 255-Methode sind die Intervalle an beiden Enden nur halb so breit wie die übrigen; rundet man eine gleichverteilte Zufallszahl aus
[0, 1]zurück auf 8 Bit, treten 0 und 255 daher halb so häufig auf wie andere Werte, in realen Bild-Roundtrips arbeitet die Umwandlung jedoch verlustfrei - Die 256-Methode hat theoretisch einen kleineren mittleren absoluten Fehler von
1 / 1024gegenüber1 / 1020bei der 255-Methode, aber wenn man ein bereits mit der 255-Methode quantisiertes Bild mit dem falschen Maßstab einliest, fügt man stattdessen zusätzlichen Fehler hinzu
Problemstellung
Ein Bildverarbeitungsprogramm wandelt ein 8-Bit-Bild in Fließkommazahlen um, führt die Verarbeitung aus und speichert es anschließend wieder als 8-Bit-Farbwerte
Die beiden Umwandlungsmethoden sehen so aus
# Standard: durch 255 teilen
pixels = img / 255.0
result = process(pixels)
output = np.trunc(result * 255 + 0.5)
# Alternative: 0.5 addieren und durch 256 teilen
pixels = (img + 0.5) / 256.0
result = process(pixels)
output = np.trunc(result * 256)
Bei beiden Methoden werden die Werte vor der endgültigen Umwandlung auf 0 bis 255 begrenzt
output_8bit = output.clip(0, 255).astype(np.uint8)
Die Standardmethode bildet die Ganzzahl 0 auf 0.0 und 255 auf 1.0 ab und entspricht der UNORM-zu-Float-Umwandlung der GPU
Die Alternative bildet 0 auf 0.5 / 256 = 0.001953125 ab; um also ein schwarzes Pixel zu erkennen, muss man diese Konstante kennen
Eigenschaften der Standardmethode mit Division durch 255
Bei der Standardmethode sind die Intervalle der Endwerte innerhalb des Bereichs [0, 1] effektiv nur halb so breit wie die anderen Intervalle
Erzeugt man eine gleichverteilte Zufallszahl aus [0, 1] und rundet mit trunc(result * 255 + 0.5), dann treten 0 und 255 halb so häufig auf wie andere Ganzzahlen
Ein ursprüngliches 8-Bit-Bild kehrt jedoch bei einem Roundtrip uint8 → float → uint8 verlustfrei zurück
Außerdem kann ein Verarbeitungsergebnis, das 0.0 oder 1.0 leicht überschreitet, durch Clamping und Rundung trotzdem im korrekten Ganzzahlintervall landen
Wenn man zum Beispiel von einer Fließkommafarbe 0.005 abzieht, wird Schwarz bei der Standardmethode negativ, aber das Endergebnis bleibt dennoch die Ganzzahl 0
trunc(255 * (-0.005) + 0.5) = 0
Fließkommagenauigkeit und Platzierung in der Intervallmitte
Werte der 255-Methode sind teilweise nicht exakt darstellbar
Zum Beispiel gilt 128 / 255.0 ≈ 0.501961, während 128 / 256.0 = 0.5 ist
Dieser Unterschied ist ein Rundungsfehler auf dem Niveau des niederwertigsten Bits in der 23-Bit-Mantisse von 32-Bit-Floats und kleiner als 2^-23
Daher ist diese Ungenauigkeit eher ein ästhetisches als ein praktisches technisches Problem
Die 256-Methode platziert jeden Fließkommawert exakt in die Mitte zwischen zwei Ganzzahlen
Diese Eigenschaft kann als Kompromiss gesehen werden, wenn man nicht weiß, welcher quantisierte Ursprungswert genau vorlag, und daher den Mittelwert zwischen zwei benachbarten Ganzzahlen verwendet
Andrew Keslers Artikel von 2015 „Converting Color Depth“ sieht darin einen Vorteil beim Dithering, weil man beim Hinzufügen von Rauschen die Behandlung von Grenzen weniger beachten muss
Umgekehrt erfordern die Endintervalle der Standardmethode für eine konsistente Rauschverteilung eine sorgfältige Behandlung
Perspektive der Quantisierung
Beide Methoden lassen sich als uniforme skalare Quantisierer auffassen
Wikipedias Erklärung zur Quantisierung) unterteilt uniforme Quantisierer für signed input data hauptsächlich in mid-riser und mid-tread
mid-tread besitzt einen Rekonstruktionswert bei 0, während mid-riser einen Klassifikationsschwellenwert bei 0 hat
Die Formeln entsprechen dabei Folgendem
| Methode | Kodierung | Dekodierung |
|---|---|---|
| mid-tread | k = trunc(x L + 0.5) |
y_k = k / L |
| mid-riser | k = trunc(x L) |
y_k = (k + 0.5) / L |
Die Standardmethode ist eine mid-tread-Form mit L=255, die Alternative eine mid-riser-Form mit L=256
Die Standardmethode gewinnt den praktischen Vorteil, die Endpunkte direkt auf 0.0 und 1.0 abzubilden, verwendet dafür aber keine Intervallanordnung, die für 8-Bit-Eingaben optimal ist
Rekonstruktionsfehler und reale Bildverarbeitung
Wenn man selbst ein System entwirft, das gleichverteilte reelle Werte x ∈ [0, 1] in 8-Bit-Ganzzahlen kodiert und danach wieder zu reellen Werten rekonstruiert, ist die 256-Methode theoretisch präziser
Der darstellbare Bereich der Standardmethode ist [-0.5 / 255, 255.5 / 255], wodurch die Intervallabstände größer werden als für [0, 1] eigentlich nötig
Laut der Berechnung von StackOverflow-Nutzer Peter Mudrievskij beträgt der mittlere absolute Fehler bei Division durch 255 1 / 1020, bei Division durch 256 1 / 1024
Beim Einlesen und Verarbeiten bereits gespeicherter 8-Bit-RGB-Bilder wird die beim Speichern verlorene Information jedoch nicht wiederhergestellt
Wenn ein Bild mit Multiplikation mit 255 und anschließendem Runden quantisiert wurde, gewinnt man keine Präzision zurück, wenn man es beim Laden durch 256 teilt
Bilder von anderen wurden höchstwahrscheinlich mit der Standardmethode quantisiert; liest man sie mit der alternativen Formel ein, verwendet man theoretisch also einen falschen Skalierungsfaktor
Praktisch bedeutet das, dass Farben in einem leicht kleineren Bereich mit einem kleinen Offset verarbeitet werden
Mischt man die Kodierungs- und Dekodierungsschritte der beiden Quantisierer, entsteht fehlerhafter Code
Fazit
Wenn man von unbekannten Dritten bereitgestellte Bilder verarbeitet, sollte man RGB-Werte durch 255 normieren
Allein weil Fließkommawerte nicht exakt sind oder weil sich ein größerer abstrakter Rekonstruktionsfehler unschön anfühlt, gibt es kaum einen überzeugenden Grund, die 256-Methode zu wählen
Wenn man sowohl das Speichern als auch das Laden der Bilder vollständig kontrolliert, 0 nicht auf 0 abgebildet werden muss und es akzeptabel ist, dass der Verarbeitungscode an den 8-Bit-Dynamikbereich gebunden bleibt, kann man mit Division durch 256 eine etwas höhere theoretische Präzision anstreben
1 Kommentare
Lobste.rs-Meinungen
Falls es nicht intuitiv wirkt, hilft ein degenerierter 2-Bit-Fall. Wenn die einzigen möglichen ganzzahligen Werte 0, 1, 2 und 3 sind und man die Umwandlung von Integer zu Fließkomma vollständig durchrechnet, erhält man 0.0, 0.33..., 0.66..., 1.0, wenn man seltsames Verhalten vermeiden will, bei dem Schwarz/Weiß nicht Schwarz/Weiß ist oder die Abstände offensichtlich ungleichmäßig sind
Daher erfolgt die Rückumwandlung durch Multiplikation mit 3, nicht mit 4 (2^2)
Für die Rückumwandlung braucht man Quantisierung (Rundung), und genau dort wird die Symmetrie gebrochen
Wenn man einen gleichmäßigen reellen Gradienten im Bereich 0..=1 erzeugt und ihn auf 0, 1, 2, 3 quantisiert, sieht man, dass Multiplikation mit 3 kein gleichmäßiges Ergebnis liefert. Nach ×3 und
round()sind 1 und 2 überrepräsentiert; nach ×3 undflooroderceilwerden 0 oder 3 wie Sonderfälle an den Rand gefaltet, sodass der Gradient so wirkt, als nutze er nur 3 der 4 FarbenDie
/3- und×3-Logik sieht für exaktes Hin-und-zurück-Konvertieren von Zahlen in Ordnung aus, aber Zwischenwerte hängen stark von der Rundungswahl ab und werden wichtig, sobald man mit der Datenverarbeitung beginntGleichmäßige Integer-Anteile erhält man nur mit Multiplikation mit (4-ε) und anschließendem Abrunden, was äquivalent zu ×4,
floor()undclamp()ist. Es fühlt sich wie ein seltsamer Off-by-one- oder ε-Unterschied an, ist aber intuitiv die am besten aussehende LösungFür mich war die Antwort immer „natürlich“ [0.0..255.0], aber offenbar ist das nicht für alle selbstverständlich
Im Artikel wird gesagt, die „Extrem“-Intervalle hätten nur halb so viel Kapazität wie die anderen, aber auch dieses Framing halte ich für falsch
Wenn es keine Werte außerhalb von [0..1] gibt, ist das scheinbar schmalere Intervall nur ein Artefakt der Darstellung. Es wird nur schmaler gerendert, weil man die Buckets anhand des Wissens abgeschnitten hat, dass es keine Werte außerhalb des Bereichs gibt
Wenn es dagegen Werte außerhalb von [0..1] gibt, dann ist dieser Bereich unendlich. Der Artikel akzeptiert Letzteres, aber nicht Ersteres
Sobald man Ersteres akzeptiert, scheint das korrekte Verhalten klar zu sein, aber dass es überhaupt solche Artikel gibt, zeigt objektiv auch, dass das Problem nicht wirklich „klar“ ist :D
Wenn 0..<1 zu Integer 0 wird und 254>..255.0 zu Integer 255, dann wird 128 verschluckt. Vermutlich soll 127.5..128.5 zu 128 werden, aber wohin sollen dann diese Hälften gehen?
Wenn man alles ein wenig verschiebt, damit 128 passt, wird 0..0.99609375 auf Integer 0 gemappt
round()aufrufenDiese Methode fühlt sich für viele offenbar ziemlich natürlich an, und dürfte deshalb wegen ihrer Einfachheit zum Standard geworden sein
pngcrushkomprimiert. Oder meinst du, dass inhaltlich etwas mit dem Bild nicht stimmt?