Drawing a kaleidoscope

For a project idea I want to draw a kaleidoscope. Apparently LLM cannot do that. So with chatGPT we wrote this code to play with. This produces something like this:

import math
import random
from PIL import Image, ImageDraw

W = H = 1400
cx = cy = W / 2
R = 520
gap = 12

random.seed(4)

img = Image.new("RGBA", (W, H), "white")
draw = ImageDraw.Draw(img)

palette = [
    (115, 45, 185),
    (35, 85, 215),
    (0, 180, 220),
    (0, 165, 90),
    (150, 205, 0),
    (255, 210, 0),
    (255, 130, 0),
]

def color_by_radius(r):
    i = min(int(r / R * len(palette)), len(palette) - 1)
    return palette[i]

def inside_circle(poly, radius=R):
    return all(math.hypot(x, y) <= radius for x, y in poly)

def centroid(poly):
    return (
        sum(p[0] for p in poly) / len(poly),
        sum(p[1] for p in poly) / len(poly)
    )

def rotate_poly_about_centroid(poly, deg):
    mx, my = centroid(poly)
    a = math.radians(deg)

    return [
        (
            mx + (x - mx) * math.cos(a) - (y - my) * math.sin(a),
            my + (x - mx) * math.sin(a) + (y - my) * math.cos(a)
        )
        for x, y in poly
    ]

def scale_poly_about_centroid(poly, factor):
    mx, my = centroid(poly)

    return [
        (
            mx + (x - mx) * factor,
            my + (y - my) * factor
        )
        for x, y in poly
    ]

def rot_point(p, deg):
    x, y = p
    a = math.radians(deg)

    return (
        x * math.cos(a) - y * math.sin(a),
        x * math.sin(a) + y * math.cos(a)
    )

def to_screen(p):
    x, y = p
    return (cx + x, cy - y)

def mirror_y(poly):
    return [(-x, y) for x, y in poly]

def shrink(poly, amount):
    mx, my = centroid(poly)

    out = []
    for x, y in poly:
        vx, vy = x - mx, y - my
        d = math.hypot(vx, vy)

        if d == 0:
            out.append((x, y))
        else:
            out.append((x - amount * vx / d, y - amount * vy / d))

    return out

def draw_rounded_triangle(draw, pts, radius, fill, steps=10):
    rounded = []

    for i in range(3):
        p_prev = pts[(i - 1) % 3]
        p = pts[i]
        p_next = pts[(i + 1) % 3]

        d1 = math.dist(p, p_prev)
        d2 = math.dist(p, p_next)

        if d1 == 0 or d2 == 0:
            continue

        t1 = min(radius / d1, 0.45)
        t2 = min(radius / d2, 0.45)

        a = (
            p[0] + (p_prev[0] - p[0]) * t1,
            p[1] + (p_prev[1] - p[1]) * t1
        )
        b = (
            p[0] + (p_next[0] - p[0]) * t2,
            p[1] + (p_next[1] - p[1]) * t2
        )

        for j in range(steps + 1):
            u = j / steps
            qx = (1 - u) ** 2 * a[0] + 2 * (1 - u) * u * p[0] + u ** 2 * b[0]
            qy = (1 - u) ** 2 * a[1] + 2 * (1 - u) * u * p[1] + u ** 2 * b[1]
            rounded.append((qx, qy))

    if len(rounded) >= 3:
        draw.polygon(rounded, fill=fill)

def draw_triangle(poly, corner_radius):
    if not inside_circle(poly):
        return

    r = sum(math.hypot(x, y) for x, y in poly) / 3

    p = shrink(poly, gap)
    pts = [to_screen(q) for q in p]

    draw_rounded_triangle(
        draw,
        pts,
        radius=corner_radius,
        fill=color_by_radius(r),
        steps=12
    )

s = 54
h = s * math.sqrt(3) / 2

base = []

for row in range(14):
    y = 45 + row * h
    max_x = math.tan(math.radians(30)) * y
    cols = int(max_x / s) + 3

    for col in range(cols):
        x = col * s + (row % 2) * s / 2

        tri_up = [
            (x, y),
            (x + s, y),
            (x + s / 2, y + h)
        ]

        tri_dn = [
            (x + s, y),
            (x + 1.5 * s, y + h),
            (x + s / 2, y + h)
        ]

        for tri in (tri_up, tri_dn):
            c_x, c_y = centroid(tri)
            theta = math.degrees(math.atan2(c_x, c_y))
            r = math.hypot(c_x, c_y)

            if 0 <= theta <= 30 and 25 < r < R:
                tri = scale_poly_about_centroid(tri, random.uniform(0.80, 2.00)) # VARIABLE
                tri = rotate_poly_about_centroid(tri, random.uniform(-30, 30)) #VARIABLE

                if inside_circle(tri):
                    corner_radius = random.uniform(10, 100) #VARIABLE
                    base.append((tri, corner_radius))

for k in range(6):
    angle = k * 60

    for tri, corner_radius in base:
        tri1 = [rot_point(p, angle) for p in tri]
        tri2 = [rot_point(p, angle) for p in mirror_y(tri)]

        draw_triangle(tri1, corner_radius)
        draw_triangle(tri2, corner_radius)

img.save("kaleidoscope_variable_rounding.png")

Then we thought a bit more. My ideas was to stop drawing and use symbols instaed. We came up with another code producing something like this:

Or this (with the right fonts):


import math
import random
from PIL import Image, ImageDraw, ImageFont

