Files
DeeJanuz--frametop/hands/rec/poses/make_poses.py
T
DeeJanuzandClaude Opus 5.5 35196e2955 Hands: step mode and pose pictures for the hand recorder
The first real session moved on every 5 s with text only, too fast to follow.
Each step now waits for Next (Space or the window's button), counts down 3-2-1
while recording, then holds. P pauses, R redoes a step, S skips a section;
"Advance by itself" (--auto) keeps the old timed flow. Nothing records while
a step waits: each step is its own recording part.

The panel and the window show a picture of each pose (hands/rec/poses,
generated by make_poses.py from a parametric hand, MIT) and a diagram of where
to hold the hands and how far out. prompts.jsonl gains ready, wait and redo
events; session.json gains mode.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 16:34:45 -06:00

1314 lines
48 KiB
Python

#!/usr/bin/env python3
# SPDX-License-Identifier: MIT
# Copyright (c) 2026 DeeJanuz
"""Example pose images for the hand recorder's headset panel.
Everything here is drawn by this script: a simple parametric hand (a palm
slab and tapered capsules for the finger bones), posed with joint angles,
ray-marched as a signed distance field with numpy, shaded and outlined.
No outside images, hand models or image generators.
python3 make_poses.py # all poses into this folder
python3 make_poses.py --only fist,ok # some of them
python3 make_poses.py --size 256 --ss 1 # quick, rough preview
Writes <id>.png (RGBA, transparent), poses.json and contact-sheet.png.
Needs numpy and Pillow. It is CPU heavy (about a minute per image on one
core), so run it on a build machine, not on the headset.
"""
import argparse
import json
import math
import os
import sys
from concurrent.futures import ProcessPoolExecutor
for _v in ("OMP_NUM_THREADS", "OPENBLAS_NUM_THREADS", "MKL_NUM_THREADS"):
os.environ.setdefault(_v, "1") # one thread per process: --jobs sets the parallelism
import numpy as np # noqa: E402
from PIL import Image, ImageDraw, ImageFont # noqa: E402
HERE = os.path.dirname(os.path.abspath(__file__))
# ------------------------------------------------------------------ math
def Rx(a):
a = math.radians(a)
c, s = math.cos(a), math.sin(a)
return np.array([[1, 0, 0], [0, c, -s], [0, s, c]], float)
def Ry(a):
a = math.radians(a)
c, s = math.cos(a), math.sin(a)
return np.array([[c, 0, s], [0, 1, 0], [-s, 0, c]], float)
def Rz(a):
a = math.radians(a)
c, s = math.cos(a), math.sin(a)
return np.array([[c, -s, 0], [s, c, 0], [0, 0, 1]], float)
def unit(v):
v = np.asarray(v, float)
return v / np.linalg.norm(v)
def orient(f, p):
"""Hand rotation: fingers along f, palm facing p (right hand: thumb = f x p)."""
f = unit(f)
p = np.asarray(p, float)
p = unit(p - f * (p @ f))
return np.column_stack([np.cross(f, p), f, p])
V = lambda *a: np.array(a, float) # noqa: E731
def smin(a, b, k):
if k <= 0:
return np.minimum(a, b)
h = np.clip(0.5 + 0.5 * (b - a) / k, 0.0, 1.0)
return b + (a - b) * h - k * h * (1.0 - h)
# ------------------------------------------------------------------ SDF nodes
# Every node maps points P (N,3) to signed distances (N,). Units are cm.
# xf(M, t) returns a copy moved by p -> M p + t (M orthonormal, may mirror).
class Cone:
"""Round cone (tapered capsule) from a (radius r1) to b (radius r2)."""
def __init__(s, a, b, r1, r2):
s.a, s.b, s.r1, s.r2 = V(*a), V(*b), float(r1), float(r2)
def xf(s, M, t):
return Cone(M @ s.a + t, M @ s.b + t, s.r1, s.r2)
def bounds(s):
return [((s.a + s.b) / 2, np.linalg.norm(s.b - s.a) / 2 + max(s.r1, s.r2))]
def d(s, P):
ba = s.b - s.a
l2 = ba @ ba
rr = s.r1 - s.r2
a2 = l2 - rr * rr
il2 = 1.0 / l2
pa = P - s.a
y = pa @ ba
z = y - l2
q = pa * l2 - y[:, None] * ba
x2 = np.einsum("ij,ij->i", q, q)
y2 = y * y * l2
z2 = z * z * l2
k = math.copysign(1.0, rr) * rr * rr * x2 if rr != 0 else np.zeros_like(x2)
d1 = np.sqrt(x2 + z2) * il2 - s.r2
d2 = np.sqrt(x2 + y2) * il2 - s.r1
d3 = (np.sqrt(np.maximum(x2 * a2 * il2, 0.0)) + y * rr) * il2 - s.r1
return np.where(np.sign(z) * a2 * z2 > k, d1, np.where(np.sign(y) * a2 * y2 < k, d2, d3))
class Box:
"""Rounded box. R's columns are the box axes. taper: x half-size factor at -y end."""
def __init__(s, c, R, h, r, taper=None, bulge=0.0):
s.c, s.R, s.h, s.r, s.taper, s.bulge = V(*c), np.asarray(R, float), V(*h), float(r), taper, bulge
def xf(s, M, t):
return Box(M @ s.c + t, M @ s.R, s.h, s.r, s.taper, s.bulge)
def bounds(s):
return [(s.c, np.linalg.norm(s.h) + s.r)]
def d(s, P):
L = (P - s.c) @ s.R
q = np.abs(L) - s.h
if s.taper is not None:
tt = np.clip((L[:, 1] + s.h[1]) / (2 * s.h[1]), 0, 1)
q[:, 0] = np.abs(L[:, 0]) - s.h[0] * (s.taper + (1 - s.taper) * tt)
d = np.linalg.norm(np.maximum(q, 0), axis=1) + np.minimum(q.max(1), 0) - s.r
if s.bulge: # gently convex faces instead of flat ones
ex, ey = s.h[0] + s.r, s.h[1] + s.r
d = d - s.bulge * np.clip(1 - (L[:, 0] / ex) ** 2, 0, 1) * np.clip(1 - (L[:, 1] / ey) ** 2, 0, 1)
return d
class Ell:
"""Ellipsoid (approximate distance)."""
def __init__(s, c, R, rad):
s.c, s.R, s.rad = V(*c), np.asarray(R, float), V(*rad)
def xf(s, M, t):
return Ell(M @ s.c + t, M @ s.R, s.rad)
def bounds(s):
return [(s.c, s.rad.max())]
def d(s, P):
L = (P - s.c) @ s.R
k0 = np.linalg.norm(L / s.rad, axis=1)
k1 = np.linalg.norm(L / (s.rad * s.rad), axis=1)
return k0 * (k0 - 1.0) / np.maximum(k1, 1e-6)
class Cyl:
"""Rounded cylinder along local y: radius ra, half height hh, edge rounding rb."""
def __init__(s, c, R, ra, hh, rb=0.1):
s.c, s.R, s.ra, s.hh, s.rb = V(*c), np.asarray(R, float), ra, hh, rb
def xf(s, M, t):
return Cyl(M @ s.c + t, M @ s.R, s.ra, s.hh, s.rb)
def bounds(s):
return [(s.c, math.hypot(s.ra, s.hh))]
def d(s, P):
L = (P - s.c) @ s.R
dx = np.hypot(L[:, 0], L[:, 2]) - s.ra + s.rb
dy = np.abs(L[:, 1]) - s.hh + s.rb
return np.minimum(np.maximum(dx, dy), 0) + np.hypot(np.maximum(dx, 0), np.maximum(dy, 0)) - s.rb
class Torus:
"""Torus in the local xz plane (axis local y)."""
def __init__(s, c, R, R1, r2):
s.c, s.R, s.R1, s.r2 = V(*c), np.asarray(R, float), R1, r2
def xf(s, M, t):
return Torus(M @ s.c + t, M @ s.R, s.R1, s.r2)
def bounds(s):
return [(s.c, s.R1 + s.r2)]
def d(s, P):
L = (P - s.c) @ s.R
return np.hypot(np.hypot(L[:, 0], L[:, 2]) - s.R1, L[:, 1]) - s.r2
class Keys:
"""A grid of nx * nz key caps on the local xz plane (local y up)."""
def __init__(s, c, R, nx, nz, pitch, kh, r):
s.c, s.R, s.nx, s.nz, s.pitch, s.kh, s.r = V(*c), np.asarray(R, float), nx, nz, pitch, V(*kh), r
def xf(s, M, t):
return Keys(M @ s.c + t, M @ s.R, s.nx, s.nz, s.pitch, s.kh, s.r)
def bounds(s):
return [(s.c, math.hypot(s.nx * s.pitch, s.nz * s.pitch) / 2 + 1)]
def d(s, P):
L = (P - s.c) @ s.R
cx, cz = (s.nx - 1) / 2, (s.nz - 1) / 2
ix = np.clip(np.round(L[:, 0] / s.pitch + cx), 0, s.nx - 1)
iz = np.clip(np.round(L[:, 2] / s.pitch + cz), 0, s.nz - 1)
q = np.abs(np.stack([L[:, 0] - (ix - cx) * s.pitch, L[:, 1], L[:, 2] - (iz - cz) * s.pitch], 1)) - s.kh
return np.linalg.norm(np.maximum(q, 0), axis=1) + np.minimum(q.max(1), 0) - s.r
class U:
"""Union of nodes, smooth when k > 0."""
def __init__(s, kids, k=0.0):
s.kids, s.k = list(kids), k
def xf(s, M, t):
return U([c.xf(M, t) for c in s.kids], s.k)
def bounds(s):
return [b for c in s.kids for b in c.bounds()]
def d(s, P):
d = s.kids[0].d(P)
for c in s.kids[1:]:
d = smin(d, c.d(P), s.k)
return d
class Sub:
"""a minus b."""
def __init__(s, a, b):
s.a, s.b = a, b
def xf(s, M, t):
return Sub(s.a.xf(M, t), s.b.xf(M, t))
def bounds(s):
return s.a.bounds()
def d(s, P):
return np.maximum(s.a.d(P), -s.b.d(P))
class Clip:
"""a cut by the plane through point o with outward normal n (keeps the -n side)."""
def __init__(s, a, o, n):
s.a, s.o, s.n = a, V(*o), unit(n)
def xf(s, M, t):
return Clip(s.a.xf(M, t), M @ s.o + t, M @ s.n)
def bounds(s):
return s.a.bounds()
def d(s, P):
return np.maximum(s.a.d(P), (P - s.o) @ s.n)
# ------------------------------------------------------------------ the hand
# Local frame of a right hand: wrist joint at the origin, fingers along +y,
# palm facing +z, thumb on the +x side. cm, adult proportions.
FINGERS = [
# name, MCP joint, bone lengths (proximal, middle, distal incl. tip), radii (MCP, PIP, DIP, tip)
("index", (2.5, 9.45, 0.0), (4.2, 2.45, 2.1), (1.03, 0.95, 0.85, 0.77)),
("middle", (0.62, 9.85, 0.0), (4.6, 2.85, 2.3), (1.06, 0.98, 0.87, 0.79)),
("ring", (-1.25, 9.5, 0.0), (4.35, 2.7, 2.2), (1.0, 0.92, 0.83, 0.75)),
("pinky", (-2.95, 8.6, 0.0), (3.5, 2.05, 2.0), (0.9, 0.82, 0.74, 0.67)),
]
THUMB_CMC = (2.1, 2.3, 0.6)
THUMB_LENS = (4.6, 3.3, 2.6)
THUMB_RADII = (1.45, 1.08, 0.98, 0.86)
PARTS = {"palm": 0, "thumb": 1, "index": 2, "middle": 3, "ring": 4, "pinky": 5, "arm": 7}
SKIN = V(0.93, 0.885, 0.84)
NAIL = V(1.0, 0.84, 0.82)
CREASE = V(0.62, 0.55, 0.52)
# palm creases (hand local x, y on the palm side): they tell the palm from the back
CREASES = [
[(-3.7, 7.3), (-2.0, 7.6), (-0.5, 8.0), (0.8, 8.5), (1.6, 9.1)],
[(3.5, 6.9), (2.0, 6.6), (0.5, 6.2), (-1.2, 5.8), (-2.8, 5.5)],
[(3.5, 6.9), (2.2, 6.2), (1.3, 5.0), (0.9, 3.5), (0.9, 2.2), (1.2, 0.9)],
]
def crease_dist(xy):
d = np.full(len(xy), 1e9)
for line in CREASES:
for a, b in zip(line[:-1], line[1:]):
a, b = V(*a), V(*b)
t = np.clip((xy - a) @ (b - a) / ((b - a) @ (b - a)), 0, 1)
d = np.minimum(d, np.linalg.norm(xy - (a + t[:, None] * (b - a)), axis=1))
return d
def thumb_twist(palmar):
return -62.0 - 0.45 * palmar
def finger_fk(base, lens, radii, ang):
mcp, pip, dip, abd = ang
F1 = Rz(-abd) @ Rx(mcp)
F2 = F1 @ Rx(pip)
F3 = F2 @ Rx(dip)
p0 = V(*base)
p1 = p0 + F1 @ V(0, lens[0], 0)
p2 = p1 + F2 @ V(0, lens[1], 0)
p3 = p2 + F3 @ V(0, lens[2] - radii[3], 0)
return dict(pts=[p0, p1, p2, p3], frames=[F1, F2, F3], radii=radii)
def thumb_fk(q, twist=None):
spread, palmar, mcp, ip = q
tw = thumb_twist(palmar) if twist is None else twist
B = Rz(-spread) @ Rx(palmar) @ Ry(tw)
F2 = B @ Rx(mcp)
F3 = F2 @ Rx(ip)
p0 = V(*THUMB_CMC)
p1 = p0 + B @ V(0, THUMB_LENS[0], 0)
p2 = p1 + F2 @ V(0, THUMB_LENS[1], 0)
p3 = p2 + F3 @ V(0, THUMB_LENS[2] - THUMB_RADII[3], 0)
return dict(pts=[p0, p1, p2, p3], frames=[B, F2, F3], radii=THUMB_RADII)
THUMB_LO = V(-30, -15, -15, -25)
THUMB_HI = V(85, 85, 70, 85)
def thumb_ik(target, prior, twist=None, fixed=()):
"""Thumb angles that put the thumb tip centre at target (hand local).
A pattern search, first held close to the prior (which picks the natural
solution), then refined from there to hit the target."""
target = V(*target)
prior = V(*prior)
def search(q, w, step):
def cost(q):
tip = thumb_fk(q, twist)["pts"][3]
return float(np.sum((tip - target) ** 2) + w * np.sum((q - prior) ** 2))
c = cost(q)
while step > 0.05:
better = False
for i in range(4):
if i in fixed:
continue
for sgn in (1, -1):
q2 = q.copy()
q2[i] = np.clip(q2[i] + sgn * step, THUMB_LO[i], THUMB_HI[i])
c2 = cost(q2)
if c2 < c:
q, c, better = q2, c2, True
if not better:
step *= 0.5
return q
q = search(prior.copy(), 0.003, 16.0)
q = search(q, 0.00002, 4.0)
err = float(np.linalg.norm(thumb_fk(q, twist)["pts"][3] - target))
if err > 0.3:
print(f"warning: thumb misses its target by {err:.2f} cm", file=sys.stderr)
return q
def pad_point(f, gap=0.0, seg=2, at=1.0, thumb_r=THUMB_RADII[3]):
"""A point just off the pad side of a finger segment (seg 0..2; at 0..1 along it)."""
p = f["pts"][seg] + (f["pts"][seg + 1] - f["pts"][seg]) * at
r = f["radii"][seg] + (f["radii"][seg + 1] - f["radii"][seg]) * at
return p + f["frames"][seg][:, 2] * (r + thumb_r + gap - 0.08)
def back_point(f, seg=1, at=0.5, gap=0.0, thumb_r=THUMB_RADII[3]):
"""A point just off the back (nail side) of a finger segment."""
p = f["pts"][seg] + (f["pts"][seg + 1] - f["pts"][seg]) * at
r = f["radii"][seg] + (f["radii"][seg + 1] - f["radii"][seg]) * at
return p - f["frames"][seg][:, 2] * (r + thumb_r + gap - 0.1)
def resolve(pose):
"""Pose dict -> local joint data. pose['thumb'] is angles or ('to', fn(J) -> point, prior)."""
J = {}
for name, base, lens, radii in FINGERS:
J[name] = finger_fk(base, lens, radii, pose[name])
th = pose["thumb"]
tw = pose.get("twist")
if isinstance(th, tuple) and th and th[0] == "to":
q = thumb_ik(th[1](J), th[2], tw, th[3] if len(th) > 3 else ())
else:
q = V(*th)
J["thumb"] = thumb_fk(q, tw)
J["thumb_q"] = q
return J
def chain_nodes(f):
p, r = f["pts"], f["radii"]
cones = [Cone(p[i], p[i + 1], r[i], r[i + 1]) for i in range(3)]
# the nail: a flat ellipsoid on the back of the last bone
F = f["frames"][2]
j, tip = p[2], p[3]
L = np.linalg.norm(tip - j)
nail = Ell(j + (tip - j) * 0.8 - F[:, 2] * (r[3] * 0.64), F, (r[3] * 0.72, L * 0.4 + 0.24, 0.32))
return cones, nail
class Hand:
"""A posed hand plus forearm, as an SDF scene item."""
def __init__(s, pose, R=np.eye(3), t=(0, 0, 0), left=False, arm=6.5, wrist=(0, 0), _copy=None):
if _copy is not None:
return
J = resolve(pose)
s.J = J
wf, wd = pose.get("wrist", wrist)
R = np.asarray(R, float)
S = np.diag([-1.0, 1, 1]) if left else np.eye(3)
Mh = S @ R @ Rx(wf) @ Rz(-wd)
Ma = S @ R
t = S @ V(*t)
s.M, s.t = Mh, t
palm = Box((-0.05, 4.95, 0.0), np.eye(3), (2.75, 4.15, 0.35), 1.1, taper=0.74, bulge=0.45)
meta_t = Cone(J["thumb"]["pts"][0], J["thumb"]["pts"][1], THUMB_RADII[0], THUMB_RADII[1])
heel = Cone((-1.9, 1.6, 0.45), (-2.4, 6.5, 0.35), 1.15, 0.95)
knuckles = [Ell(V(*base) + V(0, -0.35, -0.55), np.eye(3), (0.95, 0.9, 0.8)) for _, base, _, _ in FINGERS]
s.core = U([U([palm, meta_t, heel], 1.1)] + knuckles, 0.5).xf(Mh, t)
s.arm = Box((0, -arm / 2 + 0.6, 0), np.eye(3), (1.25, arm / 2, 0.3), 1.35, bulge=0.2).xf(Ma, t)
s.arm_o = Ma @ V(0, 0.6, 0) + t
s.arm_dir = Ma @ V(0, -1, 0)
s.arm_len = arm
s.fingers, s.nails, s.ids = [], [], []
for name in ("thumb", "index", "middle", "ring", "pinky"):
f = J[name]
cones, nail = chain_nodes(f)
if name == "thumb":
cones = cones[1:]
s.fingers.append(U(cones + [nail]).xf(Mh, t))
s.nails.append(nail.xf(Mh, t))
s.ids.append(PARTS[name])
s.k = 0.75
s.hid = 0
def world(s, p):
return s.M @ V(*p) + s.t
def joint(s, name, i):
return s.world(s.J[name]["pts"][i])
def xf(s, M, t):
h = Hand(None, _copy=True)
h.J = s.J
h.M, h.t = M @ s.M, M @ s.t + t
h.core, h.arm = s.core.xf(M, t), s.arm.xf(M, t)
h.arm_o, h.arm_dir, h.arm_len = M @ s.arm_o + t, M @ s.arm_dir, s.arm_len
h.fingers = [f.xf(M, t) for f in s.fingers]
h.nails = [n.xf(M, t) for n in s.nails]
h.ids, h.k, h.hid = s.ids, s.k, s.hid
return h
def bounds(s):
out = s.core.bounds() + s.arm.bounds()
for f in s.fingers:
out += f.bounds()
return out
def _palm(s, P):
return smin(s.core.d(P), s.arm.d(P), 1.0)
def d(s, P):
dp = s._palm(P)
out = dp
for f in s.fingers:
out = np.minimum(out, smin(dp, f.d(P), s.k))
return out
def info(s, P):
ds = [s.core.d(P), s.arm.d(P)] + [f.d(P) for f in s.fingers]
ids = np.array([PARTS["palm"], PARTS["arm"]] + s.ids)[np.argmin(np.stack(ds), 0)]
col = np.tile(SKIN, (len(P), 1))
nail = np.zeros(len(P), bool)
for pid, n in zip(s.ids, s.nails):
nail |= (ids == pid) & (n.d(P) < 0.03)
col[nail] = NAIL
palm = ids == PARTS["palm"]
if palm.any():
Lc = (P[palm] - s.t) @ s.M
w = np.clip(1 - crease_dist(Lc[:, :2]) / 0.13, 0, 1) * np.clip((Lc[:, 2] - 0.9) / 0.4, 0, 1)
col[palm] = col[palm] * (1 - w[:, None]) + CREASE * w[:, None]
along = (P - s.arm_o) @ s.arm_dir
fade = np.clip((s.arm_len - 0.4 - along) / (s.arm_len * 0.55), 0, 1)
fade = fade * fade * (3 - 2 * fade)
return s.hid * 16 + ids, col, fade, nail
class Obj:
"""A plain object: one SDF node, one colour."""
def __init__(s, node, color=(0.56, 0.6, 0.67), oid=0):
s.node, s.color, s.oid = node, V(*color), oid
def xf(s, M, t):
return Obj(s.node.xf(M, t), s.color, s.oid)
def bounds(s):
return s.node.bounds()
def d(s, P):
return s.node.d(P)
def info(s, P):
n = len(P)
return np.full(n, 100 + s.oid), np.tile(s.color, (n, 1)), np.ones(n), np.zeros(n, bool)
# ------------------------------------------------------------------ rendering
LIGHT = unit((-0.45, 0.7, 0.6))
INK = V(0.1, 0.11, 0.13)
def scene_d(items, P):
d = items[0].d(P)
for it in items[1:]:
d = np.minimum(d, it.d(P))
return d
def to_cam(items, cam):
return [it.xf(cam["R"], V(0, 0, 0)) for it in items]
def march(items, cam, W, ss):
"""Orthographic sphere tracing along -z in camera space. Returns per-pixel buffers."""
s = cam["scale"] / ss
Wp = W * ss
cx, cy = cam["center"]
bs = [b for it in items for b in it.bounds()]
C = np.array([b[0] for b in bs])
Rr = np.array([b[1] for b in bs])
zmin = (C[:, 2] - Rr).min() - 0.1
px0 = int(max(0, math.floor(((C[:, 0] - Rr).min() - cx) / s + Wp / 2)))
px1 = int(min(Wp, math.ceil(((C[:, 0] + Rr).max() - cx) / s + Wp / 2)))
py0 = int(max(0, math.floor((cy - (C[:, 1] + Rr).max()) / s + Wp / 2)))
py1 = int(min(Wp, math.ceil((cy - (C[:, 1] - Rr).min()) / s + Wp / 2)))
out = dict(hit=np.zeros((Wp, Wp), bool), z=np.full((Wp, Wp), -1e9), id=np.full((Wp, Wp), -1, int),
rgb=np.zeros((Wp, Wp, 3)), fade=np.zeros((Wp, Wp)), nail=np.zeros((Wp, Wp), bool))
if px1 <= px0 or py1 <= py0:
return out
us = cx + (np.arange(px0, px1) + 0.5 - Wp / 2) * s
vs = cy - (np.arange(py0, py1) + 0.5 - Wp / 2) * s
UU, VV = np.meshgrid(us, vs)
n = UU.size
P = np.zeros((n, 3))
P[:, 0], P[:, 1] = UU.ravel(), VV.ravel()
zst = np.full(n, -1e9)
for c, r in bs: # start each ray where it enters the first bounding sphere
dd = r * r - (P[:, 0] - c[0]) ** 2 - (P[:, 1] - c[1]) ** 2
ok = dd > 0
zst[ok] = np.maximum(zst[ok], c[2] + np.sqrt(dd[ok]))
act = np.nonzero(zst > -1e8)[0]
P[:, 2] = zst + 0.05
hit = np.zeros(n, bool)
eps = 0.0025
for _ in range(320):
if act.size == 0:
break
d = scene_d(items, P[act])
h = d < eps
hit[act[h]] = True
z = P[act, 2] - d * 0.9
P[act[~h], 2] = z[~h]
act = act[(~h) & (z > zmin)]
if act.size:
d = scene_d(items, P[act])
hit[act[d < 0.03]] = True
hi = np.nonzero(hit)[0]
if hi.size == 0:
return out
Ph = P[hi]
# normals (tetrahedron)
e = 0.01
K = np.array([[1, -1, -1], [-1, -1, 1], [-1, 1, -1], [1, 1, 1]], float)
N = np.zeros_like(Ph)
for k in K:
N += k * scene_d(items, Ph + k * e)[:, None]
N /= np.maximum(np.linalg.norm(N, axis=1, keepdims=True), 1e-9)
# ambient occlusion
occ = np.zeros(len(Ph))
for i in range(5):
hh = 0.15 + 0.45 * i
occ += (hh - scene_d(items, Ph + N * hh)) * (0.8 ** i)
ao = np.clip(1.0 - 0.32 * occ, 0, 1)
# soft shadow toward the key light
sh = np.ones(len(Ph))
t = np.full(len(Ph), 0.12)
a2 = np.arange(len(Ph))
P0 = Ph + N * 0.03
for _ in range(40):
if a2.size == 0:
break
hq = scene_d(items, P0[a2] + LIGHT * t[a2, None])
sh[a2] = np.minimum(sh[a2], 7.0 * hq / t[a2])
t[a2] += np.clip(hq, 0.06, 2.0)
a2 = a2[(sh[a2] > 0.03) & (t[a2] < 30)]
sh = np.clip(sh, 0, 1)
sh = sh * sh * (3 - 2 * sh)
# labels and base colours: nearest item wins
dist = np.stack([it.d(Ph) for it in items])
which = np.argmin(dist, 0)
ids = np.zeros(len(Ph), int)
base = np.zeros((len(Ph), 3))
fade = np.ones(len(Ph))
nail = np.zeros(len(Ph), bool)
for k, it in enumerate(items):
m = which == k
if m.any():
ids[m], base[m], fade[m], nail[m] = it.info(Ph[m])
diff = np.clip(N @ LIGHT, 0, 1) * (0.25 + 0.75 * sh)
hemi = 0.5 + 0.5 * N[:, 1]
amb = (0.62 + 0.38 * hemi) * ao
rim = np.clip(1 - np.clip(N[:, 2], 0, 1), 0, 1) ** 3 * 0.12
spec = np.clip(N @ unit(LIGHT + V(0, 0, 1)), 0, 1) ** 24 * 0.08 * sh
col = base * (0.5 * amb + 0.55 * diff)[:, None] + (rim + spec)[:, None]
col = np.clip(col, 0, 1)
yy, xx = np.divmod(np.arange(n)[hi], px1 - px0)
yy += py0
xx += px0
out["hit"][yy, xx] = True
out["z"][yy, xx] = Ph[:, 2]
out["id"][yy, xx] = ids
out["rgb"][yy, xx] = col
out["fade"][yy, xx] = fade
out["nail"][yy, xx] = nail
return out
def disk(r):
R = int(math.ceil(r + 1))
return [(dx, dy, math.hypot(dx, dy)) for dy in range(-R, R + 1) for dx in range(-R, R + 1)
if math.hypot(dx, dy) <= r + 0.5]
def smooth_pair(a, b):
"""No line where palm/forearm meets the same hand's fingers."""
same = (a // 16 == b // 16) & (a < 100) & (b < 100)
pa, pb = a % 16, b % 16
return same & ((pa == 0) | (pa == 7) | (pb == 0) | (pb == 7))
def ink(buf, ss, out_w=2.6, in_w=2.3, zt=0.45, line=INK, nail_line=V(0.62, 0.58, 0.56)):
"""Shaded buffers -> premultiplied RGBA with outline and inner lines."""
hit, Z, ID, rgb, F, NL = buf["hit"], buf["z"], buf["id"], buf["rgb"], buf["fade"], buf["nail"]
ro, ri = out_w * ss / 2 + 0.5, in_w * ss / 2 + 0.25
inner = np.zeros(hit.shape)
outer = np.zeros(hit.shape)
ofade = np.zeros(hit.shape)
nl = np.zeros(hit.shape)
Fh = np.where(hit, F, 0)
for dx, dy, r in disk(max(ro, ri) + 1):
Hq = np.roll(hit, (dy, dx), (0, 1))
wo = np.clip(ro - r + 0.5, 0, 1)
wi = np.clip(ri - r + 0.5, 0, 1)
if wo > 0:
m = (~hit) & Hq
outer = np.maximum(outer, m * wo)
ofade = np.maximum(ofade, np.roll(Fh, (dy, dx), (0, 1)) * (m * wo > 0))
if wi > 0:
Zq = np.roll(Z, (dy, dx), (0, 1))
Iq = np.roll(ID, (dy, dx), (0, 1))
e = hit & (((~Hq) & (r <= ro * 0.6)) | (Hq & (Zq - Z > zt)) |
(Hq & (Iq != ID) & ~smooth_pair(ID, Iq) & (Zq >= Z - 0.15)))
inner = np.maximum(inner, e * wi)
if r <= ss * 0.7 + 0.5:
Nq = np.roll(NL, (dy, dx), (0, 1))
nl = np.maximum(nl, (hit & Hq & (Nq != NL)) * 1.0)
col = rgb.copy()
col = col * (1 - nl[..., None] * 0.7) + nail_line * nl[..., None] * 0.7
col = col * (1 - inner[..., None]) + line * inner[..., None]
alpha = np.where(hit, F, outer * ofade)
col = np.where(hit[..., None], col, line)
return col * alpha[..., None], alpha
def over(dst_rgb, dst_a, src_rgb, src_a):
return src_rgb + dst_rgb * (1 - src_a[..., None]), src_a + dst_a * (1 - src_a)
# ------------------------------------------------------------------ arrows
ACCENT = (255, 184, 64)
ACCENT_DARK = (24, 26, 32)
def arc(c, u, v, r, a0, a1, n=48):
c, u, v = V(*c), unit(u), unit(v)
a = np.radians(np.linspace(a0, a1, n))
return [c + r * (math.cos(t) * u + math.sin(t) * v) for t in a]
def seg(p0, p1, n=12):
p0, p1 = V(*p0), V(*p1)
return [p0 + (p1 - p0) * t for t in np.linspace(0, 1, n)]
def project(cam, W, ss, p):
q = cam["R"] @ V(*p)
s = cam["scale"] / ss
return ((q[0] - cam["center"][0]) / s + W * ss / 2, (cam["center"][1] - q[1]) / s + W * ss / 2)
def draw_arrows(arrows, cam, W, ss):
img = Image.new("RGBA", (W * ss, W * ss), (0, 0, 0, 0))
dr = ImageDraw.Draw(img)
sc = ss * W / 512
lw, bw, hl, hw = 7 * sc, 3.2 * sc, 22 * sc, 15 * sc
for a in arrows:
pts = [project(cam, W, ss, p) for p in a["pts"]]
heads = a.get("heads", "end")
k = a.get("scale", 1.0)
shapes = []
def head(tip, prev):
dx, dy = tip[0] - prev[0], tip[1] - prev[1]
L = math.hypot(dx, dy) or 1
dx, dy = dx / L, dy / L
base = (tip[0] - dx * hl * k, tip[1] - dy * hl * k)
return [(tip[0] + dx * 2 * sc, tip[1] + dy * 2 * sc),
(base[0] - dy * hw * k, base[1] + dx * hw * k),
(base[0] + dy * hw * k, base[1] - dx * hw * k)]
def back_to(pts, dist):
acc = 0
for i in range(len(pts) - 1, 0, -1):
acc += math.dist(pts[i], pts[i - 1])
if acc >= dist:
return pts[i - 1]
return pts[0]
body = list(pts)
if heads in ("end", "both"):
shapes.append(head(pts[-1], back_to(pts, hl * k)))
# stop the line under the arrow head
while len(body) > 2 and math.dist(body[-1], pts[-1]) < hl * k * 0.6:
body.pop()
if heads in ("start", "both"):
rp = pts[::-1]
shapes.append(head(rp[-1], back_to(rp, hl * k)))
while len(body) > 2 and math.dist(body[0], pts[0]) < hl * k * 0.6:
body.pop(0)
for width, colr, grow in ((lw * k + 2 * bw, ACCENT_DARK, bw), (lw * k, ACCENT, 0)):
dr.line(body, fill=colr, width=int(round(width)), joint="curve")
for e in (body[0], body[-1]):
r = width / 2
dr.ellipse([e[0] - r, e[1] - r, e[0] + r, e[1] + r], fill=colr)
for sh in shapes:
if grow:
cxs = sum(p[0] for p in sh) / 3
cys = sum(p[1] for p in sh) / 3
big = []
for p in sh:
dx, dy = p[0] - cxs, p[1] - cys
L = math.hypot(dx, dy) or 1
big.append((p[0] + dx / L * grow * 1.8, p[1] + dy / L * grow * 1.8))
dr.polygon(big, fill=colr)
else:
dr.polygon(sh, fill=colr)
return img
# ------------------------------------------------------------------ layout
def fit(spec, W):
"""Pick scale and centre so that everything fits with a margin."""
cam = spec["cam"]
pts = []
lo = W // 4
tmp = dict(cam, scale=1.0, center=(0.0, 0.0))
for layer in spec["layers"]:
items = to_cam(layer["items"], cam)
bs = [b for it in items for b in it.bounds()]
C = np.array([b[0] for b in bs])
Rr = np.array([b[1] for b in bs])
ext = max(np.abs(C[:, :2]).max() + Rr.max(), 1.0) * 2.1
tmp = dict(cam, scale=ext / lo, center=(0.0, 0.0))
buf = march(items, tmp, lo, 1)
yy, xx = np.nonzero(buf["hit"] & (buf["fade"] > 0.35))
s = tmp["scale"]
for x, y in ((xx.min(), yy.min()), (xx.max() + 1, yy.max() + 1)):
pts.append((((x - lo / 2) * s), (-(y - lo / 2) * s)))
for a in spec.get("arrows", []):
for p in a["pts"]:
q = cam["R"] @ V(*p)
pts.append((q[0], q[1]))
pts = np.array(pts)
x0, y0 = pts.min(0)
x1, y1 = pts.max(0)
margin = 26 if spec.get("arrows") else 14
scale = max(spec.get("scale", 0.056), (x1 - x0) / (W - 2 * margin), (y1 - y0) / (W - 2 * margin))
return dict(cam, scale=scale, center=((x0 + x1) / 2, (y0 + y1) / 2))
def render_pose(spec, W=512, ss=2):
cam = fit(spec, W)
Wp = W * ss
rgb = np.zeros((Wp, Wp, 3))
a = np.zeros((Wp, Wp))
for layer in spec["layers"]:
items = to_cam(layer["items"], cam)
for i, it in enumerate(items):
if isinstance(it, Hand):
it.hid = i
buf = march(items, cam, W, ss)
ghost = layer.get("ghost", 0)
if ghost:
buf["rgb"] = buf["rgb"] * 0.55 + V(0.62, 0.78, 1.0) * 0.45
lrgb, la = ink(buf, ss * W / 512, out_w=2.0, in_w=1.2, line=V(0.25, 0.3, 0.4))
lrgb, la = lrgb * ghost, la * ghost
else:
lrgb, la = ink(buf, ss * W / 512)
rgb, a = over(rgb, a, lrgb, la)
if spec.get("arrows"):
arr = np.asarray(draw_arrows(spec["arrows"], cam, W, ss), float) / 255
aa = arr[..., 3]
rgb, a = over(rgb, a, arr[..., :3] * aa[..., None], aa)
# downsample (premultiplied box filter)
rgb = rgb.reshape(W, ss, W, ss, 3).mean((1, 3))
a = a.reshape(W, ss, W, ss).mean((1, 3))
col = np.where(a[..., None] > 1e-4, rgb / np.maximum(a[..., None], 1e-4), 0)
img = np.dstack([np.clip(col, 0, 1) * 255, np.clip(a, 0, 1) * 255]).round().astype(np.uint8)
return Image.fromarray(img, "RGBA")
# ------------------------------------------------------------------ poses
STRAIGHT = (0, 0, 0)
def P(index, middle, ring, pinky, thumb, **kw):
d = dict(index=index, middle=middle, ring=ring, pinky=pinky, thumb=thumb)
d.update(kw)
return d
def ab(f, a):
return tuple(f[:3]) + (a,)
CURL = {"index": (86, 100, 58, -2), "middle": (88, 102, 58, 1), "ring": (90, 102, 56, 4), "pinky": (92, 98, 52, 8)}
FLAT = P((0, 0, 0, 1), (0, 0, 0, 0), (0, 0, 0, -1), (0, 0, 0, -3), (16, 6, 6, 4))
SPREAD = P((-2, 0, 0, 14), (-2, 0, 0, 2), (-2, 0, 0, -11), (-2, 0, 0, -25), (42, 14, -4, -8))
RELAX = P((12, 16, 8, 6), (15, 20, 10, 1), (18, 24, 12, -5), (22, 28, 14, -12), (38, 24, 12, 14))
FIST = P(CURL["index"], CURL["middle"], CURL["ring"], CURL["pinky"],
("to", lambda J: back_point(J["middle"], 1, 0.35), (-5, 40, 40, 25)))
CLAW = P((-10, 80, 70, 14), (-10, 86, 72, 2), (-10, 86, 72, -11), (-10, 84, 68, -24), (45, 30, 35, 55))
POINT = P((0, 0, 0, 0), CURL["middle"], CURL["ring"], CURL["pinky"],
("to", lambda J: back_point(J["middle"], 1, 0.4), (-5, 40, 40, 25)))
OK = P((55, 50, 30, 3), (4, 6, 3, -3), (6, 8, 4, -13), (8, 10, 5, -26),
("to", lambda J: pad_point(J["index"], 0.0), (12, 50, 5, 15)))
PINCH = P((40, 48, 24, 3), (70, 80, 44, 0), (76, 84, 46, -1), (80, 84, 46, -2),
("to", lambda J: pad_point(J["index"], 0.0), (25, 40, 10, 10)))
PINCH_OPEN = P((34, 40, 18, 4), (46, 60, 30, 0), (54, 66, 34, -2), (60, 68, 36, -5),
("to", lambda J: pad_point(J["index"], 2.8), (30, 35, 5, 5)))
THUMBS = P(CURL["index"], CURL["middle"], CURL["ring"], CURL["pinky"], (62, 12, -5, -8))
GRIP = P((52, 70, 36, 0), (55, 72, 36, 1), (58, 72, 34, 3), (62, 70, 32, 6), (8, 48, 30, 25))
def COUNT(n):
up = ["index", "middle", "ring"][:n]
f = {}
spread = {"index": 4, "middle": -1, "ring": -6}
for name in ("index", "middle", "ring", "pinky"):
f[name] = (0, 0, 0, spread[name]) if name in up else CURL[name]
first_down = ["index", "middle", "ring", "pinky"][n]
th = ("to", lambda J: back_point(J[first_down], 1, 0.4), (-5, 40, 40, 25))
return P(f["index"], f["middle"], f["ring"], f["pinky"], th)
COUNT4 = P((0, 0, 0, 6), (0, 0, 0, 0), (0, 0, 0, -6), (0, 0, 0, -14),
("to", lambda J: (-1.6, 7.6, 2.3), (-15, 40, 40, 20)))
UP = np.eye(3) # palm toward you, fingers up
BACK = Ry(180) # back of the hand toward you
PALM_DOWN_FWD = orient((0, 0, -1), (0, -1, 0)) # on a desk: fingers away, palm down
def H(pose, R=UP, t=(0, 0, 0), **kw):
return Hand(pose, R, t, **kw)
def L(*items, ghost=0):
return dict(items=list(items), ghost=ghost)
def cam(pitch=0, yaw=0):
"""World -> camera. pitch > 0 looks down, yaw > 0 looks from the right."""
return dict(R=Rx(pitch) @ Ry(-yaw))
CTRL_GRIP = P((50, 66, 36, 0), (54, 70, 36, 1), (58, 70, 34, 3), (62, 68, 32, 6), (30, 45, 10, 12))
def ctrl_local():
"""A generic VR controller in the hand frame of CTRL_GRIP (hand local coordinates):
a handle across the curled fingers, a head above the thumb with a thumbstick,
and a strap over the back of the hand."""
y, z, r = grip_hole(resolve(CTRL_GRIP))
ax = unit((0.45, 1.0, 0.1))
c = V(0.0, y - 0.6, z)
a, b = c - ax * 4.5, c + ax * 4.0
grip = Cone(a, b, r * 0.92, r * 1.02)
head_c = b + ax * 1.6 + V(0.9, 0.0, 0.4)
head = Ell(head_c, orient(ax, (0.3, 0, 1)), (2.4, 2.0, 2.0))
body = U([grip, head], 0.9)
up = unit((0.55, 0.55, 0.65))
sb = head_c + up * 1.75
stick = U([Cone(sb - up * 0.4, sb + up * 0.7, 0.36, 0.36),
Cyl(sb + up * 0.85, orient(up, (0, 0, 1)), 0.78, 0.2, 0.12)])
strap = Box((0.3, y - 1.2, -2.3), orient(ax, (0, 0, 1)), (0.95, 4.4, 0.1), 0.18)
return dict(body=body, stick=stick, strap=strap, top=sb + up * 1.05)
CTRL = None
def ctrl_parts(M=np.eye(3), t=(0, 0, 0)):
global CTRL
if CTRL is None:
CTRL = ctrl_local()
t = V(*t)
return [Obj(CTRL["body"], (0.5, 0.54, 0.6), 1).xf(M, t), Obj(CTRL["stick"], (0.3, 0.32, 0.37), 2).xf(M, t),
Obj(CTRL["strap"], (0.36, 0.38, 0.43), 3).xf(M, t)]
def stick_top(hand):
if CTRL is None:
ctrl_parts()
return hand.world(CTRL["top"])
def with_ctrl(hand):
"""Controller objects placed in a hand's frame."""
return ctrl_parts(hand.M, hand.t)
def keyboard(c=(0, 0, 0), nx=14, nz=5):
pitch = 1.9
w, dpt = nx * pitch / 2 + 0.6, nz * pitch / 2 + 0.6
base = Box((c[0], c[1] + 0.8, c[2]), np.eye(3), (w - 0.4, 0.4, dpt - 0.4), 0.4)
keys = Keys((c[0], c[1] + 1.75, c[2]), np.eye(3), nx, nz, pitch, (0.62, 0.18, 0.62), 0.12)
return [Obj(base, (0.42, 0.45, 0.5), 4), Obj(keys, (0.6, 0.64, 0.7), 5)]
def mouse(c=(0, 0, 0)):
body = Clip(Ell((c[0], c[1] + 0.2, c[2]), np.eye(3), (3.1, 3.6, 5.6)), (c[0], c[1], c[2]), (0, -1, 0))
groove = Box((c[0], c[1] + 3.6, c[2] - 3.0), np.eye(3), (0.08, 1.2, 2.6), 0.02)
return [Obj(Sub(body, groove), (0.56, 0.6, 0.67), 6)]
def cup(c=(0, 0, 0)):
c = V(*c)
outer = Cyl(c + V(0, 4.75, 0), np.eye(3), 3.9, 4.75, 0.35)
inner = Cyl(c + V(0, 5.6, 0), np.eye(3), 3.45, 4.75, 0.3)
handle = Torus(c + V(-4.6, 5.2, 0), Rx(90), 2.0, 0.45)
return [Obj(U([Sub(outer, inner), Clip(handle, c + V(-4.2, 0, 0), (1, 0, 0))], 0.3), (0.56, 0.6, 0.67), 7)]
def place(hand_pose, R, anchor, target, joint=("middle", 3), **kw):
"""Hand with a joint (default: middle fingertip) at a target point."""
h = Hand(hand_pose, R, (0, 0, 0), **kw)
p = h.joint(*joint)
if kw.get("left"):
p = p # joint() already includes the mirror
off = V(*target) - p + V(*anchor)
return Hand(hand_pose, R, (np.diag([-1.0, 1, 1]) if kw.get("left") else np.eye(3)) @ off, **kw)
def typing_hands(lift=0.0):
pose = P((18, 42, 18, 3), (20, 46, 20, 0), (22, 48, 20, -3), (26, 46, 20, -7), (30, 28, 10, 10),
wrist=(-12, 0))
R = PALM_DOWN_FWD
rh = place(pose, R, (0, lift, 0), (4.0, 2.55 + 0.74, -1.0))
lh = place(pose, R, (0, lift, 0), (-4.0, 2.55 + 0.74, -1.0), left=True)
return rh, lh
def spec_typing():
rh, lh = typing_hands()
return dict(cam=cam(48), layers=[L(*keyboard(), rh, lh)])
def spec_lift():
rh, lh = typing_hands()
g1, g2 = typing_hands(lift=7)
return dict(cam=cam(48), layers=[L(g1, g2, ghost=0.5), L(*keyboard(), rh, lh)],
arrows=[dict(pts=seg((13.5, 4, 3), (13.5, 11, 3)), heads="both")])
MOUSE_POSE = P((12, 22, 10, 2), (12, 24, 12, -1), (30, 50, 26, -6), (40, 54, 28, -12), (52, 18, 10, 8),
wrist=(-8, 0))
def mouse_hand(c=(0, 0, 0)):
return place(MOUSE_POSE, PALM_DOWN_FWD, (0, 0, 0), V(*c) + V(0.4, 3.6, -4.6))
def spec_mouse():
return dict(cam=cam(35, -40), layers=[L(*mouse(), mouse_hand())])
def spec_switch():
rh, lh = typing_hands()
mh = mouse_hand((26, 0, 0))
return dict(cam=cam(48), layers=[L(mh, ghost=0.5), L(*keyboard(), *mouse((26, 0, 0)), rh, lh)],
arrows=[dict(pts=arc((17, 6, 2), (1, 0, 0), (0, 1, 0), 7, 160, 20), heads="both")])
def grip_hole(J, name="middle", palm_z=1.9):
"""Centre (y, z) and radius of the biggest bar that fits inside a curled finger (hand local)."""
f = J[name]
best = (0.0, 0.0, 0.0)
ys = [p[1] for p in f["pts"]]
zs = [p[2] for p in f["pts"]]
for y in np.linspace(ys[0], max(ys), 60):
for z in np.linspace(palm_z, max(zs), 60):
r = z - palm_z
c = V(y, z)
for i in range(3):
a, b = f["pts"][i][1:], f["pts"][i + 1][1:]
t = np.clip((c - a) @ (b - a) / ((b - a) @ (b - a)), 0, 1)
r = min(r, np.linalg.norm(c - (a + (b - a) * t)) - f["radii"][i + 1])
if r > best[2]:
best = (y, z, r)
return best
GLASS = P((40, 70, 40, 0), (42, 72, 40, 1), (44, 72, 38, 3), (48, 70, 36, 6),
("to", lambda J: (2.4, 9.0, 6.4), (10, 50, 20, 20)))
HOLD_VIEW = Rz(75) @ Ry(-15)
def spec_hold():
h = H(GLASS, HOLD_VIEW)
y, z, r = grip_hole(h.J)
c = h.world((0.3, y, z))
ax = unit(h.M @ V(1, 0, 0))
R = orient(ax, (0, 0, 1)) # the bottle's axis runs along the hand's x axis
body = Cyl(c + ax * 1.0, R, r - 0.05, 6.5, 0.4)
neck = Cone(c + ax * 7.0, c + ax * 9.6, r * 0.75, r * 0.45)
cap = Cyl(c + ax * 10.0, R, r * 0.5, 0.6, 0.15)
bottle = Obj(U([body, neck, cap], 0.6), (0.56, 0.6, 0.67), 7)
top = c + ax * 9.5
return dict(cam=cam(12), layers=[L(bottle, h)],
arrows=[dict(pts=seg(top + V(7, -6, 0), top + V(7, 1, 0)), heads="both", scale=0.85)])
def touch_scene(lR, f, p, lt=(0, 0, 0), left=True):
"""A hand holding a controller (left by default) and the other index on its thumbstick."""
ch = Hand(CTRL_GRIP, lR, lt, left=left)
up = ch.M @ unit((0.55, 0.55, 0.65))
tgt = stick_top(ch) + up * 0.7
ph = place(POINT, orient(f, p), (0, 0, 0), tgt, joint=("index", 3), left=not left)
return [ch, *with_ctrl(ch), ph]
def spec_touch_stick():
return dict(cam=cam(20), layers=[L(*touch_scene(Rz(-10) @ Rx(-50), (-1, -1, -0.6), (-0.3, -0.6, -1)))])
def spec_touch_stick_desk(f=(-1, -0.8, 0), p=(0, -1, -0.3)):
hold = Hand(CTRL_GRIP, orient((0, 0, -1), (0, 1, 0)))
objs = with_ctrl(hold)[:2] # no strap: it hangs loose on the desk
up = hold.M @ unit((0.55, 0.55, 0.65))
tgt = stick_top(hold) + up * 0.7
rh = place(POINT, orient(f, p), (0, 0, 0), tgt, joint=("index", 3))
return dict(cam=cam(25), layers=[L(*objs, rh)])
def push_spec(controllers=False):
R = BACK
main = H(RELAX if not controllers else CTRL_GRIP, R, (0, 0, 0))
ghost = H(RELAX if not controllers else CTRL_GRIP, R, (0, 0, -16))
lm = [main] + (with_ctrl(main) if controllers else [])
lg = [ghost] + (with_ctrl(ghost) if controllers else [])
return dict(cam=cam(18, 50), layers=[L(*lg, ghost=0.45), L(*lm)],
arrows=[dict(pts=seg((-4.5, 21, -1), (-4.5, 21, -15)), heads="both")])
def ghost_pair(main_pose, ghost_pose, R, cam_, arrows, ghost_alpha=0.45, **kw):
return dict(cam=cam_, layers=[L(H(ghost_pose, R, **kw), ghost=ghost_alpha), L(H(main_pose, R, **kw))],
arrows=arrows)
def spec_cross():
R = Rz(30)
rh = H(FLAT, R, (-2.2, 0, 1.6), arm=12)
lh = H(FLAT, R, (-2.2, 0, -1.6), left=True, arm=12)
return dict(cam=cam(), layers=[L(rh, lh)],
arrows=[dict(pts=seg((-5, 22, 3), (-14, 22, 3)), scale=0.85),
dict(pts=seg((5, 22, 3), (14, 22, 3)), scale=0.85)])
def spec_overlap():
rh = H(FLAT, Rz(8), (-2.5, 2.5, 3))
lh = H(FLAT, Rz(8), (-2.5, -2.5, -3), left=True)
return dict(cam=cam(), layers=[L(rh, lh)],
arrows=[dict(pts=arc((0, 9, 4), (1, 0, 0), (0, 1, 0), 13.5, 35, 145), heads="both")])
def spec_near_face():
head = Ell((0, 0, 0), np.eye(3), (7.6, 10.2, 8.8))
visor = Box((0, 2.4, 6.5), np.eye(3), (8.2, 2.6, 2.6), 1.4)
band = Torus((0, 3.0, -0.5), Rx(-8), 8.0, 0.9)
objs = [Obj(U([head]), (0.5, 0.53, 0.58), 8), Obj(U([visor, band], 0.4), (0.36, 0.38, 0.43), 9)]
R = orient((0, 1, 0.15), (1, 0, 0.2))
rh = H(RELAX, R, (-12.5, -9, 4))
lh = H(RELAX, R, (-12.5, -9, 4), left=True)
return dict(cam=cam(5, 22), layers=[L(*objs, rh, lh)],
arrows=[dict(pts=arc((0, 2, 0), (1, 0, 0), (0, 1, 0), 15.5, 40, 140), heads="both")])
def spec_screen_point():
R = Rx(-35) @ BACK
h = H(POINT, R, (0, -12, 0))
tip = h.joint("index", 3)
scr = Box((2, tip[1] + 9, -30), np.eye(3), (14, 8, 0.4), 0.5)
stand = Box((2, tip[1] - 1, -30.5), np.eye(3), (1.2, 2.5, 0.3), 0.3)
return dict(cam=cam(8), layers=[L(Obj(U([scr, stand]), (0.42, 0.45, 0.5), 10), h)],
arrows=[dict(pts=seg(tip + V(0, 1.2, 0), tip + V(0, 7.0, -20)), scale=0.75)])
def spec_turn_in_out():
return dict(cam=cam(16), layers=[L(H(RELAX, Ry(-25)))],
arrows=[dict(pts=arc((0, -1.5, 0), (1, 0, 0), (0, 0, 1), 6.5, -20, 200), heads="both")])
def spec_turn_up_down():
R = PALM_DOWN_FWD
Rg = orient((0, 0, -1), (0, 1, 0))
return dict(cam=cam(40), layers=[L(H(FLAT, Rg, (0, 0.2, 0)), ghost=0.4), L(H(FLAT, R))],
arrows=[dict(pts=arc((0, 0, 0.5), (1, 0, 0), (0, 1, 0), 7.0, 15, 165), heads="both")])
def spec_bend():
R = PALM_DOWN_FWD
g1 = H(dict(FLAT, wrist=(0, 28)), R)
g2 = H(dict(FLAT, wrist=(0, -28)), R)
g3 = H(dict(FLAT, wrist=(-35, 0)), R)
return dict(cam=cam(38), layers=[L(g1, g2, ghost=0.35), L(g3, ghost=0.35), L(H(FLAT, R))],
arrows=[dict(pts=arc((0, 1, 0), (1, 0, 0), (0, 0, -1), 21, 60, 120), heads="both"),
dict(pts=arc((0, 1, 0), (0, 0, -1), (0, 1, 0), 21, 10, 50), heads="both", scale=0.8)])
PINCH_VIEW = Ry(-90)
def spec_pinch_tap():
h = H(PINCH_OPEN, PINCH_VIEW)
a, b = h.joint("index", 3), h.joint("thumb", 3)
side = V(-3.2, 0, 0)
return dict(cam=cam(20), layers=[L(h)],
arrows=[dict(pts=seg(a + side + V(0, 1.0, 0), b + side - V(0, 1.0, 0)), heads="both", scale=0.7)])
def spec_pinch_drag():
h = H(PINCH, PINCH_VIEW)
g = H(PINCH, PINCH_VIEW, (15, 0, 0))
top = h.joint("middle", 1)
return dict(cam=cam(20), layers=[L(g, ghost=0.4), L(h)],
arrows=[dict(pts=seg(top + V(1, 3.5, 0), top + V(12, 3.5, 0)))])
def spec_grab():
R = Ry(-60)
h = H(GRIP, R)
y, z, r = grip_hole(h.J)
c = h.world((-0.4, y, z))
bar = Cyl(c, orient(h.M @ V(1, 0, 0), (0, 0, 1)), r - 0.05, 8, 0.3)
return dict(cam=cam(10), layers=[L(H(RELAX, R), ghost=0.4), L(h, Obj(bar, (0.5, 0.54, 0.6), 11))],
arrows=[dict(pts=arc(c, (0, 1, 0), (1, 0, 0), 9.5, 20, 100), heads="both", scale=0.85)])
def spec_open_close():
R = UP
return dict(cam=cam(), layers=[L(H(SPREAD, R), ghost=0.45), L(H(FIST, R))],
arrows=[dict(pts=arc((0, 11, 3), (1, 0, 0), (0, 1, 0), 9.5, -20, 60), heads="both", scale=0.85)])
def spec_no_hands():
g = H(RELAX, BACK, (0, 6, 0))
return dict(cam=cam(), layers=[L(g, ghost=0.4)],
arrows=[dict(pts=seg((7.5, 12, 4), (7.5, -2, 4)))])
POSES = {
"flat": ("Palm toward you, fingers together", False, lambda: dict(cam=cam(), layers=[L(H(FLAT))])),
"flat-back": ("Back of the hand toward you", False, lambda: dict(cam=cam(), layers=[L(H(FLAT, BACK))])),
"spread": ("Fingers spread wide", False, lambda: dict(cam=cam(), layers=[L(H(SPREAD))])),
"open": ("Open and relaxed", False, lambda: dict(cam=cam(), layers=[L(H(RELAX, Ry(-25) @ Rx(-10)))])),
"fist": ("A fist", False, lambda: dict(cam=cam(), layers=[L(H(FIST, Ry(-20)))])),
"point": ("Point with your index finger", False,
lambda: dict(cam=cam(20, -30), layers=[L(H(POINT, orient((0.1, 0.5, -1), (-0.6, -1, 0))))])),
"pinch": ("Thumb and index tips touching", False,
lambda: dict(cam=cam(20), layers=[L(H(PINCH, Ry(-90)))])),
"ok": ("OK sign: a ring, three fingers up", False,
lambda: dict(cam=cam(10), layers=[L(H(OK, Ry(-70)))])),
"thumbs-up": ("Thumbs up", False, lambda: dict(cam=cam(), layers=[L(H(THUMBS, Rz(75) @ Ry(-15)))])),
"claw": ("Fingers curled like a claw", False,
lambda: dict(cam=cam(35), layers=[L(H(CLAW, Ry(-20)))])),
"count-1": ("One: index finger", False, lambda: dict(cam=cam(), layers=[L(H(COUNT(1)))])),
"count-2": ("Two: index and middle", False, lambda: dict(cam=cam(), layers=[L(H(COUNT(2)))])),
"count-3": ("Three: index, middle, ring", False, lambda: dict(cam=cam(), layers=[L(H(COUNT(3)))])),
"count-4": ("Four: thumb folded in", False, lambda: dict(cam=cam(), layers=[L(H(COUNT4))])),
"count-5": ("Five: all fingers spread", False, lambda: dict(cam=cam(), layers=[L(H(SPREAD))])),
"turn-in-out": ("Turn your wrist: palm in, palm out", False, spec_turn_in_out),
"turn-up-down": ("Palm down, turn it up, then back", False, spec_turn_up_down),
"bend": ("Bend your wrist up, down, side to side", False, spec_bend),
"pinch-tap": ("Tap thumb and index together", False, spec_pinch_tap),
"pinch-drag": ("Pinch, move to the side, let go", False, spec_pinch_drag),
"grab": ("Close your hand around a bar, open it", False, spec_grab),
"open-close": ("Open and close your hand", False, spec_open_close),
"cross": ("Cross your hands, then uncross", True, spec_cross),
"overlap": ("One hand over the other, slide around", True, spec_overlap),
"near-face": ("Hands near your face, not touching", True, spec_near_face),
"screen-point": ("Point at a screen at arm's length", False, spec_screen_point),
"typing": ("Type on the keyboard", True, spec_typing),
"lift": ("Lift your hands off the keyboard, put them back", True, spec_lift),
"mouse": ("Hand on the mouse, move and click", False, spec_mouse),
"switch": ("Switch between keyboard and mouse", True, spec_switch),
"hold": ("Pick it up, use it, put it down", True, spec_hold),
"push": ("Palms out, push out and back", False, lambda: push_spec(False)),
"push-controller": ("Controller on, push out and back", False, lambda: push_spec(True)),
"touch-stick": ("Touch the thumbstick with your other index", True, spec_touch_stick),
"touch-stick-desk": ("Touch the thumbstick of the controller on the desk", True, spec_touch_stick_desk),
"no-hands": ("Hands down, out of view", False, spec_no_hands),
}
# ------------------------------------------------------------------ main
def _job(args):
pid, out, W, ss = args
img = render_pose(POSES[pid][2](), W, ss)
img.save(os.path.join(out, pid + ".png"), optimize=True)
return pid
def contact_sheet(ids, out, thumb=240, cols=6):
rows = (len(ids) + cols - 1) // cols
pad, lab = 14, 26
W = cols * (thumb + pad) + pad
Hh = rows * (thumb + lab + pad) + pad
sheet = Image.new("RGBA", (W, Hh), (22, 24, 29, 255))
dr = ImageDraw.Draw(sheet)
try:
font = ImageFont.load_default(size=17)
except TypeError:
font = ImageFont.load_default()
for i, pid in enumerate(ids):
x = pad + (i % cols) * (thumb + pad)
y = pad + (i // cols) * (thumb + lab + pad)
dr.rounded_rectangle([x, y, x + thumb, y + thumb], 10, fill=(34, 37, 44, 255))
im = Image.open(os.path.join(out, pid + ".png")).convert("RGBA").resize((thumb, thumb), Image.LANCZOS)
sheet.alpha_composite(im, (x, y))
dr.text((x + thumb / 2, y + thumb + 4), pid, fill=(220, 224, 230, 255), font=font, anchor="mt")
sheet.convert("RGB").save(os.path.join(out, "contact-sheet.png"), optimize=True)
def main():
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--out", default=HERE)
ap.add_argument("--only", default="", help="comma-separated pose ids")
ap.add_argument("--size", type=int, default=512)
ap.add_argument("--ss", type=int, default=3, help="supersampling per axis")
ap.add_argument("--jobs", type=int, default=1)
ap.add_argument("--no-sheet", action="store_true")
a = ap.parse_args()
ids = [p for p in a.only.split(",") if p] or list(POSES)
bad = [p for p in ids if p not in POSES]
if bad:
sys.exit("unknown pose: " + ", ".join(bad))
os.makedirs(a.out, exist_ok=True)
work = [(p, a.out, a.size, a.ss) for p in ids]
if a.jobs > 1:
with ProcessPoolExecutor(a.jobs) as ex:
for pid in ex.map(_job, work):
print(pid, flush=True)
else:
for w in work:
print(_job(w), flush=True)
if not a.only:
meta = {pid: {"file": pid + ".png", "two_hands": two, "caption": cap}
for pid, (cap, two, _) in POSES.items()}
with open(os.path.join(a.out, "poses.json"), "w") as f:
json.dump(meta, f, indent=2)
f.write("\n")
if not a.no_sheet:
have = [p for p in POSES if os.path.exists(os.path.join(a.out, p + ".png"))]
contact_sheet(have, a.out)
if __name__ == "__main__":
main()