1 Punkte von GN⁺ 7 시간 전 | 1 Kommentare | Auf WhatsApp teilen
  • 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.0 jeden 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 / 1024 gegenüber 1 / 1020 bei 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

 
GN⁺ 7 시간 전
Lobste.rs-Meinungen
  • Sieht unordentlich aus, ist aber richtig: 255 ist der korrekte Wert
    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)
    • Der erste Teil stimmt, aber daraus folgt nicht, dass man bei der „Rückumwandlung mit 3 multiplizieren muss und nicht mit 4“
      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 und floor oder ceil werden 0 oder 3 wie Sonderfälle an den Rand gefaltet, sodass der Gradient so wirkt, als nutze er nur 3 der 4 Farben
      Die /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 beginnt
      Gleichmäßige Integer-Anteile erhält man nur mit Multiplikation mit (4-ε) und anschließendem Abrunden, was äquivalent zu ×4, floor() und clamp() ist. Es fühlt sich wie ein seltsamer Off-by-one- oder ε-Unterschied an, ist aber intuitiv die am besten aussehende Lösung
  • Der Titel war ziemlich verwirrend. Ich weiß nicht, ob das Absicht war, aber am Ende wirkt es eher wie die Frage: „Entspricht 0..1 dem Bereich [0..255.0] oder [0.5..255.5]?“
    Fü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…255.0 wirklich so selbstverständlich ist, welcher Bereich von Fließkommawerten sollte dann zu Integer 0 zurückkehren, und welcher zu Integer 255?
      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
  • Auch der Standardansatz scheint daraus entstanden zu sein, dass Leute ganz natürlich round() aufrufen
    Diese Methode fühlt sich für viele offenbar ziemlich natürlich an, und dürfte deshalb wegen ihrer Einfachheit zum Standard geworden sein
  • Ich frage mich, ob auch die umgekehrte Variante dessen nützlich wäre, was man mit 256 erreichen wollte. Also 0.0 auf 0, 1.0 auf 255 und alle übrigen Fließkommawerte auf 1 bis 254 abzubilden
    uint8_t output = 0.0f >= result  
                     ? 0  
                     : 1.0f <= result  
                     ? 255  
                     : 1 + 253*result;  
    
    Es wäre schön, wenn Schwarz während der Verarbeitung Schwarz bliebe und Weiß Weiß
    • Dann bekommen 0 und 255 innerhalb des Einheitsintervalls einen größeren Anteil als die anderen Zahlen. Ungefähr 0,8 %, also 255/253
  • Das erste Bild scheint in meiner Umgebung kaputt dargestellt zu werden
    • Hier ist der Autor des Artikels. Meinst du, dass die Bilddatei beschädigt ist? Ich habe sie mit pngcrush komprimiert. Oder meinst du, dass inhaltlich etwas mit dem Bild nicht stimmt?