Random Polygon Generation
How the polygon generator endpoint works — from Pydantic params to PNG bytes.
Overview
Two routes in src/routers/playground.py handle polygon generation:
| Route | Purpose |
|---|---|
GET /polygon_generator |
Generate a single random polygon PNG |
GET /playground |
Interactive page with experiment sidebar |
The algorithm generates a random convex-ish polygon by placing vertices at random angles around a centre point, using a smooth random walk on radius to avoid self-intersections.
Source location
- Polygon generator —
src/routers/playground.py - Playground HTML/JS —
templates/playground.html - Router registration —
src/main.pyviaapp.include_router()
Pydantic Model — PolygonParams
Defined at src/routers/playground.py:16. Validated automatically by FastAPI from query parameters:
class PolygonParams(BaseModel):
width: int = Field(default=32, ge=1)
height: int = Field(default=32, ge=1)
vertices: int | None = Field(default=None, ge=3)
outline: str = Field(default="black")
@field_validator("width", "height")
@classmethod
def clamp_dimensions(cls, v):
return min(v, 1000)
@field_validator("vertices")
@classmethod
def clamp_vertices(cls, v):
if v is not None:
return min(v, 100)
return v
| Field | Default | Lower bound | Upper bound (clamped) | Notes |
|---|---|---|---|---|
width |
32 |
>= 1 |
clamped to <= 1000 |
Canvas width in pixels |
height |
32 |
>= 1 |
clamped to <= 1000 |
Canvas height in pixels |
vertices |
None |
>= 3 or None |
clamped to <= 100 |
When None, a random value between 20–40 is chosen |
outline |
"black" |
"black" or "white" |
— | Case-insensitive via validator |
Values exceeding the upper bound are silently clamped to the maximum — width=2000 produces a 1000px image.
The @field_validator("outline") normalises to lowercase and rejects anything other than "black" / "white", returning a 422 response.
Helper — _polygon_area
Defined at src/routers/playground.py:77. Computes the area of a polygon using the shoelace formula:
area = 0.5 × | Σ (x_i × y_{i+1} - x_{i+1} × y_i) |
Where (x_n, y_n) = (x_0, y_0). This is used by the retry logic (see below).
Algorithm — Step by Step
1. Determine vertex count
n = params.vertices if params.vertices is not None else random.randint(20, 40)
If the caller specifies a vertices value, use it directly. Otherwise pick a random integer uniformly between 20 and 40 (inclusive).
2. Compute centre and bounds
cx, cy = width / 2, height / 2
max_r = max(min(width, height) / 2 - 2, 0)
Vertices are generated relative to the centre (cx, cy). The maximum possible radius is half the smaller dimension, minus 2 pixels for a 1px padding margin.
The minimum acceptable polygon area is 3 % of the total canvas area:
min_area = width * height * 0.03
3. Vertex generation (with retry loop)
for _ in range(10):
angles = sorted(random.random() * 2 * math.pi for _ in range(n))
radius = random.uniform(0.5, 1.0)
points = []
for a in angles:
radius = max(0.3, min(1.0, radius + random.uniform(-0.15, 0.15)))
points.append((
cx + radius * max_r * math.cos(a),
cy + radius * max_r * math.sin(a),
))
if _polygon_area(points) >= min_area:
break
Key details:
- Random angles:
nuniformly-distributed random values in[0, 2π), then sorted clockwise. Sorting prevents edges from crossing each other. - Radius random walk: Start at a random radius between 0.5–1.0 (fraction of
max_r). For each successive vertex, adjust by±0.15(clamped to[0.3, 1.0]). This smooth walk produces organic-looking polygons rather than perfect regular shapes. - Area threshold: After generating
npoints, compute the shoelace area. If it is at least 3 % of the canvas, accept the shape. If not, retry (up to 10 attempts). This filters out degenerate near-zero-area shapes that can occur when all vertices cluster near the centre. - The final generated vertices are kept even if the area threshold is never met after 10 attempts (the loop simply falls through).
4. Auto-crop and shift
xs = [p[0] for p in points]
ys = [p[1] for p in points]
min_x, max_x = min(xs), max(xs)
min_y, max_y = min(ys), max(ys)
box_w = max(int(max_x - min_x + 4), 1)
box_h = max(int(max_y - min_y + 4), 1)
shifted = [(x - min_x + 2, y - min_y + 2) for x, y in points]
The polygon is cropped tightly around its bounding box with 2px padding on each side. All coordinates are shifted so the top-left of the bounding box aligns to (2, 2) in the output image.
5. Render to PNG
img = Image.new("RGBA", (box_w, box_h), (0, 0, 0, 0))
fill = (255, 255, 255, 30) if outline == "white" else (0, 0, 0, 30)
ImageDraw.Draw(img).polygon(shifted, outline=outline, fill=fill, width=1)
| Layer | Colour | Alpha |
|---|---|---|
| Background | Transparent | 0 |
| Fill | Same as outline | ~12 % (30 / 255) |
| Outline | Same as outline | 100 % (1px stroke) |
The semi-transparent fill ensures the polygon shape is visible even when the outline colour matches the page background (e.g. white outline on a light page). Without it, only the 1px stroke would be visible.
6. Return bytes
buf = io.BytesIO()
img.save(buf, format="PNG")
return Response(content=buf.getvalue(), media_type="image/png")
A tight-cropped, transparent-background PNG is returned.
Playground Page
The /playground route serves playground.html, which uses a sidebar layout with hash-based experiment routing (currently one experiment — #polygons). The polygons experiment:
- On page load, reads four values from
localStorage(keyspolygon-min-vertices,polygon-max-vertices,polygon-min-size,polygon-max-size), defaulting to20,40,80,500. - Saves current control values to
localStoragebefore each regeneration. - Makes 100 concurrent requests to
/polygon_generatorwith: vertices= random integer in[minV, maxV]width/height= each a random integer in[minS, maxS](independently chosen, so polygons can be rectangular)outline="white"whendata-bs-themeis"dark", otherwise"black"- Appends each returned PNG as an
<img>with classrounded.
There are no client-side limits on the control inputs — the server silently clamps oversized values (width/height to 1000, vertices to 100).
Request Examples
# Default 32×32, random vertices (20–40), black outline
curl -o poly.png http://localhost:8000/polygon_generator
# Explicit size and vertex count, white outline
curl -o poly.png "http://localhost:8000/polygon_generator?width=100&height=100&vertices=8&outline=white"
# Invalid outline — returns 422
curl http://localhost:8000/polygon_generator?outline=red
# → {"detail":[...]}