BildverarbeitungIMG-02
Dithering
Wenige Farben, die aus der Ferne wie viele aussehen.
- Laufzeit
- O(Pixel)
- Verfahren
- Fehlerdiffusion
- Zusatzspeicher
- O(Pixel)
- Fehler geht an
- 4 Nachbarn: 7/16, 3/16, 5/16, 1/16
01Worum es geht
Ein Bildschirm mit 16 Farben, ein GIF mit 256, ein E-Book-Reader mit Schwarz und Weiß, ein Belegdrucker mit einer einzigen Farbe Tinte: Immer soll ein Bild mit sehr viel weniger Farben auskommen, als es eigentlich hat. Nimmt man dafür einfach die jeweils nächstliegende erlaubte Farbe, zerfällt das Bild in Flecken. Aus einem weichen Himmel werden harte Streifen, aus einem Farbverlauf werden Stufen. Das nennt sich Banding, und es fällt sofort auf.
Dithering löst das mit einem Handel: Es tauscht die großen Flecken gegen ein feines Korn. Die einzelne Farbe stimmt danach schlechter als vorher, aber über eine kleine Fläche gemittelt stimmt sie viel besser — und genau so schaut das Auge auf ein Bild.
Das bekannteste Verfahren stammt von Robert Floyd und Louis Steinberg (1976) und ist erstaunlich kurz: Es geht die Pixel der Reihe nach durch und reicht den Fehler, den es gerade gemacht hat, an die Nachbarn weiter, die noch drankommen.




