Magic Bookof Algorithms EN

BildverarbeitungIMG-05

Marching Squares

Aus einem Feld von Zahlen wird eine Linie: sechzehn Fälle, mehr braucht es nicht.

Laufzeit
O(Zellen)
Fälle
16 in 2D, 256 in 3D
Genauigkeit
interpoliert, nicht gerastert
Ergebnis
lose Liniensegmente

01Worum es geht

Ein Feld aus Zahlen — Höhen, Temperaturen, Helligkeiten, Dichten — und die Frage, wo genau ein bestimmter Wert hindurchläuft. Die Antwort ist eine Linie: die Höhenlinie einer Landkarte, die Isotherme einer Wetterkarte, der Umriss eines Objekts in einem CT-Schnitt.

Der Trick, sie zu finden, ist so einfach, dass man ihn kaum für einen Algorithmus hält: Schau immer nur vier benachbarte Werte gleichzeitig an. Jeder davon liegt entweder über dem gesuchten Wert oder darunter. Das ergibt sechzehn mögliche Muster, und für jedes ist ein für alle Mal festgelegt, wie die Linie durch dieses kleine Quadrat verläuft.

Das Feld: Perlin Noise als Landschaft
Das Feld: Perlin Noise als Landschaft
Fünf Höhen, herausgezogen
Fünf Höhen, herausgezogen
Links die Zahlen, rechts die Linien. Erzeugt vom Beispiel weiter unten, mit derselben Perlin-Landschaft wie auf der Rausch-Seite.

02So funktioniert es

Vier Ecken, ein Index

Für jede Zelle des Feldes werden die vier Ecken abgefragt und zu vier Bits zusammengesetzt:

8 --- 4        Ecke über dem Wert -> Bit gesetzt
|     |        0 und 15: nichts zu tun,
1 --- 2        die Linie geht hier nicht durch

Die Zahl 0 bis 15 ist die Nachschlagenummer. Sie sagt, welche der vier Kanten die Linie schneidet: 3 heißt links nach rechts, 6 heißt oben nach unten, und so weiter. Weil das Muster und sein Gegenteil dieselbe Linie ergeben (nur die Seiten sind vertauscht), reichen acht Fälle für vierzehn Nummern.

Interpolieren statt runden

Der Punkt, an dem die Linie eine Kante schneidet, wird nicht in die Mitte gesetzt, sondern berechnet. Stehen an den Ecken 0,2 und 0,6 und gesucht ist 0,3, dann liegt der Schnitt bei einem Viertel:

anteil = (wert − von) / (nach − von)

Das ist der Unterschied zwischen einer treppigen und einer glatten Linie, und es kostet eine Division. Ohne diese Zeile sähe eine Höhenlinie aus wie ein Pixelbild aus den Achtzigern.

Die zwei zweideutigen Fälle

Bei den Nummern 5 und 10 liegen zwei diagonal gegenüberliegende Ecken oben und die anderen beiden unten. Hier gibt es zwei gleichermaßen zulässige Antworten: zwei getrennte Linien, die entweder die eine oder die andere Ecke abschneiden. Wer hier einfach wählt, bekommt Löcher, weil die Nachbarzelle sich anders entscheiden kann.

Der übliche Ausweg steht auch hier im Code: Der Mittelwert der vier Ecken entscheidet. Er ist für beide beteiligten Zellen derselbe, also können sie sich nicht widersprechen.

03Ausprobieren

04Implementierung

MagicBook.Algorithms/Imaging/MarchingSquares.csC#
// Ein Stück einer Höhenlinie, in den Koordinaten des Feldes, in dem es gefunden wurde.
public readonly record struct Segment(float X1, float Y1, float X2, float Y2);

// Ein Feld aus Zahlen - Höhen, Temperaturen, Helligkeiten - und die Frage, wo genau
// ein bestimmter Wert hindurchläuft. Die Antwort ist eine Linie, und der Trick, sie
// zu finden, ist, immer nur vier benachbarte Werte anzuschauen. Jeder davon liegt
// entweder über dem Wert oder darunter, das ergibt sechzehn Eckmuster,
// und für jedes davon steht fest, wie die Linie durch dieses Quadrat läuft.
public static class MarchingSquares
{
    public static Segment[] Contour(float[] values, int width, int height, float level)
    {
        var segments = new List<Segment>();

        for (var y = 0; y < height - 1; y++)
        {
            for (var x = 0; x < width - 1; x++)
            {
                var topLeft = values[y * width + x];
                var topRight = values[y * width + x + 1];
                var bottomRight = values[(y + 1) * width + x + 1];
                var bottomLeft = values[(y + 1) * width + x];

                // Ein Bit je Ecke: Liegt sie über dem Wert oder nicht?
                var corners = (topLeft > level ? 8 : 0)
                    | (topRight > level ? 4 : 0)
                    | (bottomRight > level ? 2 : 0)
                    | (bottomLeft > level ? 1 : 0);

                if (corners is 0 or 15)
                    continue;

                // Wo die Linie eine Kante schneidet, wird nicht geraten, sondern interpoliert:
                // Stehen an den Ecken 0,2 und 0,6 und gesucht ist 0,3, dann liegt der Schnitt
                // bei einem Viertel. Genau das macht die Höhenlinie glatt
                // statt treppig.
                var top = new Segment(x + Between(topLeft, topRight, level), y, 0, 0);
                var bottom = new Segment(x + Between(bottomLeft, bottomRight, level), y + 1, 0, 0);
                var left = new Segment(x, y + Between(topLeft, bottomLeft, level), 0, 0);
                var right = new Segment(x + 1, y + Between(topRight, bottomRight, level), 0, 0);

                switch (corners)
                {
                    case 1 or 14: Add(segments, left, bottom); break;
                    case 2 or 13: Add(segments, bottom, right); break;
                    case 3 or 12: Add(segments, left, right); break;
                    case 4 or 11: Add(segments, top, right); break;
                    case 6 or 9: Add(segments, top, bottom); break;
                    case 7 or 8: Add(segments, left, top); break;

                    // Zwei diagonal gegenüberliegende Ecken liegen oben, die anderen beiden
                    // unten. Beide Arten, sie zu verbinden, sind zulässig, und der
                    // Mittelwert der vier entscheidet, welche genommen wird - so können
                    // sich benachbarte Zellen nicht widersprechen.
                    case 5 or 10:
                        var middle = (topLeft + topRight + bottomRight + bottomLeft) / 4;

                        // Im Fall 5 liegen die oberen Ecken rechts oben und links unten,
                        // im Fall 10 andersherum, deshalb fällt dieselbe Entscheidung
                        // spiegelverkehrt aus.
                        var joinLeftToTop = middle > level == (corners == 5);

                        if (joinLeftToTop)
                        {
                            Add(segments, left, top);
                            Add(segments, bottom, right);
                        }
                        else
                        {
                            Add(segments, left, bottom);
                            Add(segments, top, right);
                        }

                        break;
                }
            }
        }

        return [.. segments];
    }

