"""Featured images for the blog posts (1200 x 630 JPEG, WordPress / social preview size). A bird drawn from dots on a BeadChip-like grid: red and green are the two scanner channels, the bird is gold. Run with any Python that has Pillow: python3 scripts/blog/make_featured_images.py → blog/images/*.jpg """ from __future__ import annotations import math import random from pathlib import Path from PIL import Image, ImageDraw, ImageFilter, ImageFont W, H, SS = 1200, 630, 2 # output size, supersampling INDIGO, INDIGO2 = (30, 34, 64), (18, 20, 40) RED, GREEN, GOLD, PAPER = (200, 69, 58), (47, 143, 91), (212, 160, 23), (247, 245, 239) FONT_DIR = Path("/usr/share/fonts/truetype/noto") OUT = Path(__file__).resolve().parents[2] / "blog" / "images" POSTS = [ ("01_why_simurgarray_should_exist", "Part 1", "Why SimurgArray\nshould exist", "box"), ("02_alternatives_and_why_i_built_it_anyway", "Part 2", "Alternatives, and\nwhy build\nit anyway", "flock"), ("03_what_good_it_could_do", "Part 3", "What good\ncould it do?", "converge"), ("04_going_public_plan_and_calendar", "Part 4", "Going public:\nthe plan and\na calendar", "calendar"), ("05_using_simurgarray_and_the_bird_behind_the_name", "Part 5", "Using SimurgArray,\nand the bird\nbehind the name", "thirty"), ] # fmt: skip def _bezier(p0: tuple[float, float], p1: tuple[float, float], p2: tuple[float, float], w0: float, w1: float, n: int = 40) -> list[tuple[float, float]]: # fmt: skip """Polygon of a tapered quadratic Bezier stroke (a tail plume).""" left, right = [], [] for i in range(n + 1): t = i / n x = (1 - t) ** 2 * p0[0] + 2 * (1 - t) * t * p1[0] + t * t * p2[0] y = (1 - t) ** 2 * p0[1] + 2 * (1 - t) * t * p1[1] + t * t * p2[1] dx = 2 * (1 - t) * (p1[0] - p0[0]) + 2 * t * (p2[0] - p1[0]) dy = 2 * (1 - t) * (p1[1] - p0[1]) + 2 * t * (p2[1] - p1[1]) ln = math.hypot(dx, dy) or 1.0 hw = (w0 + (w1 - w0) * t) / 2 left.append((x - dy / ln * hw, y + dx / ln * hw)) right.append((x + dy / ln * hw, y - dx / ln * hw)) return left + right[::-1] def bird_mask(w: int, h: int) -> Image.Image: """Silhouette of a simurg in flight, facing right: raised swept wings, crest, hooked beak and three long plumes ending in peacock eyes, in a w x h box (design grid 520 x 420).""" sx, sy = w / 520, h / 420 m = Image.new("L", (w, h), 0) d = ImageDraw.Draw(m) def p(pts: list[tuple[float, float]]) -> list[tuple[float, float]]: return [(x * sx, y * sy) for x, y in pts] def circle(cx: float, cy: float, r: float) -> None: d.ellipse([(cx - r) * sx, (cy - r) * sy, (cx + r) * sx, (cy + r) * sy], fill=255) body = [(250 + 95 * math.cos(t) * math.cos(-0.35) - 34 * math.sin(t) * math.sin(-0.35) + 70, 228 + 95 * math.cos(t) * math.sin(-0.35) + 34 * math.sin(t) * math.cos(-0.35)) for t in [k * math.pi / 30 for k in range(60)]] # fmt: skip d.polygon(p(body), fill=255) d.polygon(p([(385, 170), (420, 150), (440, 200), (395, 225)]), fill=255) # neck circle(432, 166, 27) # head d.polygon(p([(452, 152), (496, 166), (486, 184), (470, 178), (452, 184)]), fill=255) # hooked beak for tip in ((372, 118), (392, 108), (414, 112)): # crest d.polygon(p(_bezier((425, 150), ((425 + tip[0]) / 2, tip[1] + 18), tip, 12, 3)), fill=255) d.polygon( p( [ (345, 205), (338, 150), (312, 92), (266, 46), (200, 12), (222, 48), (176, 44), (226, 84), (170, 92), (236, 122), (180, 138), (250, 162), (204, 186), (284, 214), ] ), fill=255, ) # near wing d.polygon( p( [ (305, 214), (268, 162), (214, 124), (140, 100), (170, 130), (124, 138), (184, 162), (146, 176), (220, 196), (262, 236), ] ), fill=255, ) # far wing for c, e, eye in (((150, 250), (22, 300), 20), ((170, 320), (58, 392), 18), ((230, 350), (170, 408), 15)): d.polygon(p(_bezier((250, 250), c, e, 30, 8)), fill=255) # plume circle(e[0], e[1], eye) # peacock eye return m def background(rng: random.Random) -> Image.Image: img = Image.new("RGB", (W * SS, H * SS), INDIGO) d = ImageDraw.Draw(img) for y in range(H * SS): # vertical gradient t = y / (H * SS) c = tuple(int(a + (b - a) * t) for a, b in zip(INDIGO, INDIGO2, strict=True)) d.line([(0, y), (W * SS, y)], fill=c) step = 18 * SS for gy in range(step // 2, H * SS, step): # faint bead grid for gx in range(step // 2, W * SS, step): col = RED if rng.random() < 0.5 else GREEN a = 0.10 + 0.08 * rng.random() c = tuple(int(bg + (cc - bg) * a) for bg, cc in zip(INDIGO, col, strict=True)) r = 1.6 * SS d.ellipse([gx - r, gy - r, gx + r, gy + r], fill=c) return img def dot_bird(img: Image.Image, x0: int, y0: int, w: int, h: int, step: int, rng: random.Random, dim: float = 1.0) -> list[tuple[float, float]]: # fmt: skip """Draw the bird as dots; returns the dot centres (full-resolution pixels).""" mask = bird_mask(w, h) edge = mask.filter(ImageFilter.MinFilter(max(3, (step // 2) | 1))) d = ImageDraw.Draw(img) pts = [] for yy in range(step // 2, h, step): for xx in range(step // 2, w, step): if mask.getpixel((xx, yy)) < 128: continue inner = edge.getpixel((xx, yy)) >= 128 col = GOLD if inner else (RED if (xx // step + yy // step) % 2 else GREEN) if inner and rng.random() < 0.12: col = (236, 196, 72) c = tuple(int(bg + (cc - bg) * dim) for bg, cc in zip(INDIGO, col, strict=True)) r = step * (0.36 if inner else 0.30) cx, cy = x0 + xx, y0 + yy d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c) pts.append((cx, cy)) return pts def motif(img: Image.Image, kind: str, rng: random.Random) -> None: d = ImageDraw.Draw(img) bx, by, bw, bh = 640 * SS, 90 * SS, 520 * SS, 420 * SS if kind == "box": # a closed box, and a stream of dots leaving it to become the bird x, y, sz = 590 * SS, 380 * SS, 150 * SS d.rectangle([x, y, x + sz, y + sz], fill=(12, 13, 26), outline=(70, 74, 110), width=2 * SS) sx0, sy0, sx1, sy1 = x + sz - 20 * SS, y + 20 * SS, 790 * SS, 300 * SS for k in range(9): f = k / 8 cx, cy = sx0 + (sx1 - sx0) * f, sy0 + (sy1 - sy0) * f col = GOLD if f > 0.6 else (RED if k % 2 else GREEN) a = 0.35 + 0.65 * f c = tuple(int(bg + (cc - bg) * a) for bg, cc in zip(INDIGO, col, strict=True)) r = (2.2 + 2.6 * f) * SS d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c) dot_bird(img, bx + 90 * SS, by - 50 * SS, int(bw * 0.82), int(bh * 0.82), 12 * SS, rng) elif kind == "flock": # smaller dim birds around one bright bird for fx, fy, fs, dim in ( (560, 70, 0.28, 0.35), (600, 430, 0.24, 0.3), (1030, 470, 0.22, 0.3), (1060, 40, 0.2, 0.28), ): dot_bird(img, fx * SS, fy * SS, int(bw * fs), int(bh * fs), 6 * SS, rng, dim) dot_bird(img, bx + 40 * SS, by + 20 * SS, int(bw * 0.8), int(bh * 0.8), 12 * SS, rng) elif kind == "converge": # dots flowing in from the edges pts = dot_bird(img, bx, by, bw, bh, 13 * SS, rng) for _ in range(90): tx, ty = rng.choice(pts) ang = rng.uniform(math.pi * 0.55, math.pi * 1.45) dist = rng.uniform(140, 420) * SS for k in range(1, 7): f = k / 7 cx, cy = tx + math.cos(ang) * dist * f, ty + math.sin(ang) * dist * f a = 0.55 * (1 - f) col = RED if k % 2 else GREEN c = tuple(int(bg + (cc - bg) * a) for bg, cc in zip(INDIGO, col, strict=True)) r = (2.6 - 1.6 * f) * SS d.ellipse([cx - r, cy - r, cx + r, cy + r], fill=c) elif kind == "calendar": # a calendar grid under the bird, some weeks done x0, y0, cw, ch = 610 * SS, 400 * SS, 62 * SS, 34 * SS for i in range(14): cx, cy = x0 + (i % 7) * (cw + 8 * SS), y0 + (i // 7) * (ch + 8 * SS) done = i < 5 d.rounded_rectangle([cx, cy, cx + cw, cy + ch], radius=6 * SS, fill=(52, 44, 26) if done else (26, 29, 54), outline=GOLD if done else (70, 74, 110), width=2 * SS) # fmt: skip if done: d.line([(cx + 20 * SS, cy + 18 * SS), (cx + 28 * SS, cy + 25 * SS), (cx + 44 * SS, cy + 10 * SS)], fill=GOLD, width=3 * SS) # fmt: skip dot_bird(img, bx + 40 * SS, 30 * SS, int(bw * 0.72), int(bh * 0.72), 11 * SS, rng) elif kind == "thirty": # thirty small birds that together make the big one dot_bird(img, bx, by, bw, bh, 13 * SS, rng, dim=0.16) mask = bird_mask(bw, bh) spots: list[tuple[int, int]] = [] tries = 0 while len(spots) < 30 and tries < 50000: tries += 1 x, y = rng.randrange(bw), rng.randrange(bh) if mask.getpixel((x, y)) > 128 and all((x - a) ** 2 + (y - b) ** 2 > (50 * SS) ** 2 for a, b in spots): spots.append((x, y)) s = 0.15 for x, y in spots: dot_bird(img, bx + x - int(bw * s / 2), by + y - int(bh * s / 2), int(bw * s), int(bh * s), 3 * SS, rng) def text(img: Image.Image, part: str, title: str) -> None: d = ImageDraw.Draw(img) mono = ImageFont.truetype(str(FONT_DIR / "NotoSansMono-Regular.ttf"), 26 * SS) size = 58 while True: bold = ImageFont.truetype(str(FONT_DIR / "NotoSans-Bold.ttf"), size * SS) widest = max(d.textlength(line, font=bold) for line in title.split("\n")) if widest <= 540 * SS or size <= 36: break size -= 2 reg = ImageFont.truetype(str(FONT_DIR / "NotoSans-Regular.ttf"), 24 * SS) x = 70 * SS d.text((x, 88 * SS), part.upper(), font=mono, fill=GOLD) d.multiline_text((x, 140 * SS), title, font=bold, fill=PAPER, spacing=14 * SS) d.ellipse([x, 540 * SS, x + 12 * SS, 552 * SS], fill=RED) d.ellipse([x + 18 * SS, 540 * SS, x + 30 * SS, 552 * SS], fill=GREEN) d.ellipse([x + 36 * SS, 540 * SS, x + 48 * SS, 552 * SS], fill=GOLD) d.text((x + 66 * SS, 530 * SS), "SimurgArray", font=reg, fill=PAPER) d.text((x + 66 * SS + d.textlength("SimurgArray ", font=reg), 530 * SS), "Thirty birds, one picture.", font=reg, fill=(150, 154, 190)) # fmt: skip def main() -> None: OUT.mkdir(parents=True, exist_ok=True) for i, (stem, part, title, kind) in enumerate(POSTS): rng = random.Random(1000 + i) img = background(rng) motif(img, kind, rng) text(img, part, title) img = img.resize((W, H), Image.Resampling.LANCZOS) path = OUT / f"{stem}.jpg" img.save(path, "JPEG", quality=84, optimize=True, progressive=True) print(f"{path.name}: {path.stat().st_size // 1024} KB") if __name__ == "__main__": main()