#!/usr/bin/env python3 """ Advertisement poster for the Hermes robot: a hidden-line wireframe render of the real enclosure model, the scope face lit on its screen, on a wet floor in a hazy, Blade Runner-ish palette, finished with CRT scanlines. Inputs (made by the commands in poster/README.md): pose/*.stl each part exported in its assembled pose (PART="pose_") face.png a frame of the face from docs/face-prototype.html (flat, P3 amber) Output: hermes_poster.png """ import math import sys from pathlib import Path import numpy as np import trimesh from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageChops HERE = Path(__file__).resolve().parent POSE = Path(sys.argv[1]) if len(sys.argv) > 1 else HERE / "pose" FACE = Path(sys.argv[2]) if len(sys.argv) > 2 else HERE / "face.png" OUT = Path(sys.argv[3]) if len(sys.argv) > 3 else HERE / "hermes_poster.png" PW, PH = 1600, 2400 rng = np.random.default_rng(7) # ------------------------------------------------------------------ palette (linear-ish 0..1) INK = np.array([0.020, 0.028, 0.036]) # night HAZE = np.array([0.10, 0.20, 0.22]) # wet teal smoke AMBER = np.array([1.00, 0.66, 0.24]) # phosphor / sodium light WARM = np.array([0.95, 0.86, 0.70]) # edge highlight TEAL = np.array([0.30, 0.66, 0.70]) # contours, grid SCREEN = np.array([0.66, 0.82, 1.00]) # P7 phosphor: blue-white FONT_B = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" FONT_L = "/usr/share/fonts/truetype/dejavu/DejaVuSans-ExtraLight.ttf" FONT_C = "/usr/share/fonts/truetype/dejavu/DejaVuSansCondensed.ttf" FONT_M = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf" # ------------------------------------------------------------------ camera target = np.array([0.0, 0.0, 112.0]) direction = np.array([-0.40, -0.91, -0.10]) direction /= np.linalg.norm(direction) eye = target + direction * 950.0 fov = math.radians(24.0) F = (PH / 2) / math.tan(fov / 2) CX, CY = PW * 0.5, PH * 0.565 fwd = target - eye; fwd /= np.linalg.norm(fwd) right = np.cross(fwd, [0, 0, 1.0]); right /= np.linalg.norm(right) up = np.cross(right, fwd) def project(p): v = p - eye zc = v @ fwd return np.stack([CX + F * (v @ right) / zc, CY - F * (v @ up) / zc, zc], axis=-1) # ------------------------------------------------------------------ geometry parts = {} for f in sorted(POSE.glob("*.stl")): m = trimesh.load(f) parts[f.stem] = m print("parts:", {k: len(v.faces) for k, v in parts.items()}) def render_pass(mirror=False): """Depth buffer, shading and line buffers for one pass (mirror=True: reflection in the floor).""" zbuf = Image.new("F", (PW, PH), 1e9) shade = Image.new("RGB", (PW, PH), (0, 0, 0)) dz, ds = ImageDraw.Draw(zbuf), ImageDraw.Draw(shade) key = np.array([-0.55, -0.35, 0.75]); key /= np.linalg.norm(key) # amber key, front-left high rim = np.array([0.75, 0.55, 0.35]); rim /= np.linalg.norm(rim) # teal rim from behind right tris_all = [] for name, m in parts.items(): mm = m.copy() if mirror: mm.vertices[:, 2] *= -1 mm.invert() sub = mm.subdivide_to_size(max_edge=1.1 if name == "body" else 2.0, max_iter=10) v = sub.vertices[sub.faces] # (n, 3, 3) n = sub.face_normals p = project(v.reshape(-1, 3)).reshape(-1, 3, 3) view = (eye - v.mean(axis=1)); view /= np.linalg.norm(view, axis=1, keepdims=True) front = (n * view).sum(1) > 0 tris_all.append((p[front], n[front], view[front])) p = np.concatenate([t[0] for t in tris_all]); n = np.concatenate([t[1] for t in tris_all]) view = np.concatenate([t[2] for t in tris_all]) order = np.argsort(-p[:, :, 2].mean(1)) # far to near lk = np.clip(n @ key, 0, 1); lr = np.clip(n @ rim, 0, 1) fres = (1 - np.abs((n * view).sum(1))) ** 2 for i in order: pts = [tuple(xy) for xy in p[i, :, :2]] dz.polygon(pts, fill=float(p[i, :, 2].mean())) c = 0.035 + 0.10 * lk[i] * AMBER + 0.08 * lr[i] * TEAL + 0.10 * fres[i] * TEAL ds.polygon(pts, fill=tuple(int(min(255, 255 * x)) for x in c)) z = np.asarray(zbuf).copy() # widen with a max filter so lines lying on a surface pass the depth test zmax = np.asarray(zbuf.filter(ImageFilter.MaxFilter(3))) mask = (z < 1e8).astype(np.float32) # lines: feature edges, silhouettes, horizontal contour slices edge_buf = np.zeros((PH, PW), np.float32) cont_buf = np.zeros((PH, PW), np.float32) def splat(buf, a3, b3, weight, spacing=0.6): pa, pb = project(a3), project(b3) L = np.hypot(pb[:, 0] - pa[:, 0], pb[:, 1] - pa[:, 1]) k = np.maximum(1, np.ceil(L / spacing)).astype(int) idx = np.repeat(np.arange(len(k)), k) t = (np.arange(k.sum()) - np.repeat(np.cumsum(k) - k, k)) / np.repeat(k, k) P = a3[idx] + (b3[idx] - a3[idx]) * t[:, None] q = project(P) x, y, zc = q[:, 0], q[:, 1], q[:, 2] ok = (x >= 0) & (x < PW - 1) & (y >= 0) & (y < PH - 1) x, y, zc = x[ok], y[ok], zc[ok] xi, yi = x.astype(int), y.astype(int) vis = zc <= zmax[yi, xi] + 0.8 x, y, xi, yi = x[vis], y[vis], xi[vis], yi[vis] fx, fy = x - xi, y - yi w = weight np.add.at(buf, (yi, xi), w * (1 - fx) * (1 - fy)) np.add.at(buf, (yi, xi + 1), w * fx * (1 - fy)) np.add.at(buf, (yi + 1, xi), w * (1 - fx) * fy) np.add.at(buf, (yi + 1, xi + 1), w * fx * fy) for name, m in parts.items(): mm = m.copy() if mirror: mm.vertices[:, 2] *= -1 mm.invert() adj = mm.face_adjacency ang = mm.face_adjacency_angles e = mm.face_adjacency_edges fn = mm.face_normals mid = mm.vertices[e].mean(1) vv = eye - mid s1 = (fn[adj[:, 0]] * vv).sum(1) > 0 s2 = (fn[adj[:, 1]] * vv).sum(1) > 0 crease = ang > math.radians(28) silhouette = s1 != s2 sel = crease | silhouette V = mm.vertices splat(edge_buf, V[e[sel, 0]], V[e[sel, 1]], 0.8) # contour slices every 6 mm on the big shells: the "scan" look if name in ("body", "base", "plate", "yoke"): zlo, zhi = mm.bounds[0][2], mm.bounds[1][2] for h in np.arange(math.ceil(zlo / 6) * 6, zhi, 6.0): try: seg = trimesh.intersections.mesh_plane(mm, [0, 0, 1], [0, 0, h]) except Exception: continue if len(seg): splat(cont_buf, seg[:, 0], seg[:, 1], 0.32) return z, mask, np.asarray(shade).astype(np.float32) / 255, edge_buf, cont_buf print("main pass…") z, mask, shade, edges, conts = render_pass(False) print("reflection pass…") zr, maskr, shader, edgesr, contsr = render_pass(True) def blur(a, r): im = Image.fromarray(np.clip(a * 255, 0, 255).astype(np.uint8)) return np.asarray(im.filter(ImageFilter.GaussianBlur(r))).astype(np.float32) / 255 def blur3(rgb, r): return np.stack([blur(rgb[..., c], r) for c in range(3)], -1) # ------------------------------------------------------------------ background yy, xx = np.mgrid[0:PH, 0:PW].astype(np.float32) horizon = project(np.array([[0, 2000.0, 0]]))[0, 1] # far floor floor_y = project(np.array([[0, 0, 0.0]]))[0, 1] bg = np.zeros((PH, PW, 3), np.float32) + INK g = np.clip(1 - np.abs(yy - horizon) / 900, 0, 1) ** 1.6 # band of haze at the horizon bg += g[..., None] * HAZE * 0.55 # sodium glow low behind the robot, smoke plume, a cold light from upper right glow = np.exp(-(((xx - PW * 0.42) / 520) ** 2 + ((yy - horizon - 40) / 260) ** 2)) bg += glow[..., None] * AMBER * 0.16 plume = np.exp(-(((xx - PW * 0.55) / 380) ** 2 + ((yy - PH * 0.45) / 700) ** 2)) bg += plume[..., None] * HAZE * 0.22 # light shafts for k, (x0, wdt, a) in enumerate([(1250, 70, 0.05), (1380, 40, 0.035), (1100, 120, 0.03)]): d = (xx - (x0 - (yy - 0) * 0.42)) / wdt shaft = np.exp(-d ** 2) * np.clip(1 - yy / (PH * 0.75), 0, 1) bg += shaft[..., None] * HAZE * a * 4 # rain rain = Image.new("L", (PW, PH), 0); dr = ImageDraw.Draw(rain) for _ in range(900): x = rng.uniform(-200, PW); y = rng.uniform(-100, PH * 0.78); L = rng.uniform(30, 120) dr.line([(x, y), (x + L * 0.18, y + L)], fill=int(rng.uniform(25, 80)), width=1) rain = np.asarray(rain.filter(ImageFilter.GaussianBlur(0.7))).astype(np.float32) / 255 bg += rain[..., None] * np.array([0.55, 0.65, 0.70]) * 0.45 # perspective floor grid grid = Image.new("L", (PW, PH), 0); dg = ImageDraw.Draw(grid) for gx in np.arange(-1200, 1201, 50.0): a, b = project(np.array([[gx, -700, 0], [gx, 2400, 0]])) if a[2] > 0 and b[2] > 0: dg.line([tuple(a[:2]), tuple(b[:2])], fill=60, width=1) for gy in np.arange(-700, 2401, 50.0): a, b = project(np.array([[-1500, gy, 0], [1500, gy, 0]])) fade = int(70 * math.exp(-max(0, gy) / 900)) dg.line([tuple(a[:2]), tuple(b[:2])], fill=fade, width=1) gridf = np.asarray(grid).astype(np.float32) / 255 gridf *= (yy > horizon + 10) # fade the grid toward the horizon gridf *= np.clip((yy - horizon) / 500, 0, 1) bg += gridf[..., None] * TEAL * 0.55 # wet floor sheen floor = (yy > horizon).astype(np.float32) * np.clip((yy - horizon) / 300, 0, 1) bg *= 1 - 0.25 * floor[..., None] # ------------------------------------------------------------------ robot layers def robot_rgb(shade, edges, conts, mask): e = 1 - np.exp(-edges * 1.6) c = 1 - np.exp(-conts * 1.4) rgb = shade * 0.9 rgb = rgb * (1 - 0.35 * c[..., None]) + c[..., None] * TEAL * 0.55 rgb = rgb + e[..., None] * WARM * 0.95 return rgb, e robot, e_main = robot_rgb(shade, edges, conts, mask) refl, _ = robot_rgb(shader, edgesr, contsr, maskr) # ------------------------------------------------------------------ the face on the screen def persp_coeffs(src, dst): """Coefficients for Image.transform(PERSPECTIVE): maps output (dst) pixels back to src.""" A, B = [], [] for (x, y), (u, v) in zip(dst, src): A.append([x, y, 1, 0, 0, 0, -u * x, -u * y]); B.append(u) A.append([0, 0, 0, x, y, 1, -v * x, -v * y]); B.append(v) return np.linalg.solve(np.array(A, float), np.array(B, float)) WIN = np.array([[-17.8464, -15.1813, 161.8], [10.1287, -21.1276, 161.8], [10.1287, -21.1276, 133.2], [-17.8464, -15.1813, 133.2]]) LEDS = np.array([[9.66058, -21.8868, 126.7], [9.66058, -21.8868, 117.9], [9.66058, -21.8868, 109.5]]) def face_layer(win3): face = Image.open(FACE).convert("RGB") fw, fh = face.size q = project(win3)[:, :2] coeffs = persp_coeffs([(0, 0), (fw, 0), (fw, fh), (0, fh)], [tuple(p) for p in q]) warped = face.transform((PW, PH), Image.PERSPECTIVE, tuple(coeffs), Image.BILINEAR) poly = Image.new("L", (PW, PH), 0); ImageDraw.Draw(poly).polygon([tuple(p) for p in q], fill=255) a = np.asarray(warped).astype(np.float32) / 255 * (np.asarray(poly)[..., None] / 255) glass = (np.asarray(poly).astype(np.float32) / 255)[..., None] * np.array([0.05, 0.035, 0.02]) return a + glass, np.asarray(poly).astype(np.float32) / 255 face, facepoly = face_layer(WIN) mirror = lambda p: p * np.array([1, 1, -1]) facer, facepolyr = face_layer(mirror(WIN)) # LEDs glowing through the PETG skin: amber "speaking", the others dark led = np.zeros((PH, PW), np.float32) for i, p in enumerate(project(LEDS)): if i == 0: led += np.exp(-(((xx - p[0]) / 5) ** 2 + ((yy - p[1]) / 5) ** 2)) ledrgb = led[..., None] * AMBER * 0.9 # ------------------------------------------------------------------ composite img = bg.copy() # reflection: dimmed, blurred, fading with distance below the base refl_all = refl + facer * 1.2 fadeR = np.clip(1 - (yy - floor_y) / 520, 0, 1) ** 1.5 * (yy > floor_y - 4) refl_all = blur3(refl_all, 2.2) * (0.42 * fadeR)[..., None] mr = blur(maskr, 2.0) * fadeR img = img * (1 - 0.55 * mr[..., None]) + refl_all # robot img = img * (1 - mask[..., None]) + robot * mask[..., None] img = img + face * 1.15 + ledrgb # bloom from the screen and the brightest edges bright = face * 1.2 + e_main[..., None] * WARM * 0.25 + ledrgb img += blur3(bright, 9) * 0.9 + blur3(bright, 40) * 0.55 # amber spill from the screen onto the body front scr = project(WIN).mean(0) spill = np.exp(-(((xx - scr[0]) / 170) ** 2 + ((yy - scr[1]) / 210) ** 2)) * mask img += spill[..., None] * SCREEN * 0.07 # ------------------------------------------------------------------ typography and callouts ui = Image.new("RGBA", (PW, PH), (0, 0, 0, 0)) du = ImageDraw.Draw(ui) def tracked(draw, xy, text, font, fill, tracking, anchor_center=False): widths = [draw.textlength(ch, font=font) for ch in text] total = sum(widths) + tracking * (len(text) - 1) x, y = xy if anchor_center: x -= total / 2 for ch, w in zip(text, widths): draw.text((x, y), ch, font=font, fill=fill) x += w + tracking return total warm_t = (242, 224, 196, 255) amber_t = (255, 176, 82, 255) teal_t = (110, 182, 188, 255) dim_t = (150, 165, 168, 210) f_kick = ImageFont.truetype(FONT_M, 26) f_head = ImageFont.truetype(FONT_B, 210) f_tag = ImageFont.truetype(FONT_L, 58) f_call_h = ImageFont.truetype(FONT_C, 30) f_call = ImageFont.truetype(FONT_M, 22) f_spec = ImageFont.truetype(FONT_M, 24) f_fine = ImageFont.truetype(FONT_M, 19) M = 96 tracked(du, (M, 92), "PERSONAL AGENT UNIT // MODEL H-1", f_kick, teal_t, 6) du.line([(M, 138), (PW - M, 138)], fill=(110, 182, 188, 90), width=1) head_layer = Image.new("RGBA", (PW, PH), (0, 0, 0, 0)) dh = ImageDraw.Draw(head_layer) tracked(dh, (PW / 2, 150), "HERMES", f_head, warm_t, 46, anchor_center=True) glow_h = head_layer.filter(ImageFilter.GaussianBlur(18)) tracked(du, (PW / 2, 400), "A face for the mind on your machine.", f_tag, (232, 214, 186, 235), 3, anchor_center=True) # callouts: anchor on the model, label off to the side def callout(p3, label, sub, side, y_label): p = project(np.array([p3]))[0] x_lab = M + 10 if side < 0 else PW - M - 10 wl = sum(du.textlength(c, font=f_call_h) for c in label) + 4 * (len(label) - 1) ws = sum(du.textlength(c, font=f_call) for c in sub) + 2 * (len(sub) - 1) span = max(wl, ws) + 18 elbow = (x_lab + (span if side < 0 else -span), y_label) du.ellipse([p[0] - 7, p[1] - 7, p[0] + 7, p[1] + 7], outline=teal_t, width=2) du.line([(p[0], p[1]), elbow, (x_lab, y_label)], fill=(110, 182, 188, 200), width=2) if side < 0: tracked(du, (x_lab, y_label - 44), label, f_call_h, warm_t, 4) tracked(du, (x_lab, y_label + 10), sub, f_call, dim_t, 2) else: w = sum(du.textlength(c, font=f_call_h) for c in label) + 4 * (len(label) - 1) tracked(du, (x_lab - w, y_label - 44), label, f_call_h, warm_t, 4) w2 = sum(du.textlength(c, font=f_call) for c in sub) + 2 * (len(sub) - 1) tracked(du, (x_lab - w2, y_label + 10), sub, f_call, dim_t, 2) a = math.radians(-12) def posed(X, Y, Z): # body coordinates -> posed frame (matches pose() in the .scad) qx, qy, qz = X - 43, Y - 20, Z + 38.6 return [qx * math.cos(a) - qy * math.sin(a), qx * math.sin(a) + qy * math.cos(a), qz] lens = posed(43, 3.0, 146.1 + 26 - 1.0) grille = posed(48, 0, 25.5) win_c = WIN.mean(0) plate_edge = [38 * math.sin(math.radians(-150)), 38 * math.cos(math.radians(-150)), 36.6] callout(lens, "EYE", "5 MP OPTICAL · TILT ±30°", 1, 640) callout(win_c, "FACE", "240 × 240 PHOSPHOR", 1, 1020) callout(grille, "VOICE", "8 Ω SPEAKER · STEREO EARS", -1, 1560) callout(plate_edge, "NECK", "PAN ±80° ON A BALL RACE", 1, 1880) # footer du.line([(M, PH - 250), (PW - M, PH - 250)], fill=(110, 182, 188, 90), width=1) tracked(du, (PW / 2, PH - 222), "LISTENS. LOOKS. ANSWERS.", ImageFont.truetype(FONT_B, 40), amber_t, 14, anchor_center=True) tracked(du, (PW / 2, PH - 150), "THE THINKING STAYS ON YOUR OWN COMPUTER.", f_spec, dim_t, 5, anchor_center=True) tracked(du, (M, PH - 78), "RUNS WITH HERMES AGENT · SOME ASSEMBLY REQUIRED · PRINTED IN PETG", f_fine, (120, 135, 138, 200), 3) tracked(du, (PW - M - 150, PH - 78), "H-1 / 2026", f_fine, (120, 135, 138, 200), 3) # crop marks for (x, y, sx, sy) in [(40, 40, 1, 1), (PW - 40, 40, -1, 1), (40, PH - 40, 1, -1), (PW - 40, PH - 40, -1, -1)]: du.line([(x, y), (x + 34 * sx, y)], fill=(110, 182, 188, 140), width=2) du.line([(x, y), (x, y + 34 * sy)], fill=(110, 182, 188, 140), width=2) def over(base, layer, gain=1.0): a = np.asarray(layer).astype(np.float32) / 255 return base * (1 - a[..., 3:4] * gain) + a[..., :3] * a[..., 3:4] * gain img += np.asarray(glow_h).astype(np.float32)[..., :3] / 255 * (np.asarray(glow_h)[..., 3:4] / 255) * np.array([1.0, 0.62, 0.22]) * 0.9 img = over(img, head_layer) img = over(img, ui) # ------------------------------------------------------------------ CRT finish # gentle tone curve and split toning: teal shadows, amber highlights lum = img.mean(-1, keepdims=True) img = img + (0.03 * (1 - lum) * np.array([-0.3, 0.2, 0.35])) + 0.04 * lum * np.array([0.4, 0.1, -0.3]) img = np.clip(img, 0, None) img = img / (1 + img * 0.18) # soft shoulder # chromatic fringe r = np.roll(img[..., 0], 2, axis=1); b = np.roll(img[..., 2], -2, axis=1) img = np.stack([r, img[..., 1], b], -1) # scanlines: 3 px period, plus a slow brighter roll band row = yy.astype(int) % 3 scan = np.where(row == 0, 0.58, np.where(row == 1, 0.92, 1.0)) band = 1 + 0.06 * np.exp(-((yy - PH * 0.33) / 90) ** 2) img *= (scan * band)[..., None] # grain and vignette img += rng.normal(0, 0.018, img.shape).astype(np.float32) vig = 1 - 0.55 * (((xx - PW / 2) / (PW * 0.75)) ** 2 + ((yy - PH * 0.52) / (PH * 0.68)) ** 2) img *= np.clip(vig, 0.25, 1)[..., None] out = (np.clip(img, 0, 1) ** (1 / 1.08) * 255).astype(np.uint8) Image.fromarray(out).save(OUT) print("wrote", OUT)