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.


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
// 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
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");
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.