Magic Bookof Algorithms DE

Image processingIMG-01

Weighted Voronoi Stippling

A picture made of nothing but dots: dense where it is dark, sparse where it is light.

Running time
O(pixels · dots · rounds)
Method
Lloyd relaxation
Extra memory
O(dots)
Dot size
area = ink of its cell

01What it is about

Stippling is an old technique from copperplate engraving: a picture built from dots alone, without a single line and without grey tones. Dark areas get many dots, light areas few. The eye mixes them back into shades on its own.

The hard part is the distribution. Randomly scattered dots clump together and leave holes, a regular grid looks mechanical and produces moiré patterns. What you want is something in between: evenly spread, but without a recognisable pattern.

That is exactly what this algorithm delivers. It comes from Adrian Secord (2002) and combines two ideas: Voronoi cells divide the area among the dots, and Lloyd's algorithm keeps moving every dot into the middle of its cell until everything is in balance. The weighted part of the name is the trick that turns a pattern into a picture.

Source
Source
Result
Result
Source and result: 6000 dots, 12 rounds, produced by the example below. Photo: US Navy, public domain.

02How it works

Ink instead of brightness

First the picture is turned around: brightness becomes ink. A black pixel carries an ink of 1, a white one 0. Ink is the quantity everything else in this algorithm is about.

Throwing the first dots

The dots are thrown at the picture at random, but a throw is rejected the more likely the brighter the spot is. This rejection sampling gives a rough first distribution that is already roughly right, which saves a couple of rounds later on.

Building cells and finding centres

Every pixel belongs to the dot closest to it. That division is the Voronoi diagram, even though the code never computes it as a whole: it only ever asks which dot is nearest to a pixel.

For each dot the centre of gravity of its cell is then calculated, weighted by ink. A dark pixel pulls harder than a light one. The dot moves onto that centre.

  1. Assign every pixel to the nearest dot.
  2. Per dot sum up x · ink, y · ink and the ink itself.
  3. Move the dot to sum(x · ink) / sum(ink).

That is one round of Lloyd relaxation, repeated a dozen times. The dots push each other apart until every one of them carries about the same amount of ink. Because dark areas hold more ink per area, the cells get smaller there and the dots move closer together.

Choosing the dot sizes

Finally every dot gets a size, following a single rule: the area of a dot equals the ink of its cell. All dots together therefore cover exactly as much area as the picture has ink. The tones come out right by themselves, without any tuning.

03Try it

04Implementation

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

// One dot of the finished picture. The radius is measured in pixels of the source.
public readonly record struct Stipple(float X, float Y, float Radius);

public static class WeightedVoronoiStippling
{
    // Turns a picture into dots: dark areas get many, bright areas few.
    // brightness holds width * height values from 0 (black) to 1 (white).
    public static Stipple[] Distribute(float[] brightness, int width, int height, int dotCount, int rounds, ulong seed)
    {
        // Ink is what the dots are made of, so it is the other way round than brightness.
        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);

        // Lloyd's algorithm: every round moves each dot into the centre of gravity of
        // the area that is closer to it than to any other dot. Because the centre is
        // weighted by ink, the dots drift towards the dark parts and spread out evenly
        // at the same time. A handful of rounds is enough.
        for (var round = 0; round < rounds; round++)
            Relax(points, ink, width, height);

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

    // Throws dots at the picture and keeps the ones that land on enough ink.
    // This start is already roughly right, which saves a couple of rounds later.
    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);

                // The brighter the spot, the more likely the throw is rejected.
                if (random.NextDouble() > ink[y * width + x])
                    continue;

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

        return points;
    }

    // One round of Lloyd's algorithm.
    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];

                // White pixels carry no ink and therefore pull nothing.
                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++)
        {
            // A dot that caught no ink at all simply stays where it is.
            if (sumInk[i] > 0)
                points[i] = (sumX[i] / sumInk[i], sumY[i] / sumInk[i]);
        }
    }

    // Plain search over all dots. Squared distances are enough here, because the
    // square root would not change which one comes out smallest.
    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;
    }

    // Finally each dot gets a size. The rule is simple and it is what makes the
    // result look right: a dot covers exactly as much area as there is ink in its
    // cell. All dots together therefore carry exactly the ink of the picture.
    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++)
        {
            // Area of a circle is pi times radius squared, so this is the way back.
            var radius = MathF.Sqrt(sumInk[i] / MathF.PI);

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

        return stipples;
    }
}

05Example

MagicBook.Console/Examples/WeightedVoronoiStipplingExample.csC#
// file points to a grayscale portrait, 260 x 300 pixels.
var image = PngImage.Load(file);

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

// Draw the dots twice as large as the original, so they stay round.
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)}");
Console output
6000 dots, average radius 1.45 pixels
written to grace-hopper-stippled.png

06Good to know

  • The search for the nearest dot plainly walks over all dots. That reads well, but it is the reason the example uses a small picture: the effort is pixels times dots times rounds. For large images you would use a k-d tree, or compute the Voronoi diagram with the jump flooding algorithm on the graphics card.
  • Source pictures almost always need a tone correction first. Without it even a light grey background collects ink and the result turns muddy. The portrait here was brightened beforehand.
  • The result consists of circles with a centre and a radius, so it is a vector graphic by nature. Instead of a PNG it can be written into an SVG file in a few lines, and a pen plotter can draw that directly.
  • All of this is related to halftoning in print. The difference: a print screen is regular, these dots deliberately are not. That is why no moiré appears.
  • The source picture shows Grace Hopper. She built the first compiler and coined the term debugging after a moth got stuck in a relay of the Mark II in 1947. The photograph was taken by the US Navy and is in the public domain.