Pixels2GenAI
Path i Foundations
M 03 · 3.3.5 · hands-on

3.3.5 Delaunay Triangulation

Use `scipy.spatial.Delaunay` to wire a cloud of points into a clean triangular mesh whose triangles avoid sliver shapes, then sample the source image at each triangle's centroid to render a low-poly version of the original.

Duration18–22 min
Levelintermediate
Load3 core concepts
Prereqs3.3.4 (Voronoi), basic matplotlib

Overview

A Delaunay triangulation connects a point cloud into triangles such that no point sits inside any other triangle’s circumcircle. The constraint is elegant: out of the many ways to triangulate the same point set, this one maximises the smallest angle across all triangles — slivers are avoided and the mesh feels visually balanced [1]. Borrowed by graphics from finite-element meshing, Delaunay is now the standard triangulation for terrain generation, low-poly art, and the “facets” filter in every photo app. Boris Delaunay published the theorem in 1934; scipy.spatial.Delaunay packages it into one constructor call [2, 3].

Learning objectives

  1. State the circumcircle property and explain why it produces well-shaped triangles.
  2. Compute a triangulation with scipy.spatial.Delaunay(points) and read out its .simplices index array.
  3. Sample colours from an image at each triangle’s centroid to render a low-poly facet image.
  4. Compare point-distribution strategies — random, jittered grid, edge-weighted — for different artistic effects.

Quick start — triangulate 50 points

python · quick_start.py
import numpy as np
import matplotlib.pyplot as plt
from scipy.spatial import Delaunay

rng = np.random.default_rng(42)
points = rng.random((50, 2)) * 400

tri = Delaunay(points)

fig, ax = plt.subplots(figsize=(7, 7))
ax.triplot(points[:, 0], points[:, 1], tri.simplices,
           color='steelblue', linewidth=0.8)
ax.plot(points[:, 0], points[:, 1], 'o', color='coral', markersize=6)
ax.set_aspect('equal'); ax.axis('off')
plt.savefig('simple_delaunay.png', dpi=150, bbox_inches='tight')
A wireframe mesh of about 87 triangles connecting 50 random coral-coloured points on a white background. The triangles are visibly well-shaped, none of them long and thin.
Fig. 1 The Delaunay triangulation of 50 random points produces 87 triangles. Every triangle's circumcircle is empty of other points — the algorithm's defining property.

Core concepts

Concept 1 — The circumcircle property

Given a point cloud P, the Delaunay triangulation of P is the unique triangulation in which no point of P sits inside any triangle’s circumcircle (the unique circle passing through the triangle’s three vertices) [4]. Equivalently — and this is the property practitioners actually care about — Delaunay maximises the minimum angle over all triangles. There are no long thin slivers; every triangle is reasonably “fat.”

Why does that matter? Three reasons:

  • Numerical stability — finite-element solvers, interpolation, gradient estimation all blow up on slivers.
  • Visual balance — a mesh full of fat triangles looks intentional; one full of slivers looks broken.
  • Mesh quality — Delaunay is the closest you get to “good triangles” without picking points yourself.
A four-panel diagram. (a) shows a basic triangulation of seven points. (b) shows a triangle and its circumcircle, demonstrating the empty-circle property. (c) shows a regular grid of points triangulated. (d) shows a triangulation where each triangle is coloured by its minimum interior angle, with most triangles green and the slivers near the convex hull faintly visible.
Fig. 2 (a) the triangulation; (b) the empty circumcircle that defines it; (c) a grid produces a uniform mesh; (d) angle quality — Delaunay favours triangles with large minimum angles.

Concept 2 — Indexing into tri.simplices

tri.simplices is the canonical Delaunay output: a (num_triangles, 3) array of integer indices into your original point array. The first triangle has vertices points[tri.simplices[0]], the second has points[tri.simplices[1]], and so on. Two common operations:

# Pull out the three vertex coordinates for triangle k
triangle_k = points[tri.simplices[k]]      # shape (3, 2)

# Iterate over all triangles
for simplex in tri.simplices:
    vertices = points[simplex]              # (3, 2)
    centroid = vertices.mean(axis=0)        # (2,)
    ...

This index-then-look-up pattern is exactly the same one used by Warhol’s palette indexing in 3.3.1 — store integer references, fetch the data separately. It is also how OBJ, glTF, and every other indexed-mesh file format works.

Concept 3 — Centroid sampling for low-poly art

Once you have triangles, painting them by colour-sampling a source image is the “low-poly art” technique:

def triangle_colour(source, vertices):
    centroid = vertices.mean(axis=0)
    cx = int(np.clip(centroid[0], 0, source.shape[1] - 1))
    cy = int(np.clip(centroid[1], 0, source.shape[0] - 1))
    return source[cy, cx] / 255.0           # NB: image indexing is [y, x]

