{"id":1204,"date":"2026-06-09T13:41:19","date_gmt":"2026-06-09T13:41:19","guid":{"rendered":"https:\/\/doublelayer.eu\/vilab\/?p=1204"},"modified":"2026-06-09T13:41:19","modified_gmt":"2026-06-09T13:41:19","slug":"drawing-a-kaleidoscope","status":"publish","type":"post","link":"https:\/\/doublelayer.eu\/vilab\/2026\/06\/09\/drawing-a-kaleidoscope\/","title":{"rendered":"Drawing a kaleidoscope"},"content":{"rendered":"\n<p>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:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"1024\" src=\"https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1024x1024.png\" alt=\"\" class=\"wp-image-1205\" style=\"width:326px;height:auto\" srcset=\"https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1024x1024.png 1024w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-300x300.png 300w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-150x150.png 150w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-768x768.png 768w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-220x220.png 220w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image.png 1400w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<pre class=\"wp-block-code\"><code>import math\nimport random\nfrom PIL import Image, ImageDraw\n\nW = H = 1400\ncx = cy = W \/ 2\nR = 520\ngap = 12\n\nrandom.seed(4)\n\nimg = Image.new(\"RGBA\", (W, H), \"white\")\ndraw = ImageDraw.Draw(img)\n\npalette = &#91;\n    (115, 45, 185),\n    (35, 85, 215),\n    (0, 180, 220),\n    (0, 165, 90),\n    (150, 205, 0),\n    (255, 210, 0),\n    (255, 130, 0),\n]\n\ndef color_by_radius(r):\n    i = min(int(r \/ R * len(palette)), len(palette) - 1)\n    return palette&#91;i]\n\ndef inside_circle(poly, radius=R):\n    return all(math.hypot(x, y) &lt;= radius for x, y in poly)\n\ndef centroid(poly):\n    return (\n        sum(p&#91;0] for p in poly) \/ len(poly),\n        sum(p&#91;1] for p in poly) \/ len(poly)\n    )\n\ndef rotate_poly_about_centroid(poly, deg):\n    mx, my = centroid(poly)\n    a = math.radians(deg)\n\n    return &#91;\n        (\n            mx + (x - mx) * math.cos(a) - (y - my) * math.sin(a),\n            my + (x - mx) * math.sin(a) + (y - my) * math.cos(a)\n        )\n        for x, y in poly\n    ]\n\ndef scale_poly_about_centroid(poly, factor):\n    mx, my = centroid(poly)\n\n    return &#91;\n        (\n            mx + (x - mx) * factor,\n            my + (y - my) * factor\n        )\n        for x, y in poly\n    ]\n\ndef rot_point(p, deg):\n    x, y = p\n    a = math.radians(deg)\n\n    return (\n        x * math.cos(a) - y * math.sin(a),\n        x * math.sin(a) + y * math.cos(a)\n    )\n\ndef to_screen(p):\n    x, y = p\n    return (cx + x, cy - y)\n\ndef mirror_y(poly):\n    return &#91;(-x, y) for x, y in poly]\n\ndef shrink(poly, amount):\n    mx, my = centroid(poly)\n\n    out = &#91;]\n    for x, y in poly:\n        vx, vy = x - mx, y - my\n        d = math.hypot(vx, vy)\n\n        if d == 0:\n            out.append((x, y))\n        else:\n            out.append((x - amount * vx \/ d, y - amount * vy \/ d))\n\n    return out\n\ndef draw_rounded_triangle(draw, pts, radius, fill, steps=10):\n    rounded = &#91;]\n\n    for i in range(3):\n        p_prev = pts&#91;(i - 1) % 3]\n        p = pts&#91;i]\n        p_next = pts&#91;(i + 1) % 3]\n\n        d1 = math.dist(p, p_prev)\n        d2 = math.dist(p, p_next)\n\n        if d1 == 0 or d2 == 0:\n            continue\n\n        t1 = min(radius \/ d1, 0.45)\n        t2 = min(radius \/ d2, 0.45)\n\n        a = (\n            p&#91;0] + (p_prev&#91;0] - p&#91;0]) * t1,\n            p&#91;1] + (p_prev&#91;1] - p&#91;1]) * t1\n        )\n        b = (\n            p&#91;0] + (p_next&#91;0] - p&#91;0]) * t2,\n            p&#91;1] + (p_next&#91;1] - p&#91;1]) * t2\n        )\n\n        for j in range(steps + 1):\n            u = j \/ steps\n            qx = (1 - u) ** 2 * a&#91;0] + 2 * (1 - u) * u * p&#91;0] + u ** 2 * b&#91;0]\n            qy = (1 - u) ** 2 * a&#91;1] + 2 * (1 - u) * u * p&#91;1] + u ** 2 * b&#91;1]\n            rounded.append((qx, qy))\n\n    if len(rounded) >= 3:\n        draw.polygon(rounded, fill=fill)\n\ndef draw_triangle(poly, corner_radius):\n    if not inside_circle(poly):\n        return\n\n    r = sum(math.hypot(x, y) for x, y in poly) \/ 3\n\n    p = shrink(poly, gap)\n    pts = &#91;to_screen(q) for q in p]\n\n    draw_rounded_triangle(\n        draw,\n        pts,\n        radius=corner_radius,\n        fill=color_by_radius(r),\n        steps=12\n    )\n\ns = 54\nh = s * math.sqrt(3) \/ 2\n\nbase = &#91;]\n\nfor row in range(14):\n    y = 45 + row * h\n    max_x = math.tan(math.radians(30)) * y\n    cols = int(max_x \/ s) + 3\n\n    for col in range(cols):\n        x = col * s + (row % 2) * s \/ 2\n\n        tri_up = &#91;\n            (x, y),\n            (x + s, y),\n            (x + s \/ 2, y + h)\n        ]\n\n        tri_dn = &#91;\n            (x + s, y),\n            (x + 1.5 * s, y + h),\n            (x + s \/ 2, y + h)\n        ]\n\n        for tri in (tri_up, tri_dn):\n            c_x, c_y = centroid(tri)\n            theta = math.degrees(math.atan2(c_x, c_y))\n            r = math.hypot(c_x, c_y)\n\n            if 0 &lt;= theta &lt;= 30 and 25 &lt; r &lt; R:\n                tri = scale_poly_about_centroid(tri, random.uniform(0.80, 2.00)) # VARIABLE\n                tri = rotate_poly_about_centroid(tri, random.uniform(-30, 30)) #VARIABLE\n\n                if inside_circle(tri):\n                    corner_radius = random.uniform(10, 100) #VARIABLE\n                    base.append((tri, corner_radius))\n\nfor k in range(6):\n    angle = k * 60\n\n    for tri, corner_radius in base:\n        tri1 = &#91;rot_point(p, angle) for p in tri]\n        tri2 = &#91;rot_point(p, angle) for p in mirror_y(tri)]\n\n        draw_triangle(tri1, corner_radius)\n        draw_triangle(tri2, corner_radius)\n\nimg.save(\"kaleidoscope_variable_rounding.png\")<\/code><\/pre>\n\n\n\n<p>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:<br><\/p>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"1024\" src=\"https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1-1024x1024.png\" alt=\"\" class=\"wp-image-1207\" style=\"width:326px;height:auto\" srcset=\"https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1-1024x1024.png 1024w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1-300x300.png 300w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1-150x150.png 150w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1-768x768.png 768w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1-220x220.png 220w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-1.png 1400w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p>Or this (with the right fonts):<\/p>\n\n\n\n<figure class=\"wp-block-image size-large is-resized\"><img loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"1024\" src=\"https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-2-1024x1024.png\" alt=\"\" class=\"wp-image-1208\" style=\"width:303px;height:auto\" srcset=\"https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-2-1024x1024.png 1024w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-2-300x300.png 300w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-2-150x150.png 150w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-2-768x768.png 768w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-2-220x220.png 220w, https:\/\/doublelayer.eu\/vilab\/wp-content\/uploads\/2026\/06\/image-2.png 1400w\" sizes=\"auto, (max-width: 1024px) 100vw, 1024px\" \/><\/figure>\n\n\n\n<p><br><\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>import math\nimport random\nfrom PIL import Image, ImageDraw, ImageFont\n\ndef draw_symbol(symbol, x, y, size, angle, color):\n    font = ImageFont.truetype(\n        #\"\/usr\/share\/fonts\/truetype\/noto\/NotoColorEmoji.ttf\",\n        \"\/usr\/share\/fonts\/truetype\/noto\/NotoSansSymbols2-Regular.ttf\",\n        int(size)\n    )\n\n# =========================\n# Variables\n# =========================\n\nW = 1400\nH = 1400\nCX = W \/ 2\nCY = H \/ 2\nR = 560\n\nSEED = 8\nOUTPUT_FILE = \"kaleidoscope_symbols_symmetric.png\"\n\nN_OBJECTS = 53\n\nWEDGE_DEG = 30          # one mirror wedge\nSECTOR_DEG = 60         # one full sector after mirroring\nN_SECTORS = 6           # six-fold symmetry\n\nR_MIN = 30\nR_MAX = R - 35\n\nSIZE_MIN = 30\nSIZE_MAX = 120\nSIZE_RANDOM = 0.25      # \u00b125%\n\nROT_RANDOM = 35         # \u00b135 degrees\n\nSYMBOLS = &#91;\n    \"\u2605\", \"\u2665\", \"\u25c6\", \"\u25cf\"\n]\n\nPALETTE = &#91;\n    (115, 45, 185),     # violet\n    (35, 85, 215),      # blue\n    (0, 180, 220),      # cyan\n    (0, 165, 90),       # green\n    (150, 205, 0),      # yellow-green\n    (255, 210, 0),      # yellow\n    (255, 130, 0),      # orange\n]\n\n# =========================\n# Setup\n# =========================\n\nrandom.seed(SEED)\n\nimg = Image.new(\"RGBA\", (W, H), \"white\")\ndraw = ImageDraw.Draw(img)\n\nfont = ImageFont.load_default()\n\n# =========================\n# Helper functions\n# =========================\n\ndef color_by_radius(r):\n    i = min(int(r \/ R * len(PALETTE)), len(PALETTE) - 1)\n    return PALETTE&#91;i]\n\ndef size_by_radius(r):\n    t = r \/ R\n    size = SIZE_MIN + (SIZE_MAX - SIZE_MIN) * t\n    size *= random.uniform(1 - SIZE_RANDOM, 1 + SIZE_RANDOM)\n    return size\n\ndef polar_local(r, theta_deg):\n    # local coordinate system:\n    # theta = 0 is the central symmetry axis of one 60-degree sector\n    a = math.radians(theta_deg)\n    return (\n        r * math.sin(a),\n        r * math.cos(a)\n    )\n\ndef rotate_xy(x, y, angle_deg):\n    a = math.radians(angle_deg)\n    return (\n        x * math.cos(a) - y * math.sin(a),\n        x * math.sin(a) + y * math.cos(a)\n    )\n\ndef to_screen(x, y):\n    return (\n        CX + x,\n        CY - y\n    )\n\ndef inside_circle_xy(x, y, margin=0):\n    return math.hypot(x, y) &lt;= R - margin\n\ndef draw_symbol(symbol, x, y, size, angle, color):\n    font = ImageFont.truetype(font_path, int(size))\n    canvas = int(size * 3)\n\n    tmp = Image.new(\"RGBA\", (canvas, canvas), (255, 255, 255, 0))\n    td = ImageDraw.Draw(tmp)\n\n    bbox = td.textbbox((0, 0), symbol, font=font)\n    tw = bbox&#91;2] - bbox&#91;0]\n    th = bbox&#91;3] - bbox&#91;1]\n\n    scale = max(1, int(size \/ 8))\n\n    td.text(\n        (\n            canvas \/ 2 - tw \/ 2,\n            canvas \/ 2 - th \/ 2\n        ),\n        symbol,\n        font=font,\n        fill=color + (255,)\n    )\n\n    tmp = tmp.rotate(angle, expand=True, resample=Image.Resampling.BICUBIC)\n\n    sx, sy = to_screen(x, y)\n\n    img.alpha_composite(\n        tmp,\n        (\n            int(sx - tmp.width \/ 2),\n            int(sy - tmp.height \/ 2)\n        )\n    )\n\ndef transform_from_wedge(r, theta, sector_index, mirror):\n    # seed wedge: theta in &#91;0, 30]\n    # mirror wedge: theta in &#91;-30, 0]\n    # together: one 60-degree sector centered on 0\n    local_theta = theta if not mirror else -theta\n\n    x, y = polar_local(r, local_theta)\n\n    # rotate complete mirrored sector around center\n    x, y = rotate_xy(x, y, sector_index * SECTOR_DEG)\n\n    return x, y, local_theta + sector_index * SECTOR_DEG\n\n# =========================\n# Build seed wedge only once\n# =========================\n\nseed_objects = &#91;]\n\nfor _ in range(N_OBJECTS):\n    r = random.uniform(R_MIN, R_MAX)\n    theta = random.uniform(0, WEDGE_DEG)\n\n    symbol = random.choice(SYMBOLS)\n    size = size_by_radius(r)\n    rotation = random.uniform(-ROT_RANDOM, ROT_RANDOM)\n    color = color_by_radius(r)\n\n    seed_objects.append({\n        \"r\": r,\n        \"theta\": theta,\n        \"symbol\": symbol,\n        \"size\": size,\n        \"rotation\": rotation,\n        \"color\": color,\n    })\n\n# =========================\n# Draw reflected and rotated copies\n# =========================\n\nfor sector_index in range(N_SECTORS):\n    for obj in seed_objects:\n        for mirror in &#91;False, True]:\n            x, y, symmetry_angle = transform_from_wedge(\n                r=obj&#91;\"r\"],\n                theta=obj&#91;\"theta\"],\n                sector_index=sector_index,\n                mirror=mirror\n            )\n\n            if not inside_circle_xy(x, y, margin=obj&#91;\"size\"] * 0.5):\n                continue\n\n            rotation = obj&#91;\"rotation\"]\n\n            if mirror:\n                rotation = -rotation\n\n            rotation += symmetry_angle\n\n            draw_symbol(\n                symbol=obj&#91;\"symbol\"],\n                x=x,\n                y=y,\n                size=obj&#91;\"size\"],\n                angle=rotation,\n                color=obj&#91;\"color\"]\n            )\n\n# =========================\n# Circular crop\n# =========================\n\nmask = Image.new(\"L\", (W, H), 0)\nmask_draw = ImageDraw.Draw(mask)\nmask_draw.ellipse(\n    (CX - R, CY - R, CX + R, CY + R),\n    fill=255\n)\n\nout = Image.new(\"RGBA\", (W, H), \"white\")\nout.paste(img, (0, 0), mask)\n\nout.save(OUTPUT_FILE)<\/code><\/pre>\n\n\n\n<p><\/p>\n","protected":false},"excerpt":{"rendered":"<p>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: 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&hellip; <a class=\"read-more\" href=\"https:\/\/doublelayer.eu\/vilab\/2026\/06\/09\/drawing-a-kaleidoscope\/\">Read More<\/a><\/p>\n","protected":false},"author":1,"featured_media":0,"comment_status":"closed","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-1204","post","type-post","status-publish","format-standard","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/posts\/1204","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/comments?post=1204"}],"version-history":[{"count":2,"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/posts\/1204\/revisions"}],"predecessor-version":[{"id":1209,"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/posts\/1204\/revisions\/1209"}],"wp:attachment":[{"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/media?parent=1204"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/categories?post=1204"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/doublelayer.eu\/vilab\/wp-json\/wp\/v2\/tags?post=1204"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}