Initial commit
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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
@@ -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
@@ -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)
|
||||
Reference in New Issue
Block a user