Centroid sampling is the cheapest variant — one pixel lookup per triangle. Better-looking versions average all pixels inside the triangle (use matplotlib.path.Path to test inclusion), but the centroid is enough for the geometric mosaic look [5].

The image gets rendered through matplotlib.collections.PolyCollection, which paints filled polygons in one call:

from matplotlib.collections import PolyCollection
triangles = [points[s] for s in tri.simplices]
colours   = [triangle_colour(source, points[s]) for s in tri.simplices]
ax.add_collection(PolyCollection(triangles, facecolors=colours, edgecolors='none'))
ax.set_xlim(0, W); ax.set_ylim(H, 0)        # flip y so image-down stays down

Exercises

Three exercises in Execute → Modify → Create order: run the wireframe, try different distributions, then render a low-poly image from a procedural source.

EXECUTE I.

Run the wireframe triangulation

Run simple_delaunay.py from the downloads. Look at the output.

Reflection questions

  • The script generates 50 points and produces 87 triangles. Where does that number come from?
  • Why are no triangles long and thin?
  • What happens at the edge of the point cloud — how are border triangles bounded?
MODIFY II.

Three point-distribution strategies

Edit simple_delaunay.py to try three different distributions of the 50 points. Replot each.

Goals

  1. Jittered grid — start from a 7×7 grid of points and add small random offsets.
  2. Edge-weighted — bias the random points toward the borders by rng.beta(0.5, 0.5, (50, 2)) and rescaling.
  3. Add canvas-corner anchors — append [[0, 0], [400, 0], [400, 400], [0, 400]] to your random points so the hull covers the full square.
CREATE III.

Low-poly art from a procedural source

Build a procedural sunset image (gradient + sun + accent circle), generate a triangulation with corner+edge anchors, and render the low-poly version.

python · exercise3_starter.py
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.collections import PolyCollection
from scipy.spatial import Delaunay

W, H = 400, 400
rng = np.random.default_rng(42)

# TODO 1: build a procedural source image (H, W, 3) uint8 with a sunset gradient
#         and a sun-like circle. See hint for one approach.

# TODO 2: sample 150 random points, then append the 4 corners and 4 edge midpoints.

# TODO 3: compute the Delaunay triangulation.

# TODO 4: per triangle: sample the centroid colour from the source.

# TODO 5: render with PolyCollection; flip the y-axis to match image coords.

plt.savefig('lowpoly.png', dpi=150, bbox_inches='tight', pad_inches=0)

Make it your own

  • Replace centroid sampling with average sampling — use matplotlib.path.Path(triangle).contains_points to find the pixels inside each triangle and take their mean colour. Slower but visually closer to the source.
  • Bias point placement by image gradient — sample more points where the source has sharp edges, fewer where it is smooth. Photo apps do this for “smart” low-poly conversion.
  • Render the wireframe on top of the colour fill (edgecolors='white') for a stained-glass look. Drop the alpha (alpha=0.6) to layer it on top of the original image.

Downloads

simple_delaunay.py — wireframe quick start colored_triangulation.py — randomly coloured triangles lowpoly_art_starter.py — Exercise 3 starter lowpoly_art_solution.py — Exercise 3 reference

Summary

Common pitfalls to avoid

  • Forgetting corner anchors — the convex hull leaves border regions uncovered.
  • Indexing the source as source[cx, cy] instead of source[cy, cx] — NumPy images are [row, col].
  • Passing the vertices (points[s]) to a function expecting indices (s) or vice versa.
  • Plotting without ax.set_ylim(H, 0) — the picture renders upside-down because matplotlib defaults to y-up.
  • Sampling colours as source[cy, cx] without np.clip — points can land just outside the image bounds and crash the indexing.

References

  1. [1] Aurenhammer, F., Klein, R., & Lee, D.-T. (2013). Voronoi Diagrams and Delaunay Triangulations. World Scientific.
  2. [2] Delaunay, B. (1934). Sur la sphère vide. Bulletin de l’Académie des Sciences de l’URSS, Classe des Sciences Mathématiques et Naturelles, 6, 793–800.
  3. [3] SciPy Community. (2024). scipy.spatial.Delaunay. SciPy Documentation. docs.scipy.org
  4. [4] de Berg, M., Cheong, O., van Kreveld, M., & Overmars, M. (2008). Computational Geometry: Algorithms and Applications (3rd ed.). Springer.
  5. [5] Edelsbrunner, H. (2001). Geometry and Topology for Mesh Generation. Cambridge University Press.
  6. [6] Hunter, J. D. (2007). Matplotlib: A 2D graphics environment. Computing in Science & Engineering, 9(3), 90–95. doi:10.1109/MCSE.2007.55