Random Continents
How the continent generator builds organic land-mass shapes from outward-growing union of random polygons with optional interior carving — from Query params to cropped PNG.
Overview
| Route | Purpose |
|---|---|
GET /continent_generator |
Generate a continent-like shape from union/subtraction of random polygons |
GET /polygon_generator |
Generate a single random polygon PNG |
GET /playground |
Interactive page with experiment sidebar |
The algorithm starts with a single random polygon at the centre of a large 2000×2000 canvas, then repeatedly grows outward by adding more polygons at the boundary. The continent generator builds up land by unioning polygons. Optionally, interior polygons can be removed to carve bays, inlets, and interior lakes. After all iterations the result is cropped tightly to the content and rendered as earthy-brown land on a transparent background.
Source location
- Continent generator —
src/routers/continents.py - Polygon generator —
src/routers/playground.py - Playground HTML/JS —
templates/playground.html - Router registration —
src/main.pyviaapp.include_router()
Query Parameters
| Parameter | Default | Range | Description |
|---|---|---|---|
union_iterations |
100 |
1–200 | Number of polygons added to grow the land outward |
subtract_iterations |
10 |
0–100 | Number of polygons removed to carve bays/inlets |
min_vertices |
16 |
3–100 | Minimum vertices per polygon |
max_vertices |
20 |
3–100 | Maximum vertices per polygon |
min_size |
40 |
10–400 | Minimum polygon diameter in pixels |
max_size |
200 |
10–400 | Maximum polygon diameter in pixels |
outline |
"black" |
"black" / "white" |
Outline colour (case-insensitive) |
Values outside the valid range return a 422 validation error.
Helpers
_generate_polygon(vertices, size) — src/routers/continents.py:11
Same random-walk-on-radius algorithm as the polygon generator. Places vertices at random angles around the origin, each at a radius that walks ±0.15 from the previous (clamped to [0.3, 1.0] of size/2). Returns a list of (x, y) tuples centered on (0, 0).
_translate_polygon(poly, dx, dy) — src/routers/continents.py:24
Shifts a polygon by (dx, dy). Used to position polygons at specific canvas coordinates.
_random_point_in_mask(mask) — src/routers/continents.py:26
Rejection-samples up to 1000 random pixel coordinates, returning the first that falls in a filled region (value > 128) of the mask image. Returns None if no filled pixel is found. Used for subtract iterations (carving bays from the interior of the land mass).
_random_boundary_point(mask) — src/routers/continents.py:37
Runs getbbox() on the mask to find the bounding box of all filled pixels, then rejection-samples up to 2000 pixel coordinates within that bounding box only. Returns the first that is both filled (value > 128) and adjacent to at least one empty pixel (4-connected neighbour ≤ 128). Falls back to _random_point_in_mask if no boundary pixel is found. Used for union iterations (outward growth of the continent).
Why constrain to the bounding box? On a 2000×2000 canvas, a small 100×100 filled area has only ~400 boundary pixels out of 4 million total — a 0.01 % hit rate for full-canvas rejection sampling. By sampling only within the mask's bounding box (~10,000 pixels), the boundary-hit rate jumps to ~4 %, so every union iteration reliably finds an edge pixel to grow from.
Algorithm — Step by Step
1. Initialise mask
A 2000×2000 single-channel (grayscale) image is created, filled with black (0):
mask = Image.new("L", (CANVAS, CANVAS), 0)
draw = ImageDraw.Draw(mask)
CANVAS = 2000 is a fixed constant — large enough that generated content rarely reaches the edge.
2. Iterate union + subtract phases
total = union_iterations + subtract_iterations
for i in range(total):
n = random.randint(min_vertices, max_vertices)
s = random.randint(min_size, max_size)
poly = _generate_polygon(n, s)
Each iteration picks a random vertex count and size uniformly from the configured ranges.
First iteration — seed polygon
if i == 0:
placed = _translate_polygon(poly, CANVAS // 2, CANVAS // 2)
draw.polygon(placed, fill=255)
The first polygon is placed dead-centre at (1000, 1000). It acts as the seed from which the continent grows.
Union iterations — outward growth
elif i < union_iterations:
pt = _random_boundary_point(mask)
if pt is None:
break
draw.polygon(_translate_polygon(poly, pt[0], pt[1]), fill=255)
Each subsequent union iteration places a new polygon centered at a boundary pixel — a filled pixel adjacent to at least one empty pixel. This guarantees the new polygon extends beyond the current land mass, growing the continent outward in that direction.
The boundary search is constrained to the mask's current bounding box so that even early in the process (when the mask occupies <1 % of the canvas), boundary pixels are found reliably. Without this constraint, full-canvas rejection sampling would almost always miss the tiny boundary region and fall back to interior placement, stalling growth.
Subtract iterations — bays and inlets
else:
pt = _random_point_in_mask(mask)
if pt is None:
break
draw.polygon(_translate_polygon(poly, pt[0], pt[1]), fill=0)
Subtract iterations switch to filling with black (0) instead of white (255), effectively cutting holes out of the land mass. They use interior sampling (_random_point_in_mask) rather than boundary sampling so the carved-out regions are inside the continent, not at the edge. This creates bays, inlets, fjords, and interior lakes, making the coastline more irregular and natural.
3. Auto-crop
bbox = mask.getbbox()
if bbox is None:
# empty mask — return 1×1 transparent PNG
pad = 100
left = max(0, bbox[0] - pad)
top = max(0, bbox[1] - pad)
right = min(CANVAS, bbox[2] + pad)
bottom = min(CANVAS, bbox[3] + pad)
getbbox() finds the bounding box of all non-zero pixels. 100 px of padding is added on each side. The result is cropped to this padded bounding box, so the output image contains only the land mass with no excess transparent space.
4. Render to RGBA
filled = Image.new("RGBA", (CANVAS, CANVAS), (80, 70, 60, 255))
result = Image.composite(
filled,
Image.new("RGBA", (CANVAS, CANVAS), (0, 0, 0, 0)),
mask,
)
| Layer | Colour | Alpha |
|---|---|---|
| Background | Transparent | 0 |
| Land mass | Earthy brown (80, 70, 60) |
100 % |
| Outline | Per outline param (black / white) |
100 % (1px stroke) |
The mask is used as an alpha channel to composite the earthy-brown fill onto a transparent background.
5. Outline
dilated = mask.filter(ImageFilter.MaxFilter(3))
outline_mask = ImageChops.difference(dilated, mask)
oc = (255, 255, 255, 255) if outline == "white" else (0, 0, 0, 255)
outline_layer = Image.new("RGBA", (CANVAS, CANVAS), oc)
result = Image.composite(outline_layer, result, outline_mask)
A 1 px outline is generated by dilating the mask (3×3 max filter) and subtracting the original, leaving only the boundary pixels. These boundary pixels are set to the requested outline colour and composited on top.
6. Crop and return
result = result.crop((left, top, right, bottom))
buf = io.BytesIO()
result.save(buf, format="PNG")
return Response(content=buf.getvalue(), media_type="image/png")
The final RGBA image is cropped to the padded bounding box and returned as a PNG.
Playground Page
The /playground route serves playground.html, which uses a sidebar layout with hash-based experiment routing. The continents experiment (#continents):
- On page load, reads six values from
localStorage(keyscont-union,cont-subtract,cont-min-v,cont-max-v,cont-min-s,cont-max-s), defaulting to100,10,16,20,40,200. - Saves current control values to
localStoragebefore each generation. - Makes a single request to
/continent_generatorwith all six parameters plusoutline(auto-detected fromdata-bs-theme—"white"in dark mode,"black"otherwise). - Displays the returned PNG in an
<img>tag withclass="img-fluid rounded".
The 2000×2000 internal canvas and auto-crop mean the output image varies in size — no fixed aspect ratio is imposed.
Request Examples
# Default params — 100 unions, 10 subtracts, vertices 16–20, size 40–200
curl -o continent.png http://localhost:8000/continent_generator
# Aggressive growth, no carving (smooth blob)
curl -o continent.png "http://localhost:8000/continent_generator?union_iterations=200&subtract_iterations=0"
# Heavy carving for jagged coastlines
curl -o continent.png "http://localhost:8000/continent_generator?union_iterations=100&subtract_iterations=50"
# Fewer, larger polygons for blockier land masses
curl -o continent.png "http://localhost:8000/continent_generator?min_vertices=3&max_vertices=8&min_size=200&max_size=400"
# White outline for dark backgrounds
curl -o continent.png "http://localhost:8000/continent_generator?outline=white"
# Invalid value — returns 422
curl http://localhost:8000/continent_generator?union_iterations=300
# → {"detail":[...]}