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 generatorsrc/routers/continents.py
  • Polygon generatorsrc/routers/playground.py
  • Playground HTML/JStemplates/playground.html
  • Router registrationsrc/main.py via app.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):

  1. On page load, reads six values from localStorage (keys cont-union, cont-subtract, cont-min-v, cont-max-v, cont-min-s, cont-max-s), defaulting to 100, 10, 16, 20, 40, 200.
  2. Saves current control values to localStorage before each generation.
  3. Makes a single request to /continent_generator with all six parameters plus outline (auto-detected from data-bs-theme"white" in dark mode, "black" otherwise).
  4. Displays the returned PNG in an <img> tag with class="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":[...]}