Initial commit

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-04 14:28:01 -07:00
co-authored by Claude Sonnet 5.5
commit 4bd4729b46
44 changed files with 519803 additions and 0 deletions
+399
View File
@@ -0,0 +1,399 @@
#!/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
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)