Magic Bookof Algorithms EN

BildverarbeitungIMG-01

Weighted Voronoi Stippling

Ein Bild aus lauter Punkten: dicht, wo es dunkel ist, licht, wo es hell ist.

Laufzeit
O(Pixel · Punkte · Runden)
Verfahren
Lloyd-Relaxation
Zusatzspeicher
O(Punkte)
Punktgröße
Fläche = Tinte der Zelle

01Worum es geht

Punktieren ist eine alte Technik aus dem Kupferstich: Ein Bild entsteht nur aus Punkten, ohne eine einzige Linie und ohne Grauwerte. Dunkle Stellen bekommen viele Punkte, helle wenige. Das Auge mischt daraus von selbst wieder Tonwerte.

Die Schwierigkeit ist die Verteilung. Zufällig gestreute Punkte klumpen und lassen Löcher, ein regelmäßiges Raster wirkt maschinell und erzeugt Moiré-Muster. Gesucht ist etwas dazwischen: gleichmäßig verteilt, aber ohne erkennbares Muster.

Genau das leistet dieser Algorithmus. Er stammt von Adrian Secord (2002) und kombiniert zwei Ideen: Voronoi-Zellen teilen die Fläche unter den Punkten auf, und Lloyds Algorithmus rückt jeden Punkt so lange in die Mitte seiner Zelle, bis alles im Gleichgewicht ist. Das gewichtete im Namen ist der Trick, der aus einem Muster ein Bild macht.

Vorlage
Vorlage
Ergebnis
Ergebnis
Vorlage und Ergebnis: 6000 Punkte, 12 Runden. Erzeugt vom Beispiel weiter unten. Foto: US Navy, gemeinfrei.

02So funktioniert es

Tinte statt Helligkeit

Zuerst wird das Bild umgedreht: Aus Helligkeit wird Tinte. Ein schwarzer Pixel trägt die Tinte 1, ein weißer 0. Tinte ist die Größe, um die es im Rest des Verfahrens geht.

Startpunkte werfen

Die Punkte werden zufällig ins Bild geworfen, aber ein Wurf wird umso wahrscheinlicher verworfen, je heller die Stelle ist. Dieses Rejection Sampling liefert eine grobe erste Verteilung, die schon ungefähr stimmt, und spart damit einige Runden.

Zellen bilden und Schwerpunkte suchen

Jeder Pixel gehört zu dem Punkt, der ihm am nächsten liegt. Diese Aufteilung ist das Voronoi-Diagramm, auch wenn der Code es nie als Ganzes berechnet: Er fragt für jeden Pixel nur nach dem nächsten Punkt.

Für jeden Punkt wird dann der Schwerpunkt seiner Zelle berechnet, und zwar gewichtet mit der Tinte. Ein dunkler Pixel zieht stärker als ein heller. Der Punkt wandert auf diesen Schwerpunkt.

  1. Jeden Pixel dem nächsten Punkt zuordnen.
  2. Pro Punkt Summe von x · Tinte, y · Tinte und der Tinte selbst bilden.
  3. Punkt auf Summe(x · Tinte) / Summe(Tinte) setzen.

Das ist eine Runde Lloyd-Relaxation. Sie wird ein Dutzend Mal wiederholt. Die Punkte schieben sich dabei gegenseitig auseinander, bis jeder etwa gleich viel Tinte trägt. Weil in dunklen Bereichen mehr Tinte pro Fläche liegt, werden die Zellen dort kleiner und die Punkte dichter.

Punktgrößen bestimmen

Zum Schluss bekommt jeder Punkt eine Größe, und zwar nach einer einzigen Regel: Die Fläche des Punktes entspricht der Tinte seiner Zelle. Damit tragen alle Punkte zusammen exakt so viel Fläche, wie das Bild Tinte hat. Der Tonwert stimmt dadurch von selbst, ohne dass man irgendetwas nachjustieren muss.

03Ausprobieren

04Implementierung

MagicBook.Algorithms/Imaging/WeightedVoronoiStippling.csC#
using MagicBook.Algorithms.Randomness;

// Ein Punkt des fertigen Bildes. Der Radius zählt in Pixeln der Vorlage.
public readonly record struct Stipple(float X, float Y, float Radius);

public static class WeightedVoronoiStippling
{
    // Macht aus einem Bild Punkte: dunkle Bereiche bekommen viele, helle wenige.
    // brightness enthält width * height Werte von 0 (schwarz) bis 1 (weiß).
    public static Stipple[] Distribute(float[] brightness, int width, int height, int dotCount, int rounds, ulong seed)
    {
        // Tinte ist der Stoff, aus dem die Punkte sind, also genau andersherum als Helligkeit.
        var ink = new float[brightness.Length];
        for (var i = 0; i < ink.Length; i++)
            ink[i] = 1f - brightness[i];

        if (ink.Sum() <= 0)
            throw new ArgumentException("The picture is completely white, there is nothing to draw.", nameof(brightness));

        var points = Scatter(ink, width, height, dotCount, seed);

        // Lloyds Algorithmus: Jede Runde schiebt jeden Punkt in den Schwerpunkt der
        // Fläche, die ihm näher liegt als jedem anderen Punkt. Weil der Schwerpunkt mit
        // Tinte gewichtet wird, wandern die Punkte ins Dunkle und verteilen sich zugleich
        // gleichmäßig. Eine Handvoll Runden genügt.
        for (var round = 0; round < rounds; round++)
            Relax(points, ink, width, height);

        return Measure(points, ink, width, height);
    }