def draw_symbol(symbol, x, y, size, angle, color):
    font = ImageFont.truetype(
        #"/usr/share/fonts/truetype/noto/NotoColorEmoji.ttf",
        "/usr/share/fonts/truetype/noto/NotoSansSymbols2-Regular.ttf",
        int(size)
    )

# =========================
# Variables
# =========================

W = 1400
H = 1400
CX = W / 2
CY = H / 2
R = 560

SEED = 8
OUTPUT_FILE = "kaleidoscope_symbols_symmetric.png"

N_OBJECTS = 53

WEDGE_DEG = 30          # one mirror wedge
SECTOR_DEG = 60         # one full sector after mirroring
N_SECTORS = 6           # six-fold symmetry

R_MIN = 30
R_MAX = R - 35

SIZE_MIN = 30
SIZE_MAX = 120
SIZE_RANDOM = 0.25      # ±25%

ROT_RANDOM = 35         # ±35 degrees

SYMBOLS = [
    "★", "♥", "◆", "●"
]

PALETTE = [
    (115, 45, 185),     # violet
    (35, 85, 215),      # blue
    (0, 180, 220),      # cyan
    (0, 165, 90),       # green
    (150, 205, 0),      # yellow-green
    (255, 210, 0),      # yellow
    (255, 130, 0),      # orange
]

# =========================
# Setup
# =========================

random.seed(SEED)

img = Image.new("RGBA", (W, H), "white")
draw = ImageDraw.Draw(img)

font = ImageFont.load_default()

# =========================
# Helper functions
# =========================

def color_by_radius(r):
    i = min(int(r / R * len(PALETTE)), len(PALETTE) - 1)
    return PALETTE[i]

def size_by_radius(r):
    t = r / R
    size = SIZE_MIN + (SIZE_MAX - SIZE_MIN) * t
    size *= random.uniform(1 - SIZE_RANDOM, 1 + SIZE_RANDOM)
    return size

def polar_local(r, theta_deg):
    # local coordinate system:
    # theta = 0 is the central symmetry axis of one 60-degree sector
    a = math.radians(theta_deg)
    return (
        r * math.sin(a),
        r * math.cos(a)
    )

def rotate_xy(x, y, angle_deg):
    a = math.radians(angle_deg)
    return (
        x * math.cos(a) - y * math.sin(a),
        x * math.sin(a) + y * math.cos(a)
    )

def to_screen(x, y):
    return (
        CX + x,
        CY - y
    )

def inside_circle_xy(x, y, margin=0):
    return math.hypot(x, y) <= R - margin

def draw_symbol(symbol, x, y, size, angle, color):
    font = ImageFont.truetype(font_path, int(size))
    canvas = int(size * 3)

    tmp = Image.new("RGBA", (canvas, canvas), (255, 255, 255, 0))
    td = ImageDraw.Draw(tmp)

    bbox = td.textbbox((0, 0), symbol, font=font)
    tw = bbox[2] - bbox[0]
    th = bbox[3] - bbox[1]

    scale = max(1, int(size / 8))

    td.text(
        (
            canvas / 2 - tw / 2,
            canvas / 2 - th / 2
        ),
        symbol,
        font=font,
        fill=color + (255,)
    )

    tmp = tmp.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)

    sx, sy = to_screen(x, y)

    img.alpha_composite(
        tmp,
        (
            int(sx - tmp.width / 2),
            int(sy - tmp.height / 2)
        )
    )

def transform_from_wedge(r, theta, sector_index, mirror):
    # seed wedge: theta in [0, 30]
    # mirror wedge: theta in [-30, 0]
    # together: one 60-degree sector centered on 0
    local_theta = theta if not mirror else -theta

    x, y = polar_local(r, local_theta)

    # rotate complete mirrored sector around center
    x, y = rotate_xy(x, y, sector_index * SECTOR_DEG)

    return x, y, local_theta + sector_index * SECTOR_DEG

# =========================
# Build seed wedge only once
# =========================

seed_objects = []

for _ in range(N_OBJECTS):
    r = random.uniform(R_MIN, R_MAX)
    theta = random.uniform(0, WEDGE_DEG)

    symbol = random.choice(SYMBOLS)
    size = size_by_radius(r)
    rotation = random.uniform(-ROT_RANDOM, ROT_RANDOM)
    color = color_by_radius(r)

    seed_objects.append({
        "r": r,
        "theta": theta,
        "symbol": symbol,
        "size": size,
        "rotation": rotation,
        "color": color,
    })

# =========================
# Draw reflected and rotated copies
# =========================

for sector_index in range(N_SECTORS):
    for obj in seed_objects:
        for mirror in [False, True]:
            x, y, symmetry_angle = transform_from_wedge(
                r=obj["r"],
                theta=obj["theta"],
                sector_index=sector_index,
                mirror=mirror
            )

            if not inside_circle_xy(x, y, margin=obj["size"] * 0.5):
                continue

            rotation = obj["rotation"]

            if mirror:
                rotation = -rotation

            rotation += symmetry_angle

            draw_symbol(
                symbol=obj["symbol"],
                x=x,
                y=y,
                size=obj["size"],
                angle=rotation,
                color=obj["color"]
            )

# =========================
# Circular crop
# =========================

mask = Image.new("L", (W, H), 0)
mask_draw = ImageDraw.Draw(mask)
mask_draw.ellipse(
    (CX - R, CY - R, CX + R, CY + R),
    fill=255
)

out = Image.new("RGBA", (W, H), "white")
out.paste(img, (0, 0), mask)

out.save(OUTPUT_FILE)