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)
