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 generatorsrc/routers/playground.py
  • Playground HTML/JStemplates/playground.html
  • Router registrationsrc/main.py via app.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: n uniformly-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 n points, 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:

  1. On page load, reads four values from localStorage (keys polygon-min-vertices, polygon-max-vertices, polygon-min-size, polygon-max-size), defaulting to 20, 40, 80, 500.
  2. Saves current control values to localStorage before each regeneration.
  3. Makes 100 concurrent requests to /polygon_generator with:
  4. vertices = random integer in [minV, maxV]
  5. width / height = each a random integer in [minS, maxS] (independently chosen, so polygons can be rectangular)
  6. outline = "white" when data-bs-theme is "dark", otherwise "black"
  7. Appends each returned PNG as an <img> with class rounded.

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":[...]}