    // Wirft Punkte ins Bild und behält die, die auf genug Tinte landen.
    // Dieser Start stimmt schon ungefähr und spart dadurch später ein paar Runden.
    private static (float X, float Y)[] Scatter(float[] ink, int width, int height, int count, ulong seed)
    {
        var random = new Pcg(seed);
        var points = new (float X, float Y)[count];

        for (var i = 0; i < count; i++)
        {
            while (true)
            {
                var x = random.Next(width);
                var y = random.Next(height);

                // Je heller die Stelle, desto wahrscheinlicher wird der Wurf verworfen.
                if (random.NextDouble() > ink[y * width + x])
                    continue;

                points[i] = (x, y);
                break;
            }
        }

        return points;
    }

    // Eine Runde von Lloyds Algorithmus.
    private static void Relax((float X, float Y)[] points, float[] ink, int width, int height)
    {
        var sumX = new float[points.Length];
        var sumY = new float[points.Length];
        var sumInk = new float[points.Length];

        for (var y = 0; y < height; y++)
        {
            for (var x = 0; x < width; x++)
            {
                var weight = ink[y * width + x];

                // Weiße Pixel tragen keine Tinte und ziehen deshalb auch nichts.
                if (weight <= 0)
                    continue;

                var nearest = Nearest(points, x, y);

                sumX[nearest] += x * weight;
                sumY[nearest] += y * weight;
                sumInk[nearest] += weight;
            }
        }

        for (var i = 0; i < points.Length; i++)
        {
            // Ein Punkt, der gar keine Tinte erwischt hat, bleibt einfach liegen.
            if (sumInk[i] > 0)
                points[i] = (sumX[i] / sumInk[i], sumY[i] / sumInk[i]);
        }
    }

    // Stumpfe Suche über alle Punkte. Quadrierte Abstände genügen hier, weil die
    // Wurzel nichts daran ändert, welcher davon der kleinste ist.
    private static int Nearest((float X, float Y)[] points, int x, int y)
    {
        var best = 0;
        var bestDistance = float.MaxValue;

        for (var i = 0; i < points.Length; i++)
        {
            var dx = points[i].X - x;
            var dy = points[i].Y - y;
            var distance = dx * dx + dy * dy;

            if (distance >= bestDistance)
                continue;

            bestDistance = distance;
            best = i;
        }

        return best;
    }

    // Zum Schluss bekommt jeder Punkt eine Größe. Die Regel ist einfach und sorgt
    // dafür, dass das Ergebnis stimmt: Ein Punkt bedeckt genau so viel Fläche, wie in
    // seiner Zelle Tinte steckt. Alle Punkte zusammen tragen also die Tinte des Bildes.
    private static Stipple[] Measure((float X, float Y)[] points, float[] ink, int width, int height)
    {
        var sumInk = new float[points.Length];

        for (var y = 0; y < height; y++)
        {
            for (var x = 0; x < width; x++)
                sumInk[Nearest(points, x, y)] += ink[y * width + x];
        }

        var stipples = new Stipple[points.Length];

        for (var i = 0; i < points.Length; i++)
        {
            // Die Kreisfläche ist Pi mal Radius im Quadrat, das hier ist der Weg zurück.
            var radius = MathF.Sqrt(sumInk[i] / MathF.PI);

            stipples[i] = new Stipple(points[i].X, points[i].Y, radius);
        }

        return stipples;
    }
}

05Beispiel

MagicBook.Console/Examples/WeightedVoronoiStipplingExample.csC#
// file zeigt auf ein Graustufen-Portrait, 260 x 300 Pixel.
var image = PngImage.Load(file);

var dots = WeightedVoronoiStippling.Distribute(
    image.ToBrightness(), image.Width, image.Height, dotCount: 6000, rounds: 12, seed: 4711);

// Die Punkte doppelt so groß zeichnen wie die Vorlage, damit sie rund bleiben.
StippleRenderer.Render(dots, image.Width, image.Height, scale: 2).Save(result);

Console.WriteLine($"{dots.Length} dots, average radius {dots.Average(dot => dot.Radius):0.00} pixels");
Console.WriteLine($"written to {Path.GetFileName(result)}");
Ausgabe der Konsole
6000 dots, average radius 1.45 pixels
written to grace-hopper-stippled.png

06Gut zu wissen

  • Die Suche nach dem nächsten Punkt geht stur über alle Punkte. Das ist gut lesbar, aber der Grund, warum das Beispiel ein kleines Bild verwendet: Der Aufwand ist Pixel mal Punkte mal Runden. Für große Bilder nimmt man einen k-d-Baum oder rechnet das Voronoi-Diagramm mit dem Jump-Flooding-Algorithmus auf der Grafikkarte.
  • Vorlagen brauchen fast immer eine Tonwertkorrektur. Ohne sie sammelt schon ein hellgrauer Hintergrund Tinte, und das Ergebnis wird matschig. Das Portrait hier wurde vorher aufgehellt.
  • Das Ergebnis besteht aus Kreisen mit Mittelpunkt und Radius, ist also von Natur aus eine Vektorgrafik. Statt in ein PNG lässt es sich mit wenigen Zeilen in eine SVG-Datei schreiben, und die kann ein Stiftplotter direkt zeichnen.
  • Verwandt ist das Ganze mit dem Rastern im Druck. Der Unterschied: Ein Druckraster ist regelmäßig, diese Punkte sind es bewusst nicht. Deshalb entsteht kein Moiré.
  • Die Vorlage zeigt Grace Hopper. Sie hat den ersten Compiler gebaut und den Begriff Debugging geprägt, nachdem 1947 eine Motte in einem Relais des Mark II steckte. Das Foto ist eine Aufnahme der US Navy und gemeinfrei.