Files
hermes-bot/enclosure/poster/render_poster.py
T
kyleandClaude Sonnet 5.5 cb2bb28cd4 Verify the enclosure STLs and renders against the model; document how to regenerate them
Re-exported all ten STLs and the eight poster pose parts with OpenSCAD 2021.01: same
triangles as the committed files. Recovered the camera for renders/assembly.png and
exploded.png (pixel-identical apart from a few anti-aliased pixels) and wrote the
recipes into enclosure/README.md. The poster script now finds DejaVu fonts on more
distros (or via POSTER_FONT_DIR) and the poster README's pose_info command works on 2021.01.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AFN8nRRCiDiMN7ChBpHJhs
2026-10-05 18:52:34 -07:00

416 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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_<name>")
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
def find_font(name):
"""A DejaVu font by file name. Looks in $POSTER_FONT_DIR first, then where Debian/Ubuntu, Arch and
Fedora keep it, so the poster renders on any of them; fails with a clear message otherwise."""
import os
dirs = [os.environ.get("POSTER_FONT_DIR", ""), "/usr/share/fonts/truetype/dejavu", "/usr/share/fonts/TTF",
"/usr/share/fonts/dejavu", "/usr/share/fonts/dejavu-sans-fonts", "/usr/share/fonts/dejavu-sans-mono-fonts",
os.path.expanduser("~/.local/share/fonts"), os.path.expanduser("~/.fonts")]
for d in dirs:
if d and os.path.isfile(os.path.join(d, name)):
return os.path.join(d, name)
sys.exit(f"render_poster.py needs the DejaVu font {name}. Install DejaVu (Debian: fonts-dejavu, "
f"Arch: ttf-dejavu) or put dejavu-fonts-ttf-2.37/ttf/ somewhere and set POSTER_FONT_DIR to it.")
FONT_B = find_font("DejaVuSans-Bold.ttf")
FONT_L = find_font("DejaVuSans-ExtraLight.ttf")
FONT_C = find_font("DejaVuSansCondensed.ttf")
FONT_M = find_font("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)