    // Wie weit zwischen zwei Ecken der Wert liegt, von 0 bis 1.
    private static float Between(float from, float to, float level) =>
        Math.Abs(to - from) < 1e-6f ? 0.5f : Math.Clamp((level - from) / (to - from), 0f, 1f);

    private static void Add(List<Segment> segments, Segment from, Segment to)
    {
        // Läuft der Wert genau durch eine Ecke, können beide Schnitte auf derselben
        // Ecke landen. So ein Stück hat keine Länge und bleibt weg.
        if (from.X1 == to.X1 && from.Y1 == to.Y1)
            return;

        segments.Add(new Segment(from.X1, from.Y1, to.X1, to.Y1));
    }
}

05Höhenlinien einer Landschaft

MagicBook.Console/Examples/MarchingSquaresExample.csC#
const int width = 120;
const int height = 90;

// Eine Landschaft aus Perlin Noise und die Frage, wo bestimmte Höhen
// hindurchlaufen. Genau das sind die Höhenlinien einer Karte.
var random = new Pcg(20240921);
var seeds = new float[width * height];

for (var i = 0; i < seeds.Length; i++)
    seeds[i] = (float)random.NextDouble();

var land = PerlinNoise.Generate(seeds, width, height, octaves: 5, persistence: 0.5f);

// Perlin Noise nutzt nicht den ganzen Bereich von 0 bis 1, deshalb werden die
// Höhen zuerst gestreckt. Sonst fänden die unteren Höhenlinien nichts.
var lowest = land.Min();
var span = land.Max() - lowest;

for (var i = 0; i < land.Length; i++)
    land[i] = (land[i] - lowest) / span;

var lines = new List<Segment>();

Console.WriteLine("height  pieces of line");

foreach (var level in new[] { 0.2f, 0.35f, 0.5f, 0.65f, 0.8f })
{
    var contour = MarchingSquares.Contour(land, width, height, level);

    lines.AddRange(contour);

    Console.WriteLine($"{level,6:0.00}  {contour.Length,14}");
}

// Jedes Stück wird für sich gezeichnet. Zu geschlossenen Ringen sortiert werden
// sie hier nicht - für ein Bild braucht es das nicht, für einen Plotter schon.
ContourRenderer.Render([.. lines], width, height, scale: 4, background: land)
    .Save(Path.Combine(folder, "contour-lines.png"));

Channels.ToImage(land, width, height).Save(Path.Combine(folder, "contour-field.png"));

Console.WriteLine($"{lines.Count} pieces of line altogether");
Ausgabe der Konsole
height  pieces of line
  0.20              82
  0.35             167
  0.50             292
  0.65             302
  0.80             303
1146 pieces of line altogether

06Gut zu wissen

  • Heraus kommen lose Segmente, keine geschlossenen Ringe. Für ein Bild reicht das. Wer die Linie als Vektor braucht — Plotter, SVG, Physikkante —, muss die Enden anschließend zusammensuchen und zu Zügen verbinden.
  • Marching Cubes ist dieselbe Idee in 3D: acht Ecken, 256 Fälle, Dreiecke statt Linien. Es stammt von Lorensen und Cline (1987), war bis 2005 patentiert und ist der Grund, warum medizinische 3D-Bilder aus Schichtaufnahmen überhaupt entstehen können. Auch dort gibt es zweideutige Fälle, nur deutlich mehr davon.
  • Verwandt: Dual Contouring legt statt fester Kantenschnitte einen Punkt pro Zelle und trifft scharfe Kanten dadurch besser; Metaballs sind nichts anderes als ein Feld aus Abstandsfunktionen, durch das Marching Squares eine Linie zieht.
  • Das Feld im Beispiel ist Perlin Noise, auf 0 bis 1 gestreckt. Fünf Höhen ergeben 1146 Segmente — und sehen sofort aus wie eine Wanderkarte.