open-standards/wai/tools/wai_avatar_face_train.py
Transaction Science 66054c9486 avatar-face corpus, its second implementation, and the tool that trained its generator
live-conformance/avatar-face: the generator trained on faces the tool draws, two held-out
identity priors and their tracks, the training report, the seeded generator and identity, and
vectors.json (forms, frames, cases). avatar_face_verify.py runs the driver and the generator's
arithmetic in Python's integers, verifies records and synthesis, and mutates the corpus.
tools/wai_avatar_face_train.py renders the faces, trains, quantises and measures.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-22 10:57:20 -04:00

978 lines
44 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""Train the integer face generator of `extensions/avatars.md` (§7, the lowering `face-int/1`),
measure it, and write the inputs of the corpus `live-conformance/avatar-face/`.
python tools/wai_avatar_face_train.py --out <dir> [--steps 30000] [--qat-steps 8000]
Needs torch and numpy. Everything it trains on is generated here: `render` draws a face from a
seeded parameter set (head shape, skin, hair, eyes, brows, nose, mouth, background) at a pose
and an expression (head translation, scale, roll, yaw and pitch, eyes open or shut, gaze, brows,
mouth open and its width), at four times the output size, and box-filters it down. Each picture
comes with the sixteen keypoints the parameters put on it, exactly. No picture, recording or
footage from outside this repository is read. The generated set carries this repository's
licence.
It writes, under <dir>:
- `generator.wfg`: the generator (§7.2), trained in floating point, then fine-tuned with the
integer arithmetic emulated in the loop (weights on the 2^-12 grid, activations on the
2^-15 grid, the sigmoid table), and written by rounding each float32 weight times 2^12 and
each bias times 2^27 half to even: exact from the float's bits;
- `identities/<name>.wfi`: identity priors (§7.3) of faces held out of training, and
`tracks/<name>.json`: keypoint tracks (§7.4) of held-out motion, which the corpus reads;
- `report.json`: the seeds, the steps, the losses, and the measurement of §7.9: for the
held-out set, PSNR and SSIM of the integer generator against the floating-point network and
against the rendered truth, the keypoint error of a separately trained float keypoint
detector over each, and the bitrate of the keypoint tracks under §7.4's coding.
Every seed is fixed (`--seed`), but float training is not claimed to be bit-reproducible: its
sums depend on the device and the thread count of the machine that runs it. What is pinned is
the generator's bytes, by SHA-256. The integer generation over those bytes is exact (§7.6),
whatever machine trained them. `int_forward` below is the arithmetic of §7.5 to §7.7 in numpy
int64: the integers this tool reports are those, not the float network's.
"""
from __future__ import annotations
import argparse
import hashlib
import json
import math
import os
import struct
import time
from pathlib import Path
import numpy as np
# ---------------------------------------------------------------------------------------
# The arithmetic of §7.5 (the driver's warp and keypoint maps) in numpy int64.
# ---------------------------------------------------------------------------------------
K = 16
FW = 12 # weights are integers at 2^12
FA = 15 # activations are integers at 2^15
FB = FW + FA # biases are integers at 2^27
ACT_HI = 32767
def exp_neg_q16(x: np.ndarray) -> np.ndarray:
"""integer-operators §2: exp_neg_q16 of non-positive Q16 values, elementwise."""
x = x.astype(np.int64)
u = -x
n = u // 45426
g = 45426 * n
r = x + g
t = r + 88670
t2 = t * t
q = t2 >> 16
c = 23495 * q
y = (c >> 16) + 22544
out = np.where(n >= 63, 0, y >> np.minimum(n, 62))
return out.astype(np.int64)
def rdiv(n: np.ndarray, d: np.ndarray) -> np.ndarray:
"""sgn(n)·⌊(2|n| + d) / (2d)⌋ for d ≥ 1: half away from zero (integer-operators E12)."""
a = np.abs(n)
return np.sign(n) * ((2 * a + d) // (2 * d))
def warp_and_maps(ref: np.ndarray, src: np.ndarray, drv: np.ndarray, gauss: int, bg: int):
"""§7.5: the warped reference [h, w, 3] u8 and the keypoint maps [K, h, w] in [0, 256].
`ref` is the identity's picture [h, w, 3] u8, `src` its keypoints and `drv` the driving
keypoints, each [K, 2] integers in sixteenths of a pixel (x, y)."""
h, w, _ = ref.shape
ys, xs = np.mgrid[0:h, 0:w]
px = (16 * xs + 8).astype(np.int64)[None]
py = (16 * ys + 8).astype(np.int64)[None]
dx = px - drv[:, 0].astype(np.int64)[:, None, None]
dy = py - drv[:, 1].astype(np.int64)[:, None, None]
d2 = dx * dx + dy * dy
e = (d2 * gauss + 128) >> 8
wk = exp_neg_q16(-e) # [K, h, w], each in [0, 65553]
den = bg + wk.sum(axis=0)
mx = (src[:, 0] - drv[:, 0]).astype(np.int64)[:, None, None]
my = (src[:, 1] - drv[:, 1]).astype(np.int64)[:, None, None]
ddx = rdiv((wk * mx).sum(axis=0), den)
ddy = rdiv((wk * my).sum(axis=0), den)
ux = 16 * xs + ddx
uy = 16 * ys + ddy
ix = ux // 16
iy = uy // 16
fx = ux - 16 * ix
fy = uy - 16 * iy
x0 = np.clip(ix, 0, w - 1)
x1 = np.clip(ix + 1, 0, w - 1)
y0 = np.clip(iy, 0, h - 1)
y1 = np.clip(iy + 1, 0, h - 1)
r = ref.astype(np.int64)
p00, p01, p10, p11 = r[y0, x0], r[y0, x1], r[y1, x0], r[y1, x1]
fx3, fy3 = fx[..., None], fy[..., None]
v = ((16 - fx3) * (16 - fy3) * p00 + fx3 * (16 - fy3) * p01 + (16 - fx3) * fy3 * p10 + fx3 * fy3 * p11 + 128) >> 8
return v.astype(np.uint8), (wk >> 8).astype(np.int64)
def sigmoid_table() -> np.ndarray:
"""§7.6: the `lut16` table of the mask, T[v + 32768] for v in −32768..32767."""
v = np.arange(0, 32769, dtype=np.int64)
d = 65536 + np.minimum(exp_neg_q16(-16 * v), 65536)
s = ((1 << 31) + d // 2) // d # S(v) for v ≥ 0
t = np.zeros(65536, dtype=np.int64)
t[32768:] = s[:32768] # v = 0 .. 32767
t[1:32768] = 32768 - s[32767:0:-1] # v = −32767 .. −1
t[0] = 0 # v = −32768, never read
return t
# ---------------------------------------------------------------------------------------
# The generator file (§7.2) and its integer forward pass (§7.6, §7.7) in numpy int64.
# ---------------------------------------------------------------------------------------
def layer_shapes(c1: int, c2: int, c3: int, k: int = K):
"""(cin, cout, stride) of the ten convolutions, in the file's order (§7.2)."""
return [
(6 + k, c1, 1), (c1, c2, 2), (c2, c3, 2), (c3, c3, 1), (c3, c3, 1),
(c3, 4 * c2, 1), (2 * c2, c2, 1), (c2, 4 * c1, 1), (2 * c1, c1, 1), (c1, 4, 1),
]
def write_wfg(hdr: dict, layers) -> bytes:
o = bytearray(b"WFG1")
o += struct.pack("<BBHHHHHHI", 1, hdr["k"], hdr["width"], hdr["height"], hdr["c1"], hdr["c2"], hdr["c3"], hdr["gauss"], hdr["bg"])
for w, b in layers:
o += np.asarray(w, dtype="<i2").tobytes()
o += np.asarray(b, dtype="<i4").tobytes()
return bytes(o)
def read_wfg(b: bytes):
assert b[:4] == b"WFG1" and b[4] == 1
_, k, width, height, c1, c2, c3, gauss, bg = struct.unpack_from("<BBHHHHHHI", b, 4)
hdr = dict(k=k, width=width, height=height, c1=c1, c2=c2, c3=c3, gauss=gauss, bg=bg)
at, layers = 22, []
for cin, cout, s in layer_shapes(c1, c2, c3, k):
nw = cout * cin * 9
w = np.frombuffer(b, dtype="<i2", count=nw, offset=at).astype(np.int64).reshape(cout, cin, 3, 3)
at += 2 * nw
bb = np.frombuffer(b, dtype="<i4", count=cout, offset=at).astype(np.int64)
at += 4 * cout
layers.append((w, bb, s))
assert at == len(b)
return hdr, layers
def half_up(p: np.ndarray, s: int) -> np.ndarray:
return p if s == 0 else (p + (1 << (s - 1))) >> s
def conv_int(x: np.ndarray, w: np.ndarray, b: np.ndarray, stride: int) -> np.ndarray:
"""conv 3×3, zero padding 1, then rq: pre bias, shift 12, half up (§7.6)."""
c, h, wd = x.shape
xp = np.zeros((c, h + 2, wd + 2), np.int64)
xp[:, 1:-1, 1:-1] = x
ho, wo = (h - 1) // stride + 1, (wd - 1) // stride + 1
acc = np.zeros((w.shape[0], ho, wo), np.int64)
for ky in range(3):
for kx in range(3):
patch = xp[:, ky:ky + stride * (ho - 1) + 1:stride, kx:kx + stride * (wo - 1) + 1:stride]
acc += np.einsum("oc,chw->ohw", w[:, :, ky, kx], patch)
return half_up(acc + b[:, None, None], FW)
def shuffle2(x: np.ndarray) -> np.ndarray:
c4, h, w = x.shape
c = c4 // 4
return x.reshape(c, 2, 2, h, w).transpose(0, 3, 1, 4, 2).reshape(c, 2 * h, 2 * w)
TABLE = sigmoid_table()
def int_forward(layers, warped: np.ndarray, ref: np.ndarray, heat: np.ndarray) -> np.ndarray:
"""§7.6 and §7.7 over one frame: the generated picture [h, w, 3] u8."""
relu = lambda v: np.clip(v, 0, ACT_HI) # noqa: E731
t3 = (warped.transpose(2, 0, 1).astype(np.int64) - 128) * 256
t4 = (ref.transpose(2, 0, 1).astype(np.int64) - 128) * 256
t5 = heat.astype(np.int64) * 128
x = np.concatenate([t3, t4, t5])
(w1, b1, s1), (w2, b2, s2), (w3, b3, s3), (w4, b4, _), (w5, b5, _), (w6, b6, _), (w7, b7, _), (w8, b8, _), (w9, b9, _), (w10, b10, _) = layers
e1 = relu(conv_int(x, w1, b1, s1))
e2 = relu(conv_int(e1, w2, b2, s2))
e3 = relu(conv_int(e2, w3, b3, s3))
m1 = relu(conv_int(e3, w4, b4, 1))
r = relu(conv_int(m1, w5, b5, 1) + e3)
u2 = relu(shuffle2(conv_int(r, w6, b6, 1)))
d2 = relu(conv_int(np.concatenate([u2, e2]), w7, b7, 1))
u1 = relu(shuffle2(conv_int(d2, w8, b8, 1)))
d1 = relu(conv_int(np.concatenate([u1, e1]), w9, b9, 1))
o = conv_int(d1, w10, b10, 1)
g = np.clip(o[:3], -32768, 32767)
z = np.clip(o[3:4], -32767, 32767)
m = TABLE[z + 32768]
a = half_up(t3 * m, 15)
om = 32768 - m
bb = half_up(g * om, 15)
s = a + bb
out = np.clip(half_up(s, 8) + 128, 0, 255)
return out.transpose(1, 2, 0).astype(np.uint8)
# ---------------------------------------------------------------------------------------
# The keypoint track coding (§7.4): what the bitrate measurement counts.
# ---------------------------------------------------------------------------------------
def zigzag(v: int) -> int:
return 2 * v if v >= 0 else -2 * v - 1
def eg_bits(u: int, k: int) -> int:
"""The length of Exp-Golomb order k of u."""
m = (u >> k) + 1
return 2 * m.bit_length() - 1 + k
def encode_track(frames: np.ndarray, step: int, gen: bytes, ident: bytes, first: int, rate=(25, 1)) -> bytes:
"""A `WFK1` payload of the frames [n, K, 2] (sixteenths of a pixel), the coordinates
rounded to multiples of `step` sixteenths, with the order of the deltas' code chosen to
make the payload shortest."""
q = np.floor(frames / step + 0.5).astype(np.int64)
n = q.shape[0]
deltas = [zigzag(int(v)) for v in (q[1:] - q[:-1]).reshape(-1)]
best = min(range(16), key=lambda k: sum(eg_bits(u, k) for u in deltas))
out = bytearray(b"WFK1")
out += struct.pack("<BBBB", 1, q.shape[1], step, best)
out += gen + ident
out += struct.pack("<IHHH", first, n, rate[0], rate[1])
out += q[0].reshape(-1).astype("<i2").tobytes()
bits = []
for u in deltas:
m = (u >> best) + 1
nb = m.bit_length()
bits += [0] * (nb - 1)
bits += [int(c) for c in format(m, "b")]
if best:
bits += [int(c) for c in format(u & ((1 << best) - 1), "0%db" % best)]
while len(bits) % 8:
bits.append(0)
for i in range(0, len(bits), 8):
out.append(int("".join(map(str, bits[i:i + 8])), 2))
return bytes(out)
# ---------------------------------------------------------------------------------------
# The generated faces.
# ---------------------------------------------------------------------------------------
KEYPOINTS = [
"left eye", "right eye", "left upper lid", "right upper lid", "left pupil", "right pupil",
"nose tip", "mouth left", "mouth right", "upper lip", "lower lip", "chin",
"left brow", "right brow", "left cheek", "right cheek",
]
def identity_params(rng: np.random.Generator) -> dict:
skin = np.array([rng.uniform(0.35, 0.95), 0, 0])
skin[1] = skin[0] * rng.uniform(0.62, 0.82)
skin[2] = skin[0] * rng.uniform(0.45, 0.70)
hair = rng.uniform(0.02, 0.6) * np.array([1.0, rng.uniform(0.6, 0.95), rng.uniform(0.3, 0.8)])
if rng.random() < 0.12:
hair = rng.uniform(0.6, 0.85) * np.ones(3)
return dict(
bg0=rng.uniform(0.1, 0.9, 3), bg1=rng.uniform(0.1, 0.9, 3), bgdir=rng.uniform(0, 2 * math.pi),
bgfreq=rng.uniform(2, 9), bgamp=rng.uniform(0, 0.08),
skin=skin, hair=hair,
a=rng.uniform(0.25, 0.31), b=rng.uniform(0.32, 0.38),
hairline=rng.uniform(-0.62, -0.38), hairvol=rng.uniform(0.0, 0.10), sidehair=rng.uniform(0, 1),
eye_x=rng.uniform(0.34, 0.44), eye_y=rng.uniform(0.0, 0.14), eye_w=rng.uniform(0.40, 0.52),
eye_h=rng.uniform(0.020, 0.030), iris=rng.uniform(0.1, 0.6, 3) * np.array([1, 1, 1.3]),
iris_r=rng.uniform(0.85, 1.15), brow_t=rng.uniform(0.008, 0.016), brow_y=rng.uniform(0.16, 0.24),
nose_l=rng.uniform(0.20, 0.30), nose_w=rng.uniform(0.05, 0.08),
mouth_y=rng.uniform(0.46, 0.58), mouth_w=rng.uniform(0.55, 0.78), lip=np.clip(skin * np.array([rng.uniform(0.75, 1.0), 0.55, 0.55]), 0, 1),
glasses=rng.random() < 0.2, gl_col=rng.uniform(0.0, 0.5, 3),
neck=rng.uniform(0.35, 0.55), shirt=rng.uniform(0.05, 0.95, 3),
)
def pose_params(rng: np.random.Generator, neutral: bool = False) -> dict:
s = 0.35 if neutral else 1.0
return dict(
tx=rng.uniform(-0.06, 0.06) * s, ty=rng.uniform(-0.05, 0.05) * s, scale=1 + rng.uniform(-0.08, 0.08) * s,
roll=rng.uniform(-0.25, 0.25) * s, yaw=rng.uniform(-0.45, 0.45) * s, pitch=rng.uniform(-0.25, 0.25) * s,
open=0.0 if neutral and rng.random() < 0.7 else float(np.clip(rng.uniform(-0.3, 1.0), 0, 1)),
smile=rng.uniform(-0.2, 0.45) * s,
blink_l=0.0 if neutral else float(np.clip(rng.uniform(-1.5, 1.0), 0, 1)),
blink_r=None if rng.random() < 0.85 else float(np.clip(rng.uniform(-1.0, 1.0), 0, 1)),
gx=rng.uniform(-1, 1) * s, gy=rng.uniform(-0.7, 0.7) * s, brow=rng.uniform(-1, 1) * s,
)
def rot(roll, yaw, pitch):
cr, sr, cy, sy, cp, sp = math.cos(roll), math.sin(roll), math.cos(yaw), math.sin(yaw), math.cos(pitch), math.sin(pitch)
rz = np.array([[cr, -sr, 0], [sr, cr, 0], [0, 0, 1]])
ry = np.array([[cy, 0, sy], [0, 1, 0], [-sy, 0, cy]])
rx = np.array([[1, 0, 0], [0, cp, -sp], [0, sp, cp]])
return rz @ ry @ rx
def render(idp: dict, pp: dict, size: int = 64, ss: int = 4):
"""The face of `idp` at the pose and expression `pp`: [size, size, 3] u8 and its
keypoints [K, 2] in pixels (x, y) of the output."""
n = size * ss
yy, xx = np.mgrid[0:n, 0:n].astype(np.float64)
u = (xx + 0.5) / n - 0.5
v = (yy + 0.5) / n - 0.5
img = np.zeros((n, n, 3))
# Background: a gradient and a soft grating.
d = math.cos(idp["bgdir"]) * u + math.sin(idp["bgdir"]) * v
tt = np.clip(d + 0.5, 0, 1)[..., None]
img[:] = idp["bg0"] * (1 - tt) + idp["bg1"] * tt
img += idp["bgamp"] * np.sin(2 * math.pi * idp["bgfreq"] * (u + 0.7 * v))[..., None]
R = rot(pp["roll"], pp["yaw"], pp["pitch"])
sc = pp["scale"]
cx, cy = pp["tx"], pp["ty"] - 0.02
A, B, C = idp["a"], idp["b"], idp["a"] * 0.95
def proj(p):
q = R @ np.asarray(p, dtype=np.float64)
return np.array([cx + sc * q[0], cy + sc * q[1]]), q[2]
def surf(x, y):
z2 = 1 - (x / A) ** 2 - (y / B) ** 2
return C * math.sqrt(max(z2, 0.0))
def ell(c0, ax, ay, ang=0.0):
ca, sa = math.cos(ang), math.sin(ang)
du, dv = u - c0[0], v - c0[1]
ru = (ca * du + sa * dv) / max(ax, 1e-6)
rv = (-sa * du + ca * dv) / max(ay, 1e-6)
return ru * ru + rv * rv
def paint(mask, col, soft=1.5 / n):
nonlocal img
m = np.clip(mask, 0, 1)[..., None]
img = img * (1 - m) + np.asarray(col) * m
def inside(q, edge):
# q < 1 is inside; a soft edge of about `edge` in the normalised measure.
return np.clip((1 - q) / edge + 0.5, 0, 1)
roll = pp["roll"]
# Neck and shirt.
nk, _ = proj([0, B * 0.85, -C * 0.2])
paint(inside(ell(nk + np.array([0, 0.12]), idp["neck"] * A * sc, 0.25 * sc, roll * 0.3), 0.08), idp["skin"] * 0.82)
paint(np.clip((v - (nk[1] + 0.22 * sc)) / 0.01, 0, 1) * inside(ell([nk[0], 0.62], 0.5, 0.3), 0.1), idp["shirt"])
# Hair volume behind the head.
hc, _ = proj([0, -B * 0.25, -C * 0.3])
paint(inside(ell(hc, (A + idp["hairvol"] + 0.02 * idp["sidehair"]) * sc, (B * 0.95 + idp["hairvol"]) * sc, roll), 0.06), idp["hair"])
# The head: the silhouette of the rotated ellipsoid, approximated by its projected centre
# and axes, and shaded by a light the head turns under.
hcen, _ = proj([0, 0, 0])
q = ell(hcen, A * sc, B * sc, roll)
head = inside(q, 0.05)
du, dv = u - hcen[0], v - hcen[1]
ca, sa = math.cos(roll), math.sin(roll)
lu = (ca * du + sa * dv) / (A * sc)
lv = (-sa * du + ca * dv) / (B * sc)
nz = np.sqrt(np.clip(1 - lu * lu - lv * lv, 0, 1))
light = np.array([-0.45, -0.5, 0.74])
lam = np.clip(lu * light[0] * math.cos(pp["yaw"]) + lv * light[1] + nz * light[2] + 0.25 * math.sin(pp["yaw"]), 0, 1)
face_col = idp["skin"][None, None] * (0.55 + 0.5 * lam[..., None])
m = head[..., None]
img = img * (1 - m) + face_col * m
# Hair over the top of the head, above a hairline that turns with it.
hl = idp["hairline"] + 0.08 * pp["pitch"]
hairmask = head * np.clip((hl - lv) / 0.06 + 0.5, 0, 1)
side = idp["sidehair"] * np.clip((np.abs(lu) - 0.82) / 0.05, 0, 1) * np.clip((0.2 - lv) / 0.1, 0, 1)
paint(np.maximum(hairmask, head * side), idp["hair"] * (0.8 + 0.3 * lam[..., None]))
kp = np.zeros((K, 2))
def visible_w(xside):
# Foreshortening of a feature at x on the surface: |n·view| for a turned head.
return max(0.25, math.cos(pp["yaw"] + math.asin(max(-0.95, min(0.95, xside / A)))))
# Eyes.
ey = -idp["eye_y"] * B
for side, (ki, li, pi_) in ((-1, (0, 2, 4)), (1, (1, 3, 5))):
ex = side * idp["eye_x"] * A
p3 = [ex, ey, surf(ex, ey) - 0.01]
c2, _ = proj(p3)
fw = visible_w(ex)
blink = pp["blink_l"] if (side < 0 or pp["blink_r"] is None) else pp["blink_r"]
ew = idp["eye_w"] * A * sc * fw
eh = idp["eye_h"] * sc * (1 - 0.92 * blink)
socket = ell(c2, ew * 0.75, idp["eye_h"] * sc * 1.1, roll)
paint(inside(socket, 0.3) * 0.25, idp["skin"] * 0.6)
eyem = inside(ell(c2, ew * 0.5, eh, roll), 0.12)
paint(eyem, [0.95, 0.94, 0.92])
g = np.array([pp["gx"] * ew * 0.22, pp["gy"] * eh * 0.35])
g = np.array([ca * g[0] - sa * g[1], sa * g[0] + ca * g[1]])
ic = c2 + g
ir = idp["iris_r"] * idp["eye_h"] * sc
paint(inside(ell(ic, ir * fw, ir, roll), 0.15) * eyem, idp["iris"])
paint(inside(ell(ic, ir * 0.45 * fw, ir * 0.45, roll), 0.2) * eyem, [0.03, 0.03, 0.04])
lid = c2 + np.array([sa * eh, -ca * eh])
paint(inside(ell(c2, ew * 0.52, eh * 1.06, roll), 0.12) * (1 - inside(ell(c2, ew * 0.48, eh * 0.9, roll), 0.12)), idp["skin"] * 0.35)
kp[ki] = c2
kp[li] = lid
kp[pi_] = ic
# Brows.
bx = ex
by = ey - idp["brow_y"] * B - 0.012 * pp["brow"]
b2, _ = proj([bx, by, surf(bx, by)])
paint(inside(ell(b2, ew * 0.62, idp["brow_t"] * sc, roll + side * 0.08 * (pp["brow"] - 0.3)), 0.25), idp["hair"] * 0.7)
kp[12 if side < 0 else 13] = b2
# Glasses.
if idp["glasses"]:
for ki in (0, 1):
r0 = idp["eye_w"] * A * sc * 0.62
ring = ell(kp[ki], r0, r0 * 0.8, roll)
paint(np.clip(1 - np.abs(ring - 1) / 0.12, 0, 1), idp["gl_col"])
# Nose: shading along its ridge and two nostrils, with the tip standing out of the face.
ny = idp["nose_l"] * B
tip, _ = proj([0, ny, surf(0, ny) + 0.05])
root, _ = proj([0, ey, surf(0, ey)])
paint(inside(ell((tip + root) / 2, idp["nose_w"] * 0.35 * sc, np.linalg.norm(tip - root) / 2 + 0.01, roll), 0.4) * 0.25, idp["skin"] * 0.7)
for side in (-1, 1):
nn, _ = proj([side * idp["nose_w"] * 0.45, ny + 0.008, surf(0, ny) + 0.03])
paint(inside(ell(nn, 0.010 * sc, 0.006 * sc, roll), 0.4), idp["skin"] * 0.3)
kp[6] = tip
# Mouth: lips, the opening and teeth.
my = idp["mouth_y"] * B
mw = idp["mouth_w"] * A * (1 + 0.35 * pp["smile"])
lc, _ = proj([-mw / 2, my - 0.012 * pp["smile"], surf(-mw / 2, my)])
rc, _ = proj([mw / 2, my - 0.012 * pp["smile"], surf(mw / 2, my)])
mc, _ = proj([0, my, surf(0, my) + 0.01])
opn = pp["open"] * 0.045
up, _ = proj([0, my - 0.008 - opn * 0.2, surf(0, my) + 0.012])
lo, _ = proj([0, my + 0.009 + opn, surf(0, my) + 0.01])
mid = (up + lo) / 2
half_w = np.linalg.norm(rc - lc) / 2
ang = math.atan2(rc[1] - lc[1], rc[0] - lc[0])
hh = np.linalg.norm(lo - up) / 2
paint(inside(ell(mid, half_w, hh + 0.005, ang), 0.15), idp["lip"])
if pp["open"] > 0.05:
inner = inside(ell(mid, half_w * 0.85, max(hh - 0.006, 0.002), ang), 0.2)
paint(inner, [0.25, 0.06, 0.07])
teeth = inner * np.clip(((up[1] + 0.005 * sc + 0.01 * pp["open"]) - v) / 0.004 + 0.5, 0, 1)
paint(teeth, [0.93, 0.92, 0.88])
kp[7], kp[8], kp[9], kp[10] = lc, rc, up, lo
# Chin and cheeks.
chin, _ = proj([0, B * 0.98, surf(0, B * 0.98) + 0.01])
kp[11] = chin
for ki, side in ((14, -1), (15, 1)):
cpt, _ = proj([side * A * 0.97, 0.15 * B, surf(side * A * 0.97, 0.15 * B)])
kp[ki] = cpt
img = np.clip(img, 0, 1)
small = img.reshape(size, ss, size, ss, 3).mean(axis=(1, 3))
out = np.clip(np.floor(small * 255 + 0.5), 0, 255).astype(np.uint8)
kpx = (kp + 0.5) * size # u, v in [-0.5, 0.5) -> pixels
return out, kpx
def kp_q4(kp: np.ndarray) -> np.ndarray:
"""Keypoints in pixels to sixteenths of a pixel, rounded half to even."""
return np.round(kp * 16).astype(np.int64)
# ---------------------------------------------------------------------------------------
# Training (torch).
# ---------------------------------------------------------------------------------------
def make_sample(seed: int, size: int, gauss: int, bg: int):
rng = np.random.default_rng(seed)
idp = identity_params(rng)
ref, rk = render(idp, pose_params(rng, neutral=True), size)
tgt, tk = render(idp, pose_params(rng), size)
rq, tq = kp_q4(rk), kp_q4(tk)
warped, heat = warp_and_maps(ref, rq, tq, gauss, bg)
return ref, warped, heat, tgt, rq, tq
def sequence(idp_seed: int, n: int, size: int, fps: int = 25):
"""A held-out motion: smooth head motion, blinks, speech-like mouth, glances."""
rng = np.random.default_rng(idp_seed)
idp = identity_params(rng)
t = np.arange(n) / fps
def smooth(amp, fmin=0.15, fmax=0.6):
f = rng.uniform(fmin, fmax, 3)
ph = rng.uniform(0, 2 * math.pi, 3)
return amp * sum(np.sin(2 * math.pi * f[i] * t + ph[i]) for i in range(3)) / 3
blink = np.zeros(n)
for b0 in rng.uniform(0, n / fps, max(1, int(n / fps / 3))):
blink = np.maximum(blink, np.clip(1 - np.abs(t - b0) / 0.12, 0, 1))
speech = np.clip(0.5 + 0.5 * np.sin(2 * math.pi * rng.uniform(3, 5) * t + rng.uniform(0, 6)) * (0.6 + 0.4 * np.sin(2 * math.pi * 0.7 * t)), 0, 1)
poses = []
tx, ty, sc, ro, yw, pi_ = smooth(0.05), smooth(0.04), 1 + smooth(0.06), smooth(0.18), smooth(0.35), smooth(0.18)
sm, gx, gy, br = smooth(0.25) + 0.1, smooth(0.8, 0.2, 1.0), smooth(0.5, 0.2, 1.0), smooth(0.7, 0.2, 0.8)
for i in range(n):
poses.append(dict(tx=tx[i], ty=ty[i], scale=sc[i], roll=ro[i], yaw=yw[i], pitch=pi_[i], open=float(speech[i]) * 0.9,
smile=sm[i], blink_l=float(blink[i]), blink_r=None, gx=gx[i], gy=gy[i], brow=br[i]))
rng2 = np.random.default_rng(idp_seed + 1)
ref, rk = render(idp, pose_params(rng2, neutral=True), size)
frames, kps = zip(*(render(idp, p, size) for p in poses))
return ref, rk, np.stack(frames), np.stack(kps)
try:
import torch as _torch
from torch.utils.data import IterableDataset as _Iterable
except ImportError: # the measurement-free parts of this tool run without torch
_torch, _Iterable = None, object
POSES = 9 # renders per training identity: a near-neutral one and eight others
def _render_identity(args):
seed, size = args
rng = np.random.default_rng(seed)
idp = identity_params(rng)
imgs, kps = [], []
for j in range(POSES):
img, kp = render(idp, pose_params(rng, neutral=(j == 0)), size)
imgs.append(img)
kps.append(kp)
return np.stack(imgs), np.stack(kps)
def render_pool(path: Path, n_id: int, size: int, workers: int, base: int):
"""Render `n_id` training identities, POSES each, into `imgs.npy` and `kps.npy` (memory
mapped, so the data loader's workers share them)."""
import multiprocessing as mp
imgs = np.lib.format.open_memmap(path / "imgs.npy", mode="w+", dtype=np.uint8, shape=(n_id, POSES, size, size, 3))
kps = np.lib.format.open_memmap(path / "kps.npy", mode="w+", dtype=np.float64, shape=(n_id, POSES, K, 2))
t0 = time.time()
with mp.get_context("spawn").Pool(workers) as pool:
for i, (im, kp) in enumerate(pool.imap(_render_identity, [(base + i, size) for i in range(n_id)], chunksize=16)):
imgs[i] = im
kps[i] = kp
if (i + 1) % 2000 == 0:
print(f"rendered {i + 1}/{n_id} identities ({time.time() - t0:.0f} s)", flush=True)
imgs.flush()
kps.flush()
del imgs, kps
class FaceStream(_Iterable):
"""Training samples, made in the data loader's workers from the rendered pool: a
reference face, its warp to another pose of the same face, the keypoint maps, and the
face rendered at that pose."""
def __init__(self, pool: Path, base, gauss, bg):
self.pool, self.base, self.gauss, self.bg = str(pool), base, gauss, bg
def __iter__(self):
info = _torch.utils.data.get_worker_info()
wid = info.id if info else 0
imgs = np.load(self.pool + "/imgs.npy", mmap_mode="r")
kps = np.load(self.pool + "/kps.npy", mmap_mode="r")
rng = np.random.default_rng(self.base + 1_000_003 * wid)
f = lambda a: _torch.from_numpy(np.ascontiguousarray(a.transpose(2, 0, 1)).astype(np.float32)) # noqa: E731
while True:
i = int(rng.integers(0, imgs.shape[0]))
r = 0 if rng.random() < 0.6 else int(rng.integers(1, POSES))
t = int(rng.integers(0, POSES - 1))
t = t + 1 if t >= r else t
ref, tgt = np.asarray(imgs[i, r]), np.asarray(imgs[i, t])
warped, heat = warp_and_maps(ref, kp_q4(np.asarray(kps[i, r])), kp_q4(np.asarray(kps[i, t])), self.gauss, self.bg)
yield f(warped), f(ref), _torch.from_numpy(heat.astype(np.float32)), f(tgt)
def train(args, out: Path, report: dict):
import torch
import torch.nn as nn
import torch.nn.functional as F
dev = torch.device("mps" if torch.backends.mps.is_available() else "cpu")
torch.manual_seed(args.seed)
size, gauss, bg = args.size, args.gauss, args.bg
c1, c2, c3 = args.c1, args.c2, args.c3
shapes = layer_shapes(c1, c2, c3)
class Q(torch.autograd.Function):
"""Round half up onto a grid, gradient straight through."""
@staticmethod
def forward(ctx, x, scale):
return torch.floor(x * scale + 0.5) / scale
@staticmethod
def backward(ctx, g):
return g, None
class QE(torch.autograd.Function):
"""Round half to even onto a grid (the export's rule), gradient straight through."""
@staticmethod
def forward(ctx, x, scale):
return torch.round(x * scale) / scale
@staticmethod
def backward(ctx, g):
return g, None
class Net(nn.Module):
def __init__(self):
super().__init__()
self.convs = nn.ModuleList([nn.Conv2d(ci, co, 3, stride=s, padding=1) for ci, co, s in shapes])
self.qat = False
def conv(self, i, x):
c = self.convs[i]
w, b = c.weight, c.bias
if self.qat:
w = QE.apply(torch.clamp(w, -32768 / 4096, 32767 / 4096), 4096.0)
b = QE.apply(b, float(1 << FB))
y = F.conv2d(x, w, b, stride=c.stride, padding=1)
if self.qat:
y = Q.apply(y, 32768.0)
return y
def forward(self, warped, ref, heat):
act = lambda v: torch.clamp(v, 0, ACT_HI / 32768) # noqa: E731
t3 = (warped - 128) / 128
t4 = (ref - 128) / 128
t5 = heat / 256
x = torch.cat([t3, t4, t5], 1)
e1 = act(self.conv(0, x))
e2 = act(self.conv(1, e1))
e3 = act(self.conv(2, e2))
m1 = act(self.conv(3, e3))
r = act(self.conv(4, m1) + e3)
u2 = act(F.pixel_shuffle(self.conv(5, r), 2))
d2 = act(self.conv(6, torch.cat([u2, e2], 1)))
u1 = act(F.pixel_shuffle(self.conv(7, d2), 2))
d1 = act(self.conv(8, torch.cat([u1, e1], 1)))
o = self.conv(9, d1)
g = torch.clamp(o[:, :3], -1, 32767 / 32768)
z = o[:, 3:4]
m = torch.sigmoid(8 * z)
if self.qat:
m = Q.apply(m, 32768.0)
s = m * t3 + (1 - m) * g
return s * 128 + 128, g * 128 + 128, 8 * z
pool = out / "pool"
pool.mkdir(exist_ok=True)
if not (pool / "imgs.npy").exists() or np.load(pool / "imgs.npy", mmap_mode="r").shape[0] != args.identities:
render_pool(pool, args.identities, size, args.workers, args.seed * 104729)
if args.measure_only:
# The float network and the generator of an earlier run of this tool, from --out.
fnet = Net().to(dev)
fnet.load_state_dict(torch.load(out / "float.pt", map_location=dev))
fnet.eval()
blob = (out / "generator.wfg").read_bytes()
report["generator"] = {"sha256": hashlib.sha256(blob).hexdigest(), "bytes": len(blob)}
prev = json.loads((out / "report.json").read_text()) if (out / "report.json").exists() else {}
for k in ("losses", "train_seconds"):
if k in prev:
report[k] = prev[k]
return fnet, blob
net = Net().to(dev)
with torch.no_grad():
# Start with an even mask, and smaller weights.
net.convs[9].bias[3] = 0.0
for c in net.convs:
c.weight.mul_(0.7)
from torch.utils.data import DataLoader
def batches(base):
dl = DataLoader(FaceStream(pool, base, gauss, bg), batch_size=args.batch, num_workers=args.workers, persistent_workers=False)
return iter(dl)
def loss_fn(y, t):
l1 = (y - t).abs().mean()
gy = lambda a: a[..., 1:, :] - a[..., :-1, :] # noqa: E731
gx = lambda a: a[..., :, 1:] - a[..., :, :-1] # noqa: E731
return l1 + 1.0 * ((gy(y) - gy(t)).abs().mean() + (gx(y) - gx(t)).abs().mean())
losses = []
phases = [("float", args.steps, args.lr, False), ("qat", args.qat_steps, args.lr * 0.2, True)]
it = batches(args.seed * 7919)
t0 = time.time()
for name, steps, lr, qat in phases:
net.qat = qat
opt = torch.optim.Adam(net.parameters(), lr=lr)
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, max(steps, 1))
run = 0.0
for step in range(steps):
w_, r_, h_, t_ = (a.to(dev) for a in next(it))
y, g_, z_ = net(w_, r_, h_)
# The blend's own loss, the generated picture's (so the generator learns a whole
# face whatever the mask chooses), and a penalty on mask logits past ±4 (so the
# mask never saturates where its gradient vanishes).
loss = loss_fn(y, t_) + 0.25 * (g_ - t_).abs().mean() + 0.1 * torch.relu(z_.abs() - 4).mean()
opt.zero_grad()
loss.backward()
opt.step()
sched.step()
run += loss.item()
if (step + 1) % 500 == 0:
losses.append([name, step + 1, run / 500])
print(f"{name} {step + 1}/{steps} loss {run / 500:.3f} ({time.time() - t0:.0f} s)", flush=True)
run = 0.0
if name == "float":
float_state = {k: v.detach().clone() for k, v in net.state_dict().items()}
torch.save(float_state, out / "float.pt")
report["losses"] = losses
report["train_seconds"] = round(time.time() - t0)
# Export: each float32 weight times 2^12 and each bias times 2^27, rounded half to even.
layers = []
for c in net.convs:
w = c.weight.detach().cpu().numpy().astype(np.float32).astype(np.float64)
b = c.bias.detach().cpu().numpy().astype(np.float32).astype(np.float64)
wi = np.clip(np.round(w * 4096), -32768, 32767).astype(np.int64)
bi = np.clip(np.round(b * (1 << FB)), -(1 << 31), (1 << 31) - 1).astype(np.int64)
layers.append((wi, bi))
hdr = dict(k=K, width=size, height=size, c1=c1, c2=c2, c3=c3, gauss=gauss, bg=bg)
blob = write_wfg(hdr, layers)
(out / "generator.wfg").write_bytes(blob)
report["generator"] = {"sha256": hashlib.sha256(blob).hexdigest(), "bytes": len(blob)}
# The float network, for the measurement: the float phase's weights, run in float32.
fnet = Net().to(dev)
fnet.load_state_dict(float_state)
fnet.qat = False
fnet.eval()
return fnet, blob
def train_detector(args, dev, pool: Path):
"""A float keypoint detector, trained on the rendered training faces and their keypoints,
for the measurement's keypoint error: it reads a picture and gives its sixteen keypoints."""
import torch
import torch.nn as nn
torch.manual_seed(args.seed + 1)
size = args.size
class Det(nn.Module):
"""Keypoint maps at half the picture's size, each read by a spatial softmax as the
expected position: sub-pixel by construction."""
def __init__(self):
super().__init__()
def block(a, b, s):
return [nn.Conv2d(a, b, 3, stride=s, padding=1), nn.BatchNorm2d(b), nn.ReLU()]
self.down = nn.Sequential(*block(3, 32, 1), *block(32, 64, 2), *block(64, 64, 1), *block(64, 128, 2), *block(128, 128, 1), *block(128, 128, 1))
self.up = nn.Sequential(nn.Upsample(scale_factor=2, mode="bilinear", align_corners=False), *block(128, 64, 1), nn.Conv2d(64, K, 3, padding=1))
half = size // 2
g = (torch.arange(half, dtype=torch.float32) + 0.5) * 2
self.register_buffer("gx", g.view(1, 1, 1, half))
self.register_buffer("gy", g.view(1, 1, half, 1))
def forward(self, x):
m = self.up(self.down(x / 255 - 0.5))
n, k, h, w = m.shape
p = torch.softmax(m.reshape(n, k, -1), -1).reshape(n, k, h, w)
return torch.stack([(p * self.gx).sum((2, 3)), (p * self.gy).sum((2, 3))], -1)
det = Det().to(dev)
opt = torch.optim.Adam(det.parameters(), lr=1e-3)
det.train()
sched = torch.optim.lr_scheduler.CosineAnnealingLR(opt, args.det_steps)
rng = np.random.default_rng(args.seed + 99)
all_imgs = np.load(pool / "imgs.npy", mmap_mode="r")
all_kps = np.load(pool / "kps.npy", mmap_mode="r")
n = min(args.det_pool, all_imgs.shape[0])
imgs = torch.from_numpy(np.ascontiguousarray(np.asarray(all_imgs[:n]).reshape(-1, size, size, 3).transpose(0, 3, 1, 2)).astype(np.float32)).to(dev)
kps = torch.from_numpy(np.asarray(all_kps[:n]).reshape(-1, K, 2).astype(np.float32)).to(dev)
for step in range(args.det_steps):
idx = torch.from_numpy(rng.integers(0, imgs.shape[0], args.batch)).to(dev)
loss = (det(imgs[idx]) - kps[idx]).abs().mean()
opt.zero_grad()
loss.backward()
opt.step()
sched.step()
if (step + 1) % 1000 == 0:
print(f"detector {step + 1}/{args.det_steps} loss {loss.item():.3f} px", flush=True)
det.eval()
return det
def psnr(a: np.ndarray, b: np.ndarray) -> float:
mse = np.mean((a.astype(np.float64) - b.astype(np.float64)) ** 2)
return float("inf") if mse == 0 else 10 * math.log10(255 * 255 / mse)
def ssim(a: np.ndarray, b: np.ndarray) -> float:
"""SSIM (11×11 Gaussian window, σ 1.5, K1 0.01, K2 0.03) on each channel, averaged."""
g = np.exp(-((np.arange(11) - 5) ** 2) / (2 * 1.5 ** 2))
g /= g.sum()
def blur(x):
x = np.apply_along_axis(lambda r: np.convolve(r, g, mode="valid"), 0, x)
return np.apply_along_axis(lambda r: np.convolve(r, g, mode="valid"), 1, x)
c1, c2 = (0.01 * 255) ** 2, (0.03 * 255) ** 2
vals = []
for ch in range(3):
x, y = a[..., ch].astype(np.float64), b[..., ch].astype(np.float64)
mx, my = blur(x), blur(y)
sxx = blur(x * x) - mx * mx
syy = blur(y * y) - my * my
sxy = blur(x * y) - mx * my
s = ((2 * mx * my + c1) * (2 * sxy + c2)) / ((mx * mx + my * my + c1) * (sxx + syy + c2))
vals.append(s.mean())
return float(np.mean(vals))
def measure(args, out: Path, report: dict, fnet, blob: bytes):
import torch
dev = next(fnet.parameters()).device
det = train_detector(args, dev, out / "pool")
hdr, layers = read_wfg(blob)
ilayers = [(w, b, s) for (w, b, s) in layers]
size, gauss, bg = args.size, args.gauss, args.bg
rows = {"int_float": [], "int_truth": [], "float_truth": [], "warp_truth": []}
ss = {"int_float": [], "int_truth": [], "float_truth": [], "warp_truth": []}
kerr = {"truth": [], "int": [], "float": []}
mouth = {"truth": [], "int": [], "float": []}
bitrate = {"truth_step4": [], "truth_step1": [], "detected_step4": [], "detected_step1": []}
gen_sha = hashlib.sha256(blob).digest()
nseq, nfr = args.measure_identities, args.measure_frames
t_int = 0.0
for s in range(nseq):
ref, rk, frames, kps = sequence(50_000_000 + 7 * s, nfr, size)
rq = kp_q4(rk)
ident = hashlib.sha256(b"identity" + struct.pack("<I", s)).digest()
dq_all = kp_q4(kps)
for name, src in (("truth", dq_all),):
for step in (4, 1):
tot = 0
for f0 in range(0, nfr, 25):
tot += len(encode_track(src[f0:f0 + 25].astype(np.float64), step, gen_sha, ident, f0))
bitrate[f"{name}_step{step}"].append(tot * 8 / (nfr / 25) / 1000)
with torch.no_grad():
dk = det(torch.from_numpy(np.ascontiguousarray(frames.transpose(0, 3, 1, 2)).astype(np.float32)).to(dev)).cpu().numpy()
for step in (4, 1):
tot = 0
for f0 in range(0, nfr, 25):
tot += len(encode_track(kp_q4(dk[f0:f0 + 25]).astype(np.float64), step, gen_sha, ident, f0))
bitrate[f"detected_step{step}"].append(tot * 8 / (nfr / 25) / 1000)
ints, floats = [], []
for i in range(nfr):
dq = dq_all[i]
warped, heat = warp_and_maps(ref, rq, dq, gauss, bg)
t1 = time.time()
yi = int_forward(ilayers, warped, ref, heat)
t_int += time.time() - t1
with torch.no_grad():
f = lambda a: torch.from_numpy(a.transpose(2, 0, 1)[None].astype(np.float32)).to(dev) # noqa: E731
yf = fnet(f(warped), f(ref), torch.from_numpy(heat[None].astype(np.float32)).to(dev))[0]
yf = np.clip(np.floor(yf[0].cpu().numpy().transpose(1, 2, 0) + 0.5), 0, 255).astype(np.uint8)
truth = frames[i]
for key, (a, b) in {"int_float": (yi, yf), "int_truth": (yi, truth), "float_truth": (yf, truth), "warp_truth": (warped, truth)}.items():
rows[key].append(psnr(a, b))
ss[key].append(ssim(a, b))
ints.append(yi)
floats.append(yf)
with torch.no_grad():
for key, imgs in (("int", np.stack(ints)), ("float", np.stack(floats)), ("truth", frames)):
d = det(torch.from_numpy(np.ascontiguousarray(imgs.transpose(0, 3, 1, 2)).astype(np.float32)).to(dev)).cpu().numpy()
err = np.sqrt(((d - kps) ** 2).sum(-1))
kerr[key].append(float(np.sqrt((err ** 2).mean())))
mouth[key].append(float(np.sqrt((err[:, 7:11] ** 2).mean())))
print(f"measured sequence {s + 1}/{nseq}", flush=True)
finite = lambda v: [x for x in v if math.isfinite(x)] # noqa: E731
report["measurement"] = {
"identities": nseq, "frames_each": nfr, "fps": 25, "size": [size, size],
"psnr_db": {k: {"mean": round(float(np.mean(finite(v))), 2), "min": round(float(np.min(v)), 2), "identical_frames": int(sum(1 for x in v if not math.isfinite(x)))} for k, v in rows.items()},
"ssim": {k: {"mean": round(float(np.mean(v)), 4), "min": round(float(np.min(v)), 4)} for k, v in ss.items()},
"keypoint_rms_px": {k: round(float(np.mean(v)), 3) for k, v in kerr.items()},
"mouth_keypoint_rms_px": {k: round(float(np.mean(v)), 3) for k, v in mouth.items()},
"track_kbit_s": {k: round(float(np.mean(v)), 3) for k, v in bitrate.items()},
"numpy_int_seconds_per_frame": round(t_int / (nseq * nfr), 4),
}
def corpus_inputs(args, out: Path, report: dict, blob: bytes):
"""Identity priors and keypoint tracks of held-out faces, for the corpus (§9)."""
gen = hashlib.sha256(blob).hexdigest()
(out / "identities").mkdir(exist_ok=True)
(out / "tracks").mkdir(exist_ok=True)
files = {}
for name, seed, nfr in (("face1", 70_000_001, args.corpus_frames), ("face2", 70_000_017, args.corpus_frames)):
ref, rk, frames, kps = sequence(seed, nfr, args.size)
rq = kp_q4(rk)
wfi = bytearray(b"WFI1")
wfi += struct.pack("<BBHH", 1, K, args.size, args.size)
wfi += rq.astype("<i2").tobytes()
wfi += ref.tobytes()
(out / "identities" / f"{name}.wfi").write_bytes(bytes(wfi))
track = {"identity": name, "keypoints": kp_q4(kps).tolist()}
(out / "tracks" / f"{name}.json").write_text(json.dumps(track, separators=(",", ":")) + "\n")
files[name] = {"wfi_sha256": hashlib.sha256(bytes(wfi)).hexdigest(), "frames": nfr}
report["corpus_inputs"] = {"generator": gen, "faces": files}
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
ap.add_argument("--out", type=Path, required=True)
ap.add_argument("--seed", type=int, default=20261022)
ap.add_argument("--size", type=int, default=64)
ap.add_argument("--c1", type=int, default=24)
ap.add_argument("--c2", type=int, default=48)
ap.add_argument("--c3", type=int, default=64)
ap.add_argument("--gauss", type=int, default=1024, help="§7.5's g: 32768/σ², σ in pixels")
ap.add_argument("--bg", type=int, default=6554, help="§7.5's background weight, Q16")
ap.add_argument("--identities", type=int, default=6000)
ap.add_argument("--steps", type=int, default=60000)
ap.add_argument("--qat-steps", type=int, default=12000)
ap.add_argument("--lr", type=float, default=1e-3)
ap.add_argument("--batch", type=int, default=32)
ap.add_argument("--workers", type=int, default=12)
ap.add_argument("--det-steps", type=int, default=12000)
ap.add_argument("--det-pool", type=int, default=6000, help="training identities the detector reads, all their poses")
ap.add_argument("--measure-identities", type=int, default=12)
ap.add_argument("--measure-frames", type=int, default=100)
ap.add_argument("--corpus-frames", type=int, default=3)
ap.add_argument("--preview", action="store_true", help="only write preview pictures of the renderer and the warp")
ap.add_argument("--measure-only", action="store_true", help="measure the generator and float network an earlier run wrote to --out")
args = ap.parse_args()
args.out.mkdir(parents=True, exist_ok=True)
if args.preview:
preview(args)
return
report = {"seed": args.seed, "args": {k: v for k, v in vars(args).items() if k != "out"}, "keypoints": KEYPOINTS}
fnet, blob = train(args, args.out, report)
corpus_inputs(args, args.out, report, blob)
measure(args, args.out, report, fnet, blob)
(args.out / "report.json").write_text(json.dumps(report, indent=1, sort_keys=True) + "\n")
print(json.dumps(report["measurement"], indent=1))
def preview(args):
"""A contact sheet of generated faces and warps, as a binary PPM (no image library)."""
tiles = []
for i in range(8):
ref, warped, heat, tgt, rq, tq = make_sample(1000 + i, args.size, args.gauss, args.bg)
hm = np.clip(heat.max(0) * 255 // 256, 0, 255).astype(np.uint8)
tiles.append(np.concatenate([ref, warped, tgt, np.stack([hm] * 3, -1)], 1))
sheet = np.concatenate(tiles, 0)
big = np.repeat(np.repeat(sheet, 3, 0), 3, 1)
h, w, _ = big.shape
(args.out / "preview.ppm").write_bytes(b"P6 %d %d 255\n" % (w, h) + big.tobytes())
_, _, frames, kps = sequence(50_000_000, 16, args.size)
seq = np.concatenate(list(frames), 1)
seq = np.repeat(np.repeat(seq, 3, 0), 3, 1)
h, w, _ = seq.shape
(args.out / "sequence.ppm").write_bytes(b"P6 %d %d 255\n" % (w, h) + seq.tobytes())
print("wrote", args.out / "preview.ppm", args.out / "sequence.ppm")
if __name__ == "__main__":
main()