02So funktioniert es
Erlaubte Farben festlegen
levels sagt, wie viele Werte ein Kanal annehmen darf. Bei zwei Stufen ist Rot entweder 0 oder 255, bei drei Stufen kommt 128 dazu. Weil das für Rot, Grün und Blau gilt, erlauben zwei Stufen acht Farben, drei Stufen 27 und vier Stufen 64.
Snap rundet einen Wert auf die nächste erlaubte Stufe. Ohne alles Weitere ist das schon die einfache Quantisierung — und genau die erzeugt die Flecken.
Den Fehler weiterreichen
Der Trick ist, den Rundungsfehler nicht wegzuwerfen. Wollte ein Pixel 0,4 sein und wurde zu 0, dann sind 0,4 übrig geblieben, und diese 0,4 werden auf die Nachbarn verteilt, die noch nicht bearbeitet sind:
X 7/16 3/16 5/16 1/16
X ist der gerade bearbeitete Pixel, gearbeitet wird zeilenweise von links nach rechts. Die vier Anteile ergeben zusammen genau 16/16. Nichts geht verloren, nichts kommt hinzu — deshalb bleibt die mittlere Helligkeit des Bildes erhalten, obwohl jeder einzelne Pixel daneben liegt.
Der rechte Nachbar bekommt mit 7/16 am meisten ab, weil er als Nächstes an der Reihe ist. Der Rest geht in die nächste Zeile, mit dem Schwerpunkt genau unter dem aktuellen Pixel.
Die geordnete Variante
Es geht auch ohne Weiterreichen. Beim Ordered Dithering wird jeder Pixel vor dem Runden um einen festen Betrag angestupst, der aus einer kleinen Matrix kommt. Die klassische stammt von Bryce Bayer (1973):
0 8 2 10 12 4 14 6 3 11 1 9 15 7 13 5
Die Matrix wiederholt sich über das ganze Bild. Benachbarte Zahlen liegen darin so weit wie möglich auseinander, damit das Muster gleichmäßig bleibt. Ergebnis ist ein sichtbar gewebtes Raster statt eines zufälligen Korns.
Der Vorteil: Jeder Pixel ist unabhängig von allen anderen. Das lässt sich parallel rechnen, in einem Shader, ohne jede Reihenfolge. Der Nachteil: Man sieht das Muster.
Warum das funktioniert
Beide Verfahren machen dasselbe. Sie schieben den Fehler in hohe Frequenzen — also in ein feines Korn, das sich von Pixel zu Pixel ändert. Auge, Druckraster und Bildschirm sind allesamt Tiefpässe: Sie mitteln über kleine Flächen. Was sie durchlassen, ist der Mittelwert, und der stimmt.
03Ausprobieren
04Implementierung
// Ein Bild, das nur wenige Farben verwenden darf, zerfällt in Flecken: Alles, was
// nah beieinander liegt, landet auf derselben Farbe, und weiche Übergänge werden zu
// harten Stufen. Dithering tauscht diese Flecken gegen ein feines Korn, das das Auge
// wieder zu der Farbe zusammenmischt, die gemeint war.
public static class Dithering
{
// Floyd und Steinberg, 1976. Jeder Pixel bekommt die nächstgelegene Farbe, die noch
// erlaubt ist, und der Unterschied zwischen gewünschter und verwendeter Farbe wird
// an die Nachbarn weitergereicht, die noch an die Reihe kommen.
public static PngImage FloydSteinberg(PngImage image, int levels)
{
var channels = ToChannels(image, levels);
for (var y = 0; y < image.Height; y++)
{
for (var x = 0; x < image.Width; x++)
{
for (var channel = 0; channel < 3; channel++)
{
var index = ((y * image.Width + x) * 3) + channel;
var wanted = channels[index];
var used = Snap(wanted, levels);
channels[index] = used;
// Der Fehler wird auf vier Nachbarn verteilt. Das meiste geht nach
// rechts, der Rest in die nächste Zeile; zusammen ergeben die Teile
// genau den ganzen Fehler, es geht also nichts verloren und nichts hinzu.
var error = wanted - used;
Hand(channels, image, x + 1, y, channel, error * 7f / 16f);
Hand(channels, image, x - 1, y + 1, channel, error * 3f / 16f);
Hand(channels, image, x, y + 1, channel, error * 5f / 16f);
Hand(channels, image, x + 1, y + 1, channel, error * 1f / 16f);
}
}
}
return ToImage(channels, image);
}
// Die andere Schule: Statt den Fehler weiterzureichen, wird jeder Pixel vor dem
// Runden um einen festen Betrag gestupst. Der Betrag kommt aus einer kleinen
// Matrix, die sich über das Bild wiederholt, weshalb das Ergebnis gewebt aussieht.
public static PngImage Ordered(PngImage image, int levels)
{
var channels = ToChannels(image, levels);
var step = 1f / (levels - 1);
for (var y = 0; y < image.Height; y++)
{
for (var x = 0; x < image.Width; x++)
{
// Der Matrixwert läuft von 0 bis 15, der Stups von minus einer halben Stufe
// bis plus einer halben Stufe.
var nudge = ((Matrix[(y % 4) * 4 + (x % 4)] + 0.5f) / 16f - 0.5f) * step;
for (var channel = 0; channel < 3; channel++)
{
var index = ((y * image.Width + x) * 3) + channel;
channels[index] = Snap(channels[index] + nudge, levels);
}
}
}
return ToImage(channels, image);
}
// Dieselbe Reduktion ohne jedes Dithering: Jeder Pixel nimmt einfach die nächste
// erlaubte Farbe. Daran werden die beiden oberen gemessen.
public static PngImage Quantize(PngImage image, int levels)
{
var channels = ToChannels(image, levels);
for (var index = 0; index < channels.Length; index++)
channels[index] = Snap(channels[index], levels);
return ToImage(channels, image);
}
// Bayers 4-x-4-Matrix. Benachbarte Zahlen liegen so weit auseinander wie möglich,
// damit das Muster gleichmäßig bleibt und nirgends verklumpt.
private static readonly int[] Matrix =
[
0, 8, 2, 10,
12, 4, 14, 6,
3, 11, 1, 9,
15, 7, 13, 5,
];
// Die erlaubten Werte eines Kanals liegen gleichmäßig zwischen 0 und 1: Bei drei
// Stufen sind das 0, 0,5 und 1. Snap wählt den nächstgelegenen davon.
private static float Snap(float value, int levels)
{
var steps = levels - 1;
return Math.Clamp(MathF.Round(value * steps) / steps, 0f, 1f);
}
// Gibt einen Teil des Fehlers an einen Nachbarn, sofern es diesen Nachbarn gibt.
private static void Hand(float[] channels, PngImage image, int x, int y, int channel, float part)
{
if (x < 0 || y < 0 || x >= image.Width || y >= image.Height)
return;
channels[((y * image.Width + x) * 3) + channel] += part;
}
// Rot, Grün und Blau als Werte von 0 bis 1. Die Fehlerdiffusion muss mit Brüchen
// rechnen, ein Byte je Kanal würde sie sofort wegrunden.
private static float[] ToChannels(PngImage image, int levels)
{
ArgumentOutOfRangeException.ThrowIfLessThan(levels, 2);
var channels = new float[image.Width * image.Height * 3];
for (var pixel = 0; pixel < image.Width * image.Height; pixel++)
{
for (var channel = 0; channel < 3; channel++)
channels[pixel * 3 + channel] = image.Pixels[pixel * 4 + channel] / 255f;
}
return channels;
}
private static PngImage ToImage(float[] channels, PngImage original)
{
var pixels = new byte[original.Width * original.Height * 4];
for (var pixel = 0; pixel < original.Width * original.Height; pixel++)
{
for (var channel = 0; channel < 3; channel++)
pixels[pixel * 4 + channel] = (byte)MathF.Round(channels[pixel * 3 + channel] * 255f);
pixels[pixel * 4 + 3] = original.Pixels[pixel * 4 + 3];
}
return new PngImage { Width = original.Width, Height = original.Height, Pixels = pixels };
}
}
05Stufe für Stufe weniger Farben
// file zeigt auf ein Farbfoto, 320 x 240 Pixel.
var photo = PngImage.Load(file);
// Levels ist die Anzahl der Werte, die ein Kanal annehmen darf. Zwei Stufen lassen
// acht Farben übrig, drei Stufen 27 und vier Stufen 64.
foreach (var levels in new[] { 2, 3, 4 })
{
var dithered = Dithering.FloydSteinberg(photo, levels);
var target = Path.Combine(folder, $"grand-prismatic-spring-{levels}.png");
dithered.Save(target);
Console.WriteLine(
$"{levels} levels: {levels * levels * levels,3} colours allowed, " +
$"{dithered.ColourCount(),3} used, {new FileInfo(target).Length / 1024,3} KiB");
}
2 levels: 8 colours allowed, 8 used, 26 KiB
3 levels: 27 colours allowed, 25 used, 28 KiB
4 levels: 64 colours allowed, 47 used, 31 KiB
06Wie groß ist der Fehler wirklich?
var photo = PngImage.Load(file);
// Zwei Arten, den Fehler zu messen: Pixel für Pixel und über Quadrate von
// 4 x 4 Pixeln. Die zweite kommt dem Auge näher, denn es mittelt erst über
// eine kleine Fläche, bevor es urteilt.
Console.WriteLine("levels colours method pixel 4 x 4");
foreach (var levels in new[] { 2, 3, 4 })
{
(string Name, PngImage Result)[] ways =
[
("plain", Dithering.Quantize(photo, levels)),
("ordered", Dithering.Ordered(photo, levels)),
("diffused", Dithering.FloydSteinberg(photo, levels)),
];
foreach (var (name, result) in ways)
{
Console.WriteLine(
$"{levels,6} {levels * levels * levels,7} {name,-8} " +
$"{Error(photo, result, 1),6:0.0} {Error(photo, result, 4),6:0.0}");
}
}
// Der mittlere Unterschied zum Original, gemessen über Quadrate der angegebenen
// Größe. Größe 1 vergleicht einzelne Pixel, Größe 4 kleine Flächen.
static double Error(PngImage original, PngImage result, int size)
{
var sum = 0.0;
var squares = 0;
for (var y = 0; y + size <= original.Height; y += size)
{
for (var x = 0; x + size <= original.Width; x += size)
{
for (var channel = 0; channel < 3; channel++)
{
var difference = 0;
for (var offsetY = 0; offsetY < size; offsetY++)
{
for (var offsetX = 0; offsetX < size; offsetX++)
{
var pixel = (((y + offsetY) * original.Width) + x + offsetX) * 4 + channel;
difference += original.Pixels[pixel] - result.Pixels[pixel];
}
}
sum += Math.Abs(difference) / (double)(size * size);
squares++;
}
}
}
return sum / squares;
}
levels colours method pixel 4 x 4
2 8 plain 80.5 63.8
2 8 ordered 103.0 7.8
2 8 diffused 102.1 8.3
3 27 plain 34.1 22.1
3 27 ordered 44.6 4.9
3 27 diffused 42.6 4.3
4 64 plain 21.0 11.6
4 64 ordered 28.1 3.6
4 64 diffused 25.8 2.9
07Gut zu wissen
- Das zweite Beispiel misst genau das nach. Pixel für Pixel ist das geditherte Bild schlechter als die stumpfe Quantisierung, über 4 × 4 Pixel gemittelt ist es rund achtmal besser. Beide Zahlen stehen nebeneinander in der Ausgabe.
- Gedithertes Rauschen ist schwer zu komprimieren: Es gibt keine gleichen Nachbarpixel mehr, von denen die Lauflängenkodierung lebt. Rettung ist hier die Palette: Die Bilder oben verwenden höchstens 64 Farben, also genügt ein Byte pro Pixel plus eine Farbtabelle. Der PNG-Schreiber der Solution erkennt das selbst, deshalb sind die Dateien 26 bis 31 KiB groß statt etwa 150.
- Klassische Erweiterung: Serpentinen. Man geht jede zweite Zeile rückwärts durch. Das verhindert, dass sich der Fehler immer in dieselbe Richtung schiebt und wurmartige Spuren bildet.
- Atkinson-Dithering (Apple, 1984) verteilt nur
6/8des Fehlers und wirft den Rest weg. Das Bild verliert Kontrast in den Extremen, wirkt dafür klarer — der Look der ersten Macintosh-Bildschirme. - Sauber wäre die Fehlerrechnung in linearem Licht, nicht auf den sRGB-Werten, wie es hier und in den meisten Implementierungen geschieht. In den Mitteltönen ist der Unterschied klein, in den dunklen Bereichen sichtbar.
- Dithering ist überall dort noch aktuell, wo Ausgabegeräte wenige Zustände kennen: E-Ink, Thermodrucker, LED-Matrizen, Laserschneider, Stickmaschinen. Und in Spielen als Stilmittel, weil es nach 1990 aussieht.
- Die Vorlage zeigt die Grand Prismatic Spring im Yellowstone. Die Farben kommen von Bakterienmatten, die je nach Wassertemperatur unterschiedliche Pigmente bilden — außen orange und braun bei etwa 50 °C, in der Mitte tiefblau bei über 70 °C, wo kaum noch etwas lebt. Das Foto ist eine Aufnahme des National Park Service und gemeinfrei.