Files
SimurgArray/scripts/blog/make_featured_images.py
T
kemal e7ee009df3 blog: featured images (1200x630, ~90 KB) and a branding brief
Dotted simurg on a bead grid in the channel colors (red/green, gold bird,
indigo), one motif per post; generated by scripts/blog/make_featured_images.py.
2026-09-30 07:22:20 +01:00

258 lines
11 KiB
Python

"""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()