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
+20
View File
@@ -0,0 +1,20 @@
# Poster
`hermes_poster.png` (1600 × 2400) is rendered from the real enclosure model: hidden-line wireframe
of the feature edges and silhouettes, 6 mm contour slices on the shells, the flat P7 face lit on
the screen, a mirrored pass for the wet floor, then haze, bloom and CRT scanlines.
To regenerate after changing the model:
```bash
cd enclosure
for n in base servo_pan plate body yoke servo_tilt cradle camera; do
openscad -o poster/pose/$n.stl -D "PART=\"pose_$n\"" -D '$fn=48' hermes_robot.scad
done
openscad -o /dev/null -D 'PART="pose_info"' hermes_robot.scad # prints WIN / LED coordinates
python3 poster/render_poster.py
```
If the window or LED positions change, copy the printed `WIN` and `LED` values into
`render_poster.py`. `face.png` is a frame captured from `docs/face-prototype.html` (flat shape,
P7, speaking). Requires `numpy`, `trimesh` and `Pillow`.
Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.6 MiB

File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+198
View File
@@ -0,0 +1,198 @@
solid OpenSCAD_Model
facet normal 0.97815 -0.207899 3.31478e-06
outer loop
vertex 9.46323 -15.6007 219.72
vertex 10.3636 -11.3649 196.093
vertex 9.66912 -14.632 219.859
endloop
endfacet
facet normal 0.978144 -0.207929 -2.20173e-06
outer loop
vertex 10.3636 -11.3649 196.093
vertex 9.46323 -15.6007 219.72
vertex 10.1577 -12.3335 195.954
endloop
endfacet
facet normal -0.0288981 -0.135956 0.990293
outer loop
vertex -14.7846 -9.43426 219.859
vertex 9.46323 -15.6007 219.72
vertex 9.66912 -14.632 219.859
endloop
endfacet
facet normal -0.0289 -0.135964 0.990292
outer loop
vertex 9.46323 -15.6007 219.72
vertex -14.7846 -9.43426 219.859
vertex -14.9905 -10.4029 219.72
endloop
endfacet
facet normal 0.0289012 0.135969 -0.990291
outer loop
vertex 10.1577 -12.3335 195.954
vertex -14.0901 -6.16709 196.093
vertex 10.3636 -11.3649 196.093
endloop
endfacet
facet normal 0.0289002 0.135965 -0.990292
outer loop
vertex -14.0901 -6.16709 196.093
vertex 10.1577 -12.3335 195.954
vertex -14.296 -7.13572 195.954
endloop
endfacet
facet normal -0.978145 0.207923 -4.88768e-08
outer loop
vertex -14.9905 -10.4029 219.72
vertex -14.0901 -6.16709 196.093
vertex -14.296 -7.13572 195.954
endloop
endfacet
facet normal -0.978146 0.207921 -4.24054e-07
outer loop
vertex -14.0901 -6.16709 196.093
vertex -14.9905 -10.4029 219.72
vertex -14.7846 -9.43426 219.859
endloop
endfacet
facet normal 0.205889 0.968628 0.139173
outer loop
vertex -14.0901 -6.16709 196.093
vertex 9.66912 -14.632 219.859
vertex 10.3636 -11.3649 196.093
endloop
endfacet
facet normal 0.205886 0.968628 0.139176
outer loop
vertex 9.66912 -14.632 219.859
vertex -14.0901 -6.16709 196.093
vertex -14.7846 -9.43426 219.859
endloop
endfacet
facet normal -0.205888 -0.968629 -0.139166
outer loop
vertex -14.9905 -10.4029 219.72
vertex -6.51822 -11.4269 214.313
vertex 9.46323 -15.6007 219.72
endloop
endfacet
facet normal -0.205898 -0.968625 -0.139181
outer loop
vertex -14.9905 -10.4029 219.72
vertex -6.28673 -10.3378 206.391
vertex -6.51822 -11.4269 214.313
endloop
endfacet
facet normal -0.205889 -0.968627 -0.139178
outer loop
vertex -6.28673 -10.3378 206.391
vertex -14.296 -7.13572 195.954
vertex 1.53845 -12.0011 206.391
endloop
endfacet
facet normal -0.205891 -0.968627 -0.139177
outer loop
vertex -14.296 -7.13572 195.954
vertex -6.28673 -10.3378 206.391
vertex -14.9905 -10.4029 219.72
endloop
endfacet
facet normal -0.205876 -0.968627 -0.1392
outer loop
vertex 1.30697 -13.0901 214.313
vertex 9.46323 -15.6007 219.72
vertex -6.51822 -11.4269 214.313
endloop
endfacet
facet normal -0.205897 -0.968627 -0.139169
outer loop
vertex 1.53845 -12.0011 206.391
vertex 9.46323 -15.6007 219.72
vertex 1.30697 -13.0901 214.313
endloop
endfacet
facet normal -0.205884 -0.968629 -0.139177
outer loop
vertex 1.53845 -12.0011 206.391
vertex 10.1577 -12.3335 195.954
vertex 9.46323 -15.6007 219.72
endloop
endfacet
facet normal -0.205888 -0.968627 -0.13918
outer loop
vertex 10.1577 -12.3335 195.954
vertex 1.53845 -12.0011 206.391
vertex -14.296 -7.13572 195.954
endloop
endfacet
facet normal 0.978148 -0.20791 9.31053e-07
outer loop
vertex 0.277524 -17.9333 213.617
vertex 1.53845 -12.0011 206.391
vertex 1.30697 -13.0901 214.313
endloop
endfacet
facet normal 0.978147 -0.207913 -1.33146e-06
outer loop
vertex 1.53845 -12.0011 206.391
vertex 0.277524 -17.9333 213.617
vertex 0.50901 -16.8442 205.695
endloop
endfacet
facet normal -0.0289393 -0.136156 0.990265
outer loop
vertex -6.51822 -11.4269 214.313
vertex 0.277524 -17.9333 213.617
vertex 1.30697 -13.0901 214.313
endloop
endfacet
facet normal -0.0289415 -0.136159 0.990264
outer loop
vertex 0.277524 -17.9333 213.617
vertex -6.51822 -11.4269 214.313
vertex -7.54766 -16.27 213.617
endloop
endfacet
facet normal 0.0289415 0.136159 -0.990264
outer loop
vertex 0.50901 -16.8442 205.695
vertex -6.28673 -10.3378 206.391
vertex 1.53845 -12.0011 206.391
endloop
endfacet
facet normal 0.0289415 0.136159 -0.990264
outer loop
vertex -6.28673 -10.3378 206.391
vertex 0.50901 -16.8442 205.695
vertex -7.31617 -15.1809 205.695
endloop
endfacet
facet normal -0.978147 0.207913 8.47143e-07
outer loop
vertex -7.54766 -16.27 213.617
vertex -6.28673 -10.3378 206.391
vertex -7.31617 -15.1809 205.695
endloop
endfacet
facet normal -0.978147 0.207913 8.47143e-07
outer loop
vertex -6.28673 -10.3378 206.391
vertex -7.54766 -16.27 213.617
vertex -6.51822 -11.4269 214.313
endloop
endfacet
facet normal -0.205889 -0.968627 -0.139181
outer loop
vertex -7.31617 -15.1809 205.695
vertex 0.277524 -17.9333 213.617
vertex -7.54766 -16.27 213.617
endloop
endfacet
facet normal -0.205889 -0.968627 -0.139181
outer loop
vertex 0.277524 -17.9333 213.617
vertex -7.31617 -15.1809 205.695
vertex 0.50901 -16.8442 205.695
endloop
endfacet
endsolid OpenSCAD_Model
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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)