humanforge 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,619 @@
1
+ // Selfie-to-avatar fitting for the realistic human.
2
+ //
3
+ // Input: MediaPipe Face Landmarker output (478 landmarks, normalized image coords) and,
4
+ // optionally, the selfie's RGBA pixels. Output: a HumanConfig patch (gender / ethnicity /
5
+ // weight macros, face detail sliders, skin + skinTint, eye color) that resembles the person.
6
+ //
7
+ // Platform-neutral: no DOM, no WASM. Run the landmark model wherever you like (MediaPipe
8
+ // tasks-vision on the web, a TFLite model on device, or a server) and call fitSelfie().
9
+ //
10
+ // Method: each MediaPipe landmark is tied to one MakeHuman vertex (FACE_MAP, built by
11
+ // tools/build-facemap.ts). Per iteration we similarity-align the landmarks to the current
12
+ // model (Umeyama/Horn, removes pose and scale), then solve a box-constrained ridge least-squares
13
+ // problem whose columns are the morph deltas at the mapped vertices. Detail sliders are exactly
14
+ // linear; macros are linearized with finite differences (Gauss-Newton). Expression units are
15
+ // free nuisance columns, so a smile in the photo isn't baked into a wide mouth.
16
+ import { DEFAULT_HUMAN, HumanConfig, HumanPackage, macroWeights } from './runtime';
17
+ import { DETAIL_SLIDERS } from './sliders';
18
+ import { EYE_COLORS, FACE_MAP, SCLERA_COLOR, SKIN_COLORS } from './faceMap';
19
+
20
+ export interface Landmark { x: number; y: number; z: number }
21
+ export interface RgbaImage { width: number; height: number; data: Uint8ClampedArray | Uint8Array }
22
+
23
+ // ---------------------------------------------------------------- small linear algebra
24
+
25
+ /** Weighted similarity transform (s, R, t) minimizing Σ w‖s·R·src + t − dst‖² (Horn's quaternion method). */
26
+ export function similarityAlign(src: Float64Array, dst: Float64Array, w?: Float64Array) {
27
+ const n = src.length / 3;
28
+ let W = 0;
29
+ const cs = [0, 0, 0], cd = [0, 0, 0];
30
+ for (let i = 0; i < n; i++) {
31
+ const wi = w ? w[i] : 1;
32
+ W += wi;
33
+ for (let k = 0; k < 3; k++) { cs[k] += wi * src[i * 3 + k]; cd[k] += wi * dst[i * 3 + k]; }
34
+ }
35
+ for (let k = 0; k < 3; k++) { cs[k] /= W; cd[k] /= W; }
36
+ const S = new Float64Array(9);
37
+ let vs = 0;
38
+ for (let i = 0; i < n; i++) {
39
+ const wi = w ? w[i] : 1;
40
+ for (let a = 0; a < 3; a++) {
41
+ const pa = src[i * 3 + a] - cs[a];
42
+ vs += wi * pa * pa;
43
+ for (let b = 0; b < 3; b++) S[a * 3 + b] += wi * pa * (dst[i * 3 + b] - cd[b]);
44
+ }
45
+ }
46
+ const [xx, xy, xz, yx, yy, yz, zx, zy, zz] = S;
47
+ const N = [
48
+ [xx + yy + zz, yz - zy, zx - xz, xy - yx],
49
+ [yz - zy, xx - yy - zz, xy + yx, zx + xz],
50
+ [zx - xz, xy + yx, -xx + yy - zz, yz + zy],
51
+ [xy - yx, zx + xz, yz + zy, -xx - yy + zz],
52
+ ];
53
+ const q = topEigenvector4(N);
54
+ const [q0, qx, qy, qz] = q;
55
+ const R = [
56
+ q0 * q0 + qx * qx - qy * qy - qz * qz, 2 * (qx * qy - q0 * qz), 2 * (qx * qz + q0 * qy),
57
+ 2 * (qy * qx + q0 * qz), q0 * q0 - qx * qx + qy * qy - qz * qz, 2 * (qy * qz - q0 * qx),
58
+ 2 * (qz * qx - q0 * qy), 2 * (qz * qy + q0 * qx), q0 * q0 - qx * qx - qy * qy + qz * qz,
59
+ ];
60
+ // s = trace(Rᵀ S) / Σ w|p|²
61
+ let tr = 0;
62
+ for (let a = 0; a < 3; a++) for (let b = 0; b < 3; b++) tr += R[b * 3 + a] * S[a * 3 + b];
63
+ const s = vs > 0 ? tr / vs : 1;
64
+ const t = [0, 1, 2].map((r) => cd[r] - s * (R[r * 3] * cs[0] + R[r * 3 + 1] * cs[1] + R[r * 3 + 2] * cs[2]));
65
+ const apply = (p: Float64Array, out = new Float64Array(p.length)) => {
66
+ for (let i = 0; i < p.length; i += 3) {
67
+ const x = p[i], y = p[i + 1], z = p[i + 2];
68
+ for (let r = 0; r < 3; r++) out[i + r] = s * (R[r * 3] * x + R[r * 3 + 1] * y + R[r * 3 + 2] * z) + t[r];
69
+ }
70
+ return out;
71
+ };
72
+ return { s, R, t, apply };
73
+ }
74
+
75
+ /** Largest-eigenvalue eigenvector of a symmetric 4×4 matrix (cyclic Jacobi). */
76
+ function topEigenvector4(M: number[][]) {
77
+ const a = M.map((r) => r.slice());
78
+ const v = [[1, 0, 0, 0], [0, 1, 0, 0], [0, 0, 1, 0], [0, 0, 0, 1]];
79
+ for (let sweep = 0; sweep < 50; sweep++) {
80
+ let off = 0;
81
+ for (let p = 0; p < 4; p++) for (let q = p + 1; q < 4; q++) off += a[p][q] * a[p][q];
82
+ if (off < 1e-30) break;
83
+ for (let p = 0; p < 4; p++) for (let q = p + 1; q < 4; q++) {
84
+ if (Math.abs(a[p][q]) < 1e-300) continue;
85
+ const theta = (a[q][q] - a[p][p]) / (2 * a[p][q]);
86
+ const t = Math.sign(theta || 1) / (Math.abs(theta) + Math.sqrt(theta * theta + 1));
87
+ const c = 1 / Math.sqrt(t * t + 1), s = t * c;
88
+ for (let k = 0; k < 4; k++) {
89
+ const akp = a[k][p], akq = a[k][q];
90
+ a[k][p] = c * akp - s * akq; a[k][q] = s * akp + c * akq;
91
+ }
92
+ for (let k = 0; k < 4; k++) {
93
+ const apk = a[p][k], aqk = a[q][k];
94
+ a[p][k] = c * apk - s * aqk; a[q][k] = s * apk + c * aqk;
95
+ }
96
+ for (let k = 0; k < 4; k++) {
97
+ const vkp = v[k][p], vkq = v[k][q];
98
+ v[k][p] = c * vkp - s * vkq; v[k][q] = s * vkp + c * vkq;
99
+ }
100
+ }
101
+ }
102
+ let best = 0;
103
+ for (let i = 1; i < 4; i++) if (a[i][i] > a[best][best]) best = i;
104
+ return [v[0][best], v[1][best], v[2][best], v[3][best]];
105
+ }
106
+
107
+ /**
108
+ * Box-constrained ridge least squares:
109
+ * minimize ‖A x − b‖²_W + Σ_j λ_j (x_j − p_j)² subject to lo ≤ x ≤ hi
110
+ * A is column-major (cols[j] has length m). Solved by projected coordinate descent on the
111
+ * normal equations, which is exact for this convex problem and tiny here (≤ ~100 unknowns).
112
+ */
113
+ export function solveBoxRidge(
114
+ cols: Float64Array[], b: Float64Array, opts: { w?: Float64Array; lambda: Float64Array; prior?: Float64Array; lo: Float64Array; hi: Float64Array; x0?: Float64Array; sweeps?: number },
115
+ ) {
116
+ const n = cols.length, m = b.length;
117
+ const w = opts.w;
118
+ const G = new Float64Array(n * n), g = new Float64Array(n);
119
+ for (let i = 0; i < n; i++) {
120
+ const ci = cols[i];
121
+ let gi = 0;
122
+ for (let r = 0; r < m; r++) gi += (w ? w[r] : 1) * ci[r] * b[r];
123
+ g[i] = gi;
124
+ for (let j = i; j < n; j++) {
125
+ const cj = cols[j];
126
+ let s = 0;
127
+ for (let r = 0; r < m; r++) s += (w ? w[r] : 1) * ci[r] * cj[r];
128
+ G[i * n + j] = G[j * n + i] = s;
129
+ }
130
+ }
131
+ const x = opts.x0 ? Float64Array.from(opts.x0) : new Float64Array(n);
132
+ for (let j = 0; j < n; j++) x[j] = Math.min(opts.hi[j], Math.max(opts.lo[j], x[j]));
133
+ const sweeps = opts.sweeps ?? 300;
134
+ for (let it = 0; it < sweeps; it++) {
135
+ let change = 0;
136
+ for (let j = 0; j < n; j++) {
137
+ const d = G[j * n + j] + opts.lambda[j];
138
+ if (d <= 0) continue;
139
+ let r = g[j] + opts.lambda[j] * (opts.prior ? opts.prior[j] : 0);
140
+ for (let k = 0; k < n; k++) if (k !== j) r -= G[j * n + k] * x[k];
141
+ const nx = Math.min(opts.hi[j], Math.max(opts.lo[j], r / d));
142
+ change = Math.max(change, Math.abs(nx - x[j]));
143
+ x[j] = nx;
144
+ }
145
+ if (change < 1e-7) break;
146
+ }
147
+ return x;
148
+ }
149
+
150
+ // ---------------------------------------------------------------- landmarks
151
+
152
+ /** MediaPipe normalized landmarks -> 3D points in MakeHuman axes (y up, z toward camera), image-pixel units. */
153
+ export function landmarksToPoints(lms: Landmark[], width: number, height: number) {
154
+ const out = new Float64Array(FACE_MAP.vertex.length * 3);
155
+ for (let i = 0; i < FACE_MAP.vertex.length; i++) {
156
+ const l = lms[i];
157
+ out[i * 3] = l.x * width;
158
+ out[i * 3 + 1] = -l.y * height;
159
+ out[i * 3 + 2] = -l.z * width;
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Landmarks -> fitting target in MakeHuman space. MediaPipe's landmark layout and the MakeHuman
166
+ * mesh never agree exactly, so instead of fitting raw landmarks we transfer the person's
167
+ * *difference from MediaPipe's canonical face* onto the neutral MakeHuman face. A face equal to
168
+ * the canonical one then maps to the neutral avatar, with no systematic bias.
169
+ */
170
+ export function landmarksToTarget(pkg: HumanPackage, lms: Landmark[], width: number, height: number, neutralConfig: HumanConfig = DEFAULT_HUMAN) {
171
+ const obs = landmarksToPoints(lms, width, height);
172
+ const ref = Float64Array.from(FACE_MAP.reference);
173
+ if (!ref.length) return obs;
174
+ const aligned = similarityAlign(obs, ref, Float64Array.from(FACE_MAP.weight)).apply(obs);
175
+ const neutral = landmarkBasis(pkg).evaluate({ ...neutralConfig, detail: {} });
176
+ for (let i = 0; i < aligned.length; i++) aligned[i] = neutral[i] + aligned[i] - ref[i];
177
+ return aligned;
178
+ }
179
+
180
+ interface LandmarkBasis { base: Float64Array; col: (name: string) => Float64Array | null; evaluate: (c: HumanConfig) => Float64Array }
181
+ const basisCache = new WeakMap<HumanPackage, LandmarkBasis>();
182
+ /** Morph deltas restricted to the mapped vertices, so shapes at the landmarks cost ~µs instead of a full evaluateShape. */
183
+ export function landmarkBasis(pkg: HumanPackage): LandmarkBasis {
184
+ const hit = basisCache.get(pkg);
185
+ if (hit) return hit;
186
+ const L = FACE_MAP.vertex.length;
187
+ const rowOf = new Map<number, number[]>();
188
+ FACE_MAP.vertex.forEach((v, i) => { if (!rowOf.has(v)) rowOf.set(v, []); rowOf.get(v)!.push(i); });
189
+ const cols = new Map<string, Float64Array | null>();
190
+ const col = (name: string) => {
191
+ if (cols.has(name)) return cols.get(name)!;
192
+ const m = pkg.morphs.get(name);
193
+ let c: Float64Array | null = null;
194
+ if (m) {
195
+ for (let i = 0; i < m.idx.length; i++) {
196
+ const rows = rowOf.get(m.idx[i]);
197
+ if (!rows) continue;
198
+ c ??= new Float64Array(L * 3);
199
+ for (const r of rows) for (let k = 0; k < 3; k++) c[r * 3 + k] += m.d[i * 3 + k] * m.scale;
200
+ }
201
+ }
202
+ cols.set(name, c);
203
+ return c;
204
+ };
205
+ const base = shapeAtLandmarks(pkg.base);
206
+ const evaluate = (c: HumanConfig) => {
207
+ const out = Float64Array.from(base);
208
+ const add = (name: string, wt: number) => {
209
+ const d = Math.abs(wt) >= 1e-4 ? col(name) : null;
210
+ if (d) for (let r = 0; r < out.length; r++) out[r] += d[r] * wt;
211
+ };
212
+ for (const [k, v] of Object.entries(macroWeights(c))) add(k, v);
213
+ for (const s of DETAIL_SLIDERS) {
214
+ const v = c.detail[s.id] ?? 0;
215
+ if (v > 0) s.pos.forEach((t) => add(t, v));
216
+ else if (v < 0) s.neg.forEach((t) => add(t, -v));
217
+ }
218
+ return out;
219
+ };
220
+ const b = { base, col, evaluate };
221
+ basisCache.set(pkg, b);
222
+ return b;
223
+ }
224
+
225
+ /** The mapped MakeHuman vertices of a shape, as landmark-ordered points (metres). */
226
+ export function shapeAtLandmarks(shape: Float32Array) {
227
+ const v = FACE_MAP.vertex;
228
+ const out = new Float64Array(v.length * 3);
229
+ for (let i = 0; i < v.length; i++) for (let k = 0; k < 3; k++) out[i * 3 + k] = shape[v[i] * 3 + k];
230
+ return out;
231
+ }
232
+
233
+ // ---------------------------------------------------------------- shape fit
234
+
235
+ /** Detail sliders the face landmarks can actually observe. */
236
+ export const FIT_SLIDERS = DETAIL_SLIDERS.filter(
237
+ (s) => ['head', 'eyes', 'nose', 'mouth'].includes(s.group) && !['chinCleft', 'eyeBags', 'dimples'].includes(s.id),
238
+ );
239
+ /** Expression units fitted as nuisance parameters (then discarded). */
240
+ const NUISANCE = [
241
+ 'mouth-open', 'mouth-corner-puller', 'mouth-retraction', 'mouth-pursing', 'mouth-depression',
242
+ 'eye-left-closure', 'eye-right-closure', 'eye-left-opened-up', 'eye-right-opened-up',
243
+ 'eyebrows-left-up', 'eyebrows-right-up', 'eyebrows-left-down', 'eyebrows-right-down',
244
+ ];
245
+ type MacroKey = 'gender' | 'weight' | 'african' | 'asian' | 'caucasian';
246
+ /**
247
+ * Macros estimated by default. Gender is not: face landmarks carry too little gender signal
248
+ * (in tests on real portraits it was right about half the time), so ask the user and pass it
249
+ * as a hint, or opt in with `macros: ['gender', ...]`.
250
+ */
251
+ export const DEFAULT_FIT_MACROS: MacroKey[] = ['african', 'asian', 'caucasian'];
252
+
253
+ export interface FitShapeOptions {
254
+ /** Starting config; also the prior for macros. Defaults to DEFAULT_HUMAN. */
255
+ base?: HumanConfig;
256
+ /** Ridge strength for detail sliders (relative to the data term). Higher = more conservative. Default 0.02. */
257
+ lambda?: number;
258
+ /** Macros to estimate from geometry. Default DEFAULT_FIT_MACROS (ethnicity mix). */
259
+ macros?: MacroKey[];
260
+ /** Weight of the depth axis (MediaPipe z is a guess from a single image). Default 0.4. */
261
+ zWeight?: number;
262
+ /** Outer Gauss-Newton iterations. Default 6. */
263
+ iterations?: number;
264
+ }
265
+
266
+ export interface FitShapeResult {
267
+ config: HumanConfig;
268
+ /** RMS landmark error after fitting, relative to face height (≈ 0.02 is a good fit). */
269
+ error: number;
270
+ /** Error of the starting shape, same units. */
271
+ initialError: number;
272
+ /** Fitted nuisance expression weights (useful for debugging, not part of the identity). */
273
+ expression: Record<string, number>;
274
+ }
275
+
276
+ interface Var { kind: 'detail' | 'expr' | 'macro'; key: string; sign: 1 | -1; targets: string[]; lo: number; hi: number; lambda: number; prior: number }
277
+
278
+ /**
279
+ * Fit macros + detail sliders to target points (landmark order, any units/pose).
280
+ * Gender has a kink at 0.5 (the female and male bases are separate), and detail sliders can
281
+ * imitate part of it, so it is picked by a coarse search first and then refined.
282
+ */
283
+ export function fitFaceShape(pkg: HumanPackage, target: Float64Array, opts: FitShapeOptions = {}): FitShapeResult {
284
+ const macros = opts.macros ?? DEFAULT_FIT_MACROS;
285
+ if (!macros.includes('gender')) return fitFaceShapeAt(pkg, target, { ...opts, macros });
286
+ const base = opts.base ?? DEFAULT_HUMAN;
287
+ const rest = macros.filter((m) => m !== 'gender');
288
+ let best: FitShapeResult | null = null;
289
+ for (const gender of [0.05, 0.3, 0.5, 0.7, 0.95]) {
290
+ const r = fitFaceShapeAt(pkg, target, { ...opts, base: { ...base, gender }, macros: rest, iterations: 3 });
291
+ if (!best || r.error < best.error) best = r;
292
+ }
293
+ const res = fitFaceShapeAt(pkg, target, { ...opts, base: { ...base, gender: best!.config.gender }, macros });
294
+ return { ...res, initialError: fitFaceShapeAt(pkg, target, { ...opts, iterations: 1, macros: [] }).initialError };
295
+ }
296
+
297
+ function fitFaceShapeAt(pkg: HumanPackage, target: Float64Array, opts: FitShapeOptions = {}): FitShapeResult {
298
+ const base: HumanConfig = { ...(opts.base ?? DEFAULT_HUMAN), detail: { ...(opts.base ?? DEFAULT_HUMAN).detail } };
299
+ const lambda = opts.lambda ?? 0.02;
300
+ const zW = opts.zWeight ?? 0.4;
301
+ const macros = opts.macros ?? DEFAULT_FIT_MACROS;
302
+ const iterations = opts.iterations ?? 6;
303
+ const L = FACE_MAP.vertex.length;
304
+ const basis = landmarkBasis(pkg);
305
+
306
+ // per-row weights (landmark weight × axis weight)
307
+ const w = new Float64Array(L * 3), pw = new Float64Array(L);
308
+ for (let i = 0; i < L; i++) {
309
+ const lw = FACE_MAP.weight[i];
310
+ pw[i] = lw;
311
+ w[i * 3] = lw; w[i * 3 + 1] = lw; w[i * 3 + 2] = lw * zW * (FACE_MAP.contour[i] ? 0 : 1);
312
+ }
313
+
314
+ const vars: Var[] = [];
315
+ for (const s of FIT_SLIDERS) {
316
+ if (s.pos.length) vars.push({ kind: 'detail', key: s.id, sign: 1, targets: s.pos, lo: 0, hi: 1, lambda, prior: 0 });
317
+ if (s.neg.length) vars.push({ kind: 'detail', key: s.id, sign: -1, targets: s.neg, lo: 0, hi: 1, lambda, prior: 0 });
318
+ }
319
+ for (const u of NUISANCE) if (pkg.morphs.has(`expr/${u}`)) vars.push({ kind: 'expr', key: u, sign: 1, targets: [`expr/${u}`], lo: 0, hi: 1, lambda: lambda * 0.1, prior: 0 });
320
+ for (const k of macros) vars.push({ kind: 'macro', key: k, sign: 1, targets: [], lo: k === 'weight' ? 0.25 : 0, hi: k === 'weight' ? 0.85 : 1, lambda: lambda * 4, prior: base[k] });
321
+
322
+ // Detail/expression columns are constant: morph deltas at the mapped vertices.
323
+ const morphCol = (names: string[]) => {
324
+ const c = new Float64Array(L * 3);
325
+ for (const name of names) { const d = basis.col(name); if (d) for (let r = 0; r < c.length; r++) c[r] += d[r]; }
326
+ return c;
327
+ };
328
+ const fixedCols = new Map<number, Float64Array>();
329
+ vars.forEach((v, j) => { if (v.kind !== 'macro') fixedCols.set(j, morphCol(v.targets)); });
330
+
331
+ const x = new Float64Array(vars.length);
332
+ vars.forEach((v, j) => { if (v.kind === 'macro') x[j] = base[v.key as MacroKey]; });
333
+ for (const [j, v] of vars.entries()) {
334
+ const cur = base.detail[v.key];
335
+ if (v.kind === 'detail' && cur && Math.sign(cur) === v.sign) x[j] = Math.abs(cur);
336
+ }
337
+
338
+ const macroConfig = (xv: Float64Array): HumanConfig => {
339
+ const c: HumanConfig = { ...base, detail: {} };
340
+ vars.forEach((v, j) => { if (v.kind === 'macro') (c as any)[v.key] = xv[j]; });
341
+ return c;
342
+ };
343
+ const macroShape = (c: HumanConfig) => basis.evaluate(c);
344
+ const faceHeight = (p: Float64Array) => {
345
+ const [a, b] = FACE_MAP.heightPair;
346
+ return Math.hypot(p[a * 3] - p[b * 3], p[a * 3 + 1] - p[b * 3 + 1], p[a * 3 + 2] - p[b * 3 + 2]);
347
+ };
348
+ const model = (P0: Float64Array, xv: Float64Array) => {
349
+ const out = Float64Array.from(P0);
350
+ for (const [j, c] of fixedCols) if (xv[j]) for (let r = 0; r < out.length; r++) out[r] += c[r] * xv[j];
351
+ return out;
352
+ };
353
+ const rms = (a: Float64Array, b: Float64Array) => {
354
+ let e = 0, W = 0;
355
+ for (let r = 0; r < a.length; r++) { e += w[r] * (a[r] - b[r]) ** 2; W += w[r]; }
356
+ return Math.sqrt(e / W) / faceHeight(b);
357
+ };
358
+
359
+ let initialError = 0, error = 0;
360
+ for (let it = 0; it < iterations; it++) {
361
+ const mc = macroConfig(x);
362
+ const P0 = macroShape(mc);
363
+ // P0 contains the current macros but no detail; detail/expression come from x.
364
+ const cur = model(P0, x);
365
+ const T = similarityAlign(target, cur, pw).apply(target);
366
+ if (it === 0) initialError = rms(cur, T);
367
+ // Linearize: model ≈ P0 + Σ fixed·x + Σ J_macro·(x − x_macro); solve for all x at once.
368
+ const cols: Float64Array[] = [];
369
+ const rhs = new Float64Array(T.length);
370
+ for (let r = 0; r < T.length; r++) rhs[r] = T[r] - P0[r];
371
+ vars.forEach((v, j) => {
372
+ if (v.kind !== 'macro') { cols.push(fixedCols.get(j)!); return; }
373
+ const h = 0.05, k = v.key as MacroKey;
374
+ const up = { ...mc, [k]: Math.min(1, mc[k] + h) }, dn = { ...mc, [k]: Math.max(0, mc[k] - h) };
375
+ // Macros also move/scale the whole head (body height changes); align that away so the
376
+ // column only holds the change in face shape, which is what the aligned target can show.
377
+ const Pu = similarityAlign(macroShape(up), P0, pw).apply(macroShape(up));
378
+ const Pd = similarityAlign(macroShape(dn), P0, pw).apply(macroShape(dn));
379
+ const span = up[k] - dn[k];
380
+ const c = new Float64Array(T.length);
381
+ for (let r = 0; r < c.length; r++) c[r] = (Pu[r] - Pd[r]) / span;
382
+ cols.push(c);
383
+ for (let r = 0; r < rhs.length; r++) rhs[r] += c[r] * x[j];
384
+ });
385
+ // Scale-free ridge: λ is relative to the mean column energy.
386
+ let energy = 0;
387
+ for (const c of cols) for (let r = 0; r < c.length; r++) energy += w[r] * c[r] * c[r];
388
+ energy /= cols.length;
389
+ const lam = Float64Array.from(vars, (v) => v.lambda * energy);
390
+ // Two-sided sliders: penalize using both directions at once (keeps x+ · x− ≈ 0).
391
+ const next = solveBoxRidge(cols, rhs, {
392
+ w, lambda: lam, prior: Float64Array.from(vars, (v) => v.prior),
393
+ lo: Float64Array.from(vars, (v) => v.lo), hi: Float64Array.from(vars, (v) => v.hi), x0: x,
394
+ });
395
+ x.set(next);
396
+ error = rms(model(macroShape(macroConfig(x)), x), T);
397
+ }
398
+
399
+ const config = macroConfig(x);
400
+ config.detail = { ...base.detail };
401
+ for (const s of FIT_SLIDERS) delete config.detail[s.id];
402
+ const expression: Record<string, number> = {};
403
+ vars.forEach((v, j) => {
404
+ if (v.kind === 'detail') config.detail[v.key] = (config.detail[v.key] ?? 0) + v.sign * x[j];
405
+ else if (v.kind === 'expr') expression[v.key] = x[j];
406
+ });
407
+ for (const k of Object.keys(config.detail)) config.detail[k] = Math.round(config.detail[k] * 1000) / 1000;
408
+ const rs = config.african + config.asian + config.caucasian || 1;
409
+ config.african /= rs; config.asian /= rs; config.caucasian /= rs;
410
+ return { config, error, initialError, expression };
411
+ }
412
+
413
+ // ---------------------------------------------------------------- color
414
+
415
+ const toLin = (c: number) => { c /= 255; return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; };
416
+ const toSrgb = (c: number) => Math.round(255 * Math.min(1, Math.max(0, c <= 0.0031308 ? c * 12.92 : 1.055 * c ** (1 / 2.4) - 0.055)));
417
+ const hex = (rgb: number[]) => '#' + rgb.map((c) => c.toString(16).padStart(2, '0')).join('');
418
+
419
+ /** sRGB (0..255) -> CIE Lab (D65). */
420
+ export function srgbToLab([r, g, b]: number[]) {
421
+ const R = toLin(r), G = toLin(g), B = toLin(b);
422
+ const f = (t: number) => (t > 216 / 24389 ? Math.cbrt(t) : (24389 / 27 * t + 16) / 116);
423
+ const X = f((0.4124 * R + 0.3576 * G + 0.1805 * B) / 0.95047), Y = f(0.2126 * R + 0.7152 * G + 0.0722 * B), Z = f((0.0193 * R + 0.1192 * G + 0.9505 * B) / 1.08883);
424
+ return [116 * Y - 16, 500 * (X - Y), 200 * (Y - Z)];
425
+ }
426
+
427
+ /** Per-channel median of the pixels in a disc. */
428
+ function sampleDisc(img: RgbaImage, cx: number, cy: number, r: number, keep?: (rgb: number[], d: number) => boolean) {
429
+ const ch: number[][] = [[], [], []];
430
+ const r2 = r * r;
431
+ for (let y = Math.max(0, Math.floor(cy - r)); y <= Math.min(img.height - 1, Math.ceil(cy + r)); y++) {
432
+ for (let x = Math.max(0, Math.floor(cx - r)); x <= Math.min(img.width - 1, Math.ceil(cx + r)); x++) {
433
+ const d2 = (x - cx) ** 2 + (y - cy) ** 2;
434
+ if (d2 > r2) continue;
435
+ const o = (y * img.width + x) * 4;
436
+ const rgb = [img.data[o], img.data[o + 1], img.data[o + 2]];
437
+ if (keep && !keep(rgb, Math.sqrt(d2) / r)) continue;
438
+ for (let k = 0; k < 3; k++) ch[k].push(rgb[k]);
439
+ }
440
+ }
441
+ return ch;
442
+ }
443
+ const median = (a: number[]) => { if (!a.length) return NaN; const s = a.slice().sort((p, q) => p - q); return s[s.length >> 1]; };
444
+
445
+ /** Median skin color (sRGB 0..255) from cheek and forehead patches. */
446
+ export function sampleSkinColor(img: RgbaImage, lms: Landmark[]) {
447
+ const faceW = Math.hypot((lms[234].x - lms[454].x) * img.width, (lms[234].y - lms[454].y) * img.height);
448
+ const ch: number[][] = [[], [], []];
449
+ for (const i of FACE_MAP.skinLandmarks) {
450
+ const p = sampleDisc(img, lms[i].x * img.width, lms[i].y * img.height, faceW * 0.035);
451
+ for (let k = 0; k < 3; k++) ch[k].push(...p[k]);
452
+ }
453
+ return ch.map(median);
454
+ }
455
+
456
+ /** Iris centre/radius (pixels) for the eye whose corners are (a, b). */
457
+ function irisNear(img: RgbaImage, lms: Landmark[], a: number, b: number) {
458
+ const mx = (lms[a].x + lms[b].x) / 2, my = (lms[a].y + lms[b].y) / 2;
459
+ const [c, p, q] = Math.hypot(lms[468].x - mx, lms[468].y - my) < Math.hypot(lms[473].x - mx, lms[473].y - my) ? [468, 469, 471] : [473, 474, 476];
460
+ return { x: lms[c].x * img.width, y: lms[c].y * img.height, r: Math.hypot((lms[p].x - lms[q].x) * img.width, (lms[p].y - lms[q].y) * img.height) / 2 };
461
+ }
462
+
463
+ /** Median sclera (eye white) color, sRGB 0..255: a white reference for the photo's lighting. */
464
+ export function sampleScleraColor(img: RgbaImage, lms: Landmark[]) {
465
+ if (lms.length < 478) return null;
466
+ const px: number[][] = [];
467
+ for (const [a, b] of [[33, 133], [263, 362]]) {
468
+ const iris = irisNear(img, lms, a, b);
469
+ for (const corner of [a, b]) {
470
+ const cx = lms[corner].x * img.width, cy = lms[corner].y * img.height;
471
+ const D = Math.hypot(cx - iris.x, cy - iris.y);
472
+ if (!(D - iris.r > 2)) continue;
473
+ // a thin band from just outside the iris toward the corner
474
+ const ux = (cx - iris.x) / D, uy = (cy - iris.y) / D;
475
+ for (let d = iris.r * 1.15; d < iris.r + (D - iris.r) * 0.8; d += 0.7) {
476
+ for (let o = -iris.r * 0.25; o <= iris.r * 0.25; o += 0.7) {
477
+ const x = Math.round(iris.x + ux * d - uy * o), y = Math.round(iris.y + uy * d + ux * o);
478
+ if (x < 0 || y < 0 || x >= img.width || y >= img.height) continue;
479
+ const at = (y * img.width + x) * 4;
480
+ px.push([img.data[at], img.data[at + 1], img.data[at + 2]]);
481
+ }
482
+ }
483
+ }
484
+ }
485
+ if (px.length < 4) return null;
486
+ // the brightest pixels are sclera; the rest is lid, lashes, iris edge and shadow
487
+ px.sort((u, v) => v[0] + v[1] + v[2] - (u[0] + u[1] + u[2]));
488
+ const top = px.slice(0, Math.max(2, Math.round(px.length * 0.2)));
489
+ return [0, 1, 2].map((k) => median(top.map((p) => p[k])));
490
+ }
491
+
492
+ /**
493
+ * Re-express a photo's skin color as if it had been lit like the textures, using the sclera as
494
+ * a white reference (cancels exposure and white balance). Gain is limited so a bad sclera
495
+ * sample can't produce absurd colors.
496
+ */
497
+ export function normalizeSkin(skin: number[], sclera: number[] | null) {
498
+ // Only trust a clearly visible eye white: clearly brighter than the skin and nearly neutral.
499
+ // Small or shadowed eyes (typical in waist-up portraits) give lid/skin pixels instead.
500
+ const lum = (c: number[]) => 0.2126 * toLin(c[0]) + 0.7152 * toLin(c[1]) + 0.0722 * toLin(c[2]);
501
+ if (!sclera || lum(sclera) < lum(skin) * 1.25 || Math.max(...sclera) - Math.min(...sclera) > 0.18 * Math.max(...sclera)) return skin;
502
+ const gain = [0, 1, 2].map((k) => toLin(SCLERA_COLOR[k]) / Math.max(1e-3, toLin(sclera[k])));
503
+ const g = gain.reduce((a, b) => a + b) / 3;
504
+ const lim = Math.min(2.5, Math.max(0.4, g));
505
+ // overall exposure fully, white balance only half (sclera is rarely truly neutral in photos)
506
+ return [0, 1, 2].map((k) => toSrgb(toLin(skin[k]) * lim * Math.sqrt(gain[k] / g)));
507
+ }
508
+
509
+ /** Median iris color (sRGB 0..255), skipping pupil, specular highlights and lid shadow. */
510
+ export function sampleIrisColor(img: RgbaImage, lms: Landmark[]) {
511
+ if (lms.length < 478) return null;
512
+ const ch: number[][] = [[], [], []];
513
+ for (const [c, a, b] of [[468, 469, 471], [473, 474, 476]]) {
514
+ const cx = lms[c].x * img.width, cy = lms[c].y * img.height;
515
+ const r = Math.hypot((lms[a].x - lms[b].x) * img.width, (lms[a].y - lms[b].y) * img.height) / 2;
516
+ if (!(r > 1.5)) continue;
517
+ const p = sampleDisc(img, cx, cy, r * 0.85, (rgb, d) => {
518
+ const l = (rgb[0] + rgb[1] + rgb[2]) / 3;
519
+ return d > 0.35 && l > 18 && l < 225;
520
+ });
521
+ for (let k = 0; k < 3; k++) ch[k].push(...p[k]);
522
+ }
523
+ return ch[0].length >= 6 ? ch.map(median) : null;
524
+ }
525
+
526
+ const labDist = (a: number[], b: number[], lw = 1) => Math.hypot((a[0] - b[0]) * lw, a[1] - b[1], a[2] - b[2]);
527
+
528
+ /** Choose the closest eye texture for an iris color. Photos darken irises, so lightness counts less than hue. */
529
+ export function pickEyes(iris: number[], available: string[] = Object.keys(EYE_COLORS)) {
530
+ const lab = srgbToLab(iris);
531
+ let best = available[0], bd = Infinity;
532
+ for (const id of available) {
533
+ const ref = EYE_COLORS[id];
534
+ if (!ref) continue;
535
+ const r = srgbToLab(ref);
536
+ // compare chroma direction at matched lightness: scale the reference's a/b by the photo's lightness ratio
537
+ const k = Math.max(0.35, Math.min(1, lab[0] / Math.max(1, r[0])));
538
+ const d = labDist(lab, [r[0], r[1] * k, r[2] * k], 0.35);
539
+ if (d < bd) { bd = d; best = id; }
540
+ }
541
+ return best;
542
+ }
543
+
544
+ /**
545
+ * Choose a skin texture (preferring the fitted gender and age bucket) and a tint that moves its
546
+ * face color toward the photo's. The tint only darkens/hue-shifts (it multiplies the texture).
547
+ */
548
+ export function pickSkin(skin: number[], c: Partial<HumanConfig> & Pick<HumanConfig, 'gender' | 'age'>, available: { id: string; tags: string[] }[], tintStrength = 0.6) {
549
+ const lab = srgbToLab(skin);
550
+ const sex = c.gender >= 0.5 ? 'male' : 'female';
551
+ const years = 18 + 72 * c.age;
552
+ const ageTag = years < 35 ? 'young' : years < 60 ? 'middleage' : 'old';
553
+ let best = available[0]?.id ?? 'young_caucasian_female', bd = Infinity;
554
+ for (const s of available) {
555
+ const ref = SKIN_COLORS[s.id];
556
+ if (!ref) continue;
557
+ // lightness counts half: photo exposure varies far more than skin hue does
558
+ let d = labDist(lab, srgbToLab(ref), 0.5);
559
+ if (!s.tags.some((t) => t.startsWith(sex))) d += 40;
560
+ if (!s.tags.includes(ageTag)) d += 20;
561
+ // among similar colors, prefer the ethnicity the face geometry leans toward
562
+ if (c.african !== undefined && c.asian !== undefined && c.caucasian !== undefined) {
563
+ const mix: Record<string, number> = { african: c.african, asian: c.asian, caucasian: c.caucasian };
564
+ const tag = s.tags.find((t) => t in mix);
565
+ if (tag) d += 6 * (Math.max(c.african, c.asian, c.caucasian) - mix[tag]);
566
+ }
567
+ if (d < bd) { bd = d; best = s.id; }
568
+ }
569
+ const ref = SKIN_COLORS[best] ?? [200, 160, 140];
570
+ // Tint corrects hue only (chromaticity ratio at equal luminance), since the photo's exposure is
571
+ // unknown. Limited to ±15% per channel and normalized so it never brightens the texture.
572
+ const lum = (c: number[]) => 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
573
+ const pl = skin.map(toLin), rl = ref.map(toLin);
574
+ const ratio = [0, 1, 2].map((k) => Math.min(1.15, Math.max(0.85, (pl[k] / Math.max(1e-4, lum(pl))) / (rl[k] / Math.max(1e-4, lum(rl))))));
575
+ const top = Math.max(...ratio);
576
+ const tint = ratio.map((v) => 1 + tintStrength * (v / top - 1));
577
+ return { skin: best, skinTint: hex(tint.map(toSrgb)) };
578
+ }
579
+
580
+ // ---------------------------------------------------------------- one-call API
581
+
582
+ export interface FitSelfieOptions extends FitShapeOptions {
583
+ /** Known facts override estimates, e.g. { gender: 1, age: 0.2 }. */
584
+ hints?: Partial<Pick<HumanConfig, 'gender' | 'age' | 'african' | 'asian' | 'caucasian' | 'weight'>>;
585
+ /** Skins to choose from (pkg.manifest.skins). */
586
+ skins?: { id: string; tags: string[] }[];
587
+ /** Eye texture ids to choose from (pkg.manifest.eyes). */
588
+ eyes?: string[];
589
+ }
590
+
591
+ export interface SelfieFit extends FitShapeResult {
592
+ /** Sampled colors (sRGB), for UI/debugging. `skin` is after sclera normalization. */
593
+ colors: { skin: number[] | null; rawSkin: number[] | null; sclera: number[] | null; iris: number[] | null };
594
+ }
595
+
596
+ /**
597
+ * Selfie -> HumanConfig. `landmarks` are MediaPipe Face Landmarker landmarks (478 with iris,
598
+ * normalized to the image); `image` is the same image's RGBA pixels (optional: no colors without it).
599
+ */
600
+ export function fitSelfie(pkg: HumanPackage, landmarks: Landmark[], imageSize: { width: number; height: number }, image?: RgbaImage, opts: FitSelfieOptions = {}): SelfieFit {
601
+ if (landmarks.length < FACE_MAP.vertex.length) throw new Error(`need ${FACE_MAP.vertex.length} landmarks, got ${landmarks.length}`);
602
+ const hints = opts.hints ?? {};
603
+ const base: HumanConfig = { ...(opts.base ?? DEFAULT_HUMAN), ...hints };
604
+ const fixed = new Set(Object.keys(hints));
605
+ const macros = (opts.macros ?? DEFAULT_FIT_MACROS).filter((k) => !fixed.has(k));
606
+ // Known macros (hints) shape the neutral face the person's differences are transferred onto.
607
+ const target = landmarksToTarget(pkg, landmarks, imageSize.width, imageSize.height, { ...DEFAULT_HUMAN, ...hints });
608
+ const res = fitFaceShape(pkg, target, { ...opts, base, macros });
609
+ const colors: SelfieFit['colors'] = { skin: null, rawSkin: null, sclera: null, iris: null };
610
+ if (image) {
611
+ colors.rawSkin = sampleSkinColor(image, landmarks);
612
+ colors.sclera = sampleScleraColor(image, landmarks);
613
+ colors.skin = normalizeSkin(colors.rawSkin, colors.sclera);
614
+ if (colors.skin.every(Number.isFinite)) Object.assign(res.config, pickSkin(colors.skin, res.config, opts.skins ?? pkg.manifest.skins));
615
+ colors.iris = sampleIrisColor(image, landmarks);
616
+ if (colors.iris) res.config.eyes = pickEyes(colors.iris, opts.eyes ?? pkg.manifest.eyes);
617
+ }
618
+ return { ...res, colors };
619
+ }