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
416 lines
18 KiB
Python
416 lines
18 KiB
Python
#!/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)
|