spine-rigc 0.7.0 → 0.8.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.
package/src/pose.ts ADDED
@@ -0,0 +1,1386 @@
1
+ /**
2
+ * pose — where each loose part sits in one pose frame.
3
+ *
4
+ * ⭐ This is an ENTRY instrument, and the distinction decides every choice below.
5
+ * The user hands over a condition — "here is the pose I want" as a picture — and
6
+ * an agent has to turn it into spec coordinates. Nothing here grades anything:
7
+ * the poses become inputs the spec then states by construction, so no pass bar
8
+ * attaches to any number this file produces. The residual exists so the agent
9
+ * knows **how much to trust** a placement and **where two answers are equally
10
+ * good**, which is a different job from scoring and needs the opposite defaults.
11
+ *
12
+ * What that means in practice: a refusal names the part and the reason and still
13
+ * prints the best it found, an ambiguity reports BOTH optima rather than picking,
14
+ * and a part whose rotation genuinely does not matter is reported as having a free
15
+ * degree of freedom instead of a bad one.
16
+ *
17
+ * 🔍 The estimator. For every part PNG it searches the rigid family
18
+ * (translation, one rotation, one uniform scale) for the placement whose pixels
19
+ * best explain the frame's pixels **inside the part's own alpha footprint**. The
20
+ * objective is an alpha-weighted mean absolute colour error in 0..1:
21
+ *
22
+ * err(part pixel) = material · |partRGB − frameRGB| / 255 + (1 − material)
23
+ *
24
+ * where `material` is how much of the frame is *not* background there — so a part
25
+ * pixel hanging over the background, or off the canvas entirely, costs the maximum
26
+ * 1 rather than whatever colour distance the background happens to give.
27
+ * Normalising by the part's own alpha weight is what makes residuals comparable
28
+ * between a thumb and a torso.
29
+ *
30
+ * ⚠️ Measuring on the part's own footprint is also the only occlusion robustness
31
+ * here, and it is deliberately not a solver. A part drawn *behind* another in the
32
+ * frame has the occluder's pixels where its own should be, so its residual rises
33
+ * even at the correct placement. `unexplained` separates the two readings: a low
34
+ * residual is a confident placement, a middling residual with a high `unexplained`
35
+ * is usually a correct placement seen through something else. Weigh accordingly;
36
+ * do not read either as a verdict.
37
+ *
38
+ * Coordinates are the frame's own: **frame pixels, y down, origin top-left**, the
39
+ * same convention a cut manifest uses. `rotationDeg` is screen degrees — positive
40
+ * turns clockwise on screen — so `screenToSpineDegrees` in `src/transform.ts` is
41
+ * the one conversion to Spine's y-up CCW world, and `cropToSpineY` the other.
42
+ */
43
+ import { existsSync, readdirSync, statSync } from 'node:fs';
44
+ import { basename, join, resolve } from 'node:path';
45
+ import { Plate, readPlate } from '../tools/plate.ts';
46
+ // `bilinear` rather than a second sampler written here: how a pixel is read
47
+ // between two pixel centres is exactly the kind of thing two implementations
48
+ // drift on, and `check` already measures with this one.
49
+ import { bilinear } from './render.ts';
50
+
51
+ export class PoseError extends Error {}
52
+
53
+ /** The `spec` field every report carries, so a consumer can refuse a future shape. */
54
+ export const POSE_SPEC = 'rigc-pose/1';
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // the constants the search is made of — every one of them is reported
58
+ // ---------------------------------------------------------------------------
59
+ //
60
+ // They are exported because a number that steers a refusal has to be quotable:
61
+ // the docs cite them, the selftest states its tolerances against them, and the
62
+ // report repeats the ones a caller can move.
63
+
64
+ /**
65
+ * Longest side, in pixels, the coarse scan would like to reduce the FRAME to.
66
+ *
67
+ * A ceiling on the pyramid, not the level the scan runs at — `COARSE_PART_SPAN`
68
+ * can overrule it downward, because a level that has reduced the part to three
69
+ * pixels tells nobody anything about where the part is.
70
+ */
71
+ export const COARSE_LONG_SIDE = 40;
72
+
73
+ /**
74
+ * Pixels the PART must still span at the level the coarse scan runs at.
75
+ *
76
+ * ⚠️ The other half of choosing that level, and the half a frame-only rule gets
77
+ * wrong. Measured: on a 1600x1200 frame the frame rule alone picks a 64x
78
+ * reduction, at which a 120x180 part is 2x3 pixels sampled six times — no signal
79
+ * at all, and eight such parts came back with the wrong scale and a position out
80
+ * by seventeen pixels. The coarse level is therefore the coarser of "the frame
81
+ * fits in COARSE_LONG_SIDE" and "the part still spans this much".
82
+ */
83
+ export const COARSE_PART_SPAN = 10;
84
+
85
+ /**
86
+ * Coarse anchor positions are stepped at a fraction of the part's own size.
87
+ *
88
+ * The objective varies over distances of order the part, not of order a pixel, so
89
+ * scanning every pixel of the coarse level buys resolution the refinement stages
90
+ * supply anyway — and it is what makes the scan's cost grow with the frame's area
91
+ * instead of with the number of places the part could plausibly be.
92
+ */
93
+ export const COARSE_STRIDE_FRACTION = 0.25;
94
+
95
+ /** How many scale rungs one octave gets in the coarse ladder. */
96
+ export const SCALE_STEPS_PER_OCTAVE = 3;
97
+
98
+ /** The coarse rotation ladder's step, in degrees. */
99
+ export const COARSE_ROTATION_STEP = 15;
100
+
101
+ /** Default scale window, as frame pixels per part pixel. */
102
+ export const DEFAULT_SCALE_MIN = 0.5;
103
+ export const DEFAULT_SCALE_MAX = 2;
104
+
105
+ /**
106
+ * Above this residual the placement is refused by name rather than reported flat.
107
+ *
108
+ * ⚠️ Not a pass bar. It is where "this part is somewhere in this picture" stops
109
+ * being a claim worth making — a foreign part scores far above it and a real one
110
+ * far below, and the report carries the number either way so a caller who
111
+ * disagrees can read past the refusal.
112
+ */
113
+ export const DEFAULT_MAX_RESIDUAL = 0.25;
114
+
115
+ /** Two optima this close are reported as both, never as one. Absolute, then relative to the best. */
116
+ export const AMBIGUITY_ABSOLUTE = 0.01;
117
+ export const AMBIGUITY_RELATIVE = 0.2;
118
+
119
+ /**
120
+ * Max self-residual under rotation, relative to the identity, for a part to be
121
+ * called rotation-free.
122
+ *
123
+ * The gap it sits in is wide, which is why one number can hold it: on the
124
+ * selftest's own art a smooth 32px ball reads 0.014 and the least distinctive
125
+ * non-round part in the set — a two-tone head — reads 0.307. Anything with a
126
+ * corner, a silhouette or an off-centre feature is an order of magnitude clear
127
+ * of this line.
128
+ */
129
+ export const ROTATION_FREE_TOLERANCE = 0.04;
130
+
131
+ /** Per-pixel error above which a pixel counts toward `unexplained`. */
132
+ export const UNEXPLAINED_TOLERANCE = 0.15;
133
+
134
+ /** Mean absolute channel distance, 0..255, within which a frame pixel counts as background. */
135
+ export const BACKGROUND_TOLERANCE = 10;
136
+
137
+ /** Share of the frame's border ring one colour must hold before it is called the background. */
138
+ export const BACKGROUND_BORDER_SHARE = 0.6;
139
+
140
+ /** How many distinct places each coarse scale rung sends down for refinement. */
141
+ const MINIMA_PER_SCALE = 3;
142
+
143
+ /** How many candidates survive each refinement level. */
144
+ const REFINE_CANDIDATES = 12;
145
+
146
+ /** Sample budgets per stage. The reported residual uses every pixel regardless. */
147
+ const COARSE_SAMPLES = 96;
148
+ const REFINE_SAMPLES = 384;
149
+ const POLISH_SAMPLES = 2048;
150
+
151
+ /** Alternates beyond this many are not printed; the count is still stated. */
152
+ const MAX_ALTERNATES = 3;
153
+
154
+ const DEG = Math.PI / 180;
155
+
156
+ // ---------------------------------------------------------------------------
157
+ // the report
158
+ // ---------------------------------------------------------------------------
159
+
160
+ export interface PoseBackground {
161
+ kind: 'transparent' | 'colour' | 'unknown';
162
+ /** The background colour, when there is one. */
163
+ colour: [number, number, number] | null;
164
+ /** Share of the one-pixel border ring that agreed with the verdict, 0..1. */
165
+ borderShare: number;
166
+ /** Share of the frame that counts as material, 0..1. */
167
+ materialShare: number;
168
+ }
169
+
170
+ /** One rigid placement of one part, in frame pixels, y down, origin top-left. */
171
+ export interface PosePlacement {
172
+ /** Where the part image's own centre — `(width/2, height/2)` — lands. */
173
+ x: number;
174
+ y: number;
175
+ /** Screen degrees: positive turns clockwise on screen. `screenToSpineDegrees` converts. */
176
+ rotationDeg: number;
177
+ /** Uniform, as frame pixels per part pixel. */
178
+ scale: number;
179
+ /** Alpha-weighted mean absolute error over the part's own footprint, 0..1. Lower is better explained. */
180
+ residual: number;
181
+ /** Share of the part's alpha weight whose per-pixel error clears `UNEXPLAINED_TOLERANCE`. Occlusion shows up here. */
182
+ unexplained: number;
183
+ /** Share of the part's alpha weight that lands outside the frame canvas. */
184
+ offCanvas: number;
185
+ /** Frame pixels of material this placement accounts for — the tie-break between equal residuals. */
186
+ footprint: number;
187
+ /** Axis-aligned box the placed part's material occupies, frame pixels. */
188
+ bbox: { x: number; y: number; width: number; height: number };
189
+ }
190
+
191
+ export type PoseRefusalReason = 'empty-part' | 'larger-than-canvas' | 'no-match';
192
+
193
+ export interface PoseRefusal {
194
+ reason: PoseRefusalReason;
195
+ detail: string;
196
+ }
197
+
198
+ export interface PosePart {
199
+ /** The PNG's file name — how the report names the part everywhere. */
200
+ part: string;
201
+ path: string;
202
+ width: number;
203
+ height: number;
204
+ /**
205
+ * Why this part's answer should not be taken at face value, or `null`.
206
+ *
207
+ * ⚠️ `placement` is still filled in under a `no-match` refusal, on purpose: a
208
+ * refusal here names why you should not trust a number, it does not hide it.
209
+ * `empty-part` and `larger-than-canvas` leave it `null` because nothing was
210
+ * searched.
211
+ */
212
+ refusal: PoseRefusal | null;
213
+ placement: PosePlacement | null;
214
+ /** Other optima worth reporting, best first. Non-empty means the answer was not unique. */
215
+ alternates: PosePlacement[];
216
+ /** True when at least one alternate sits inside the ambiguity margin. */
217
+ ambiguous: boolean;
218
+ /** True when the part is self-similar under rotation, so `rotationDeg` is yours to choose. */
219
+ rotationFree: boolean;
220
+ /**
221
+ * Worst residual the part scores against itself over eleven rotations, 0..1.
222
+ *
223
+ * The number `rotationFree` is a threshold on — reported because "how round is
224
+ * this part" is a spectrum, and a part just over the line is worth knowing about.
225
+ */
226
+ rotationSelfSimilarity: number;
227
+ /**
228
+ * The grid this part was actually looked for on: the frame reduction the
229
+ * exhaustive pass ran at, its anchor grid, and the step between anchors in
230
+ * those reduced pixels. A coarse grid of a handful of cells is a warning that
231
+ * the part is small relative to the frame and the first pass had little to go on.
232
+ */
233
+ coarse: { reduction: number; cols: number; rows: number; stride: number } | null;
234
+ /** Plain-language versions of everything above, in the order they were found. */
235
+ notes: string[];
236
+ }
237
+
238
+ export interface PoseSearch {
239
+ scale: { min: number; max: number; steps: number };
240
+ rotation: { minDeg: number; maxDeg: number; stepDeg: number; steps: number };
241
+ /**
242
+ * How the exhaustive first pass was sized. The level it runs at is chosen PER
243
+ * PART — see `PosePart.coarse` — because it depends on how big the part is.
244
+ */
245
+ coarse: { frameLongSide: number; partSpan: number; strideFraction: number; framePyramid: number };
246
+ maxResidual: number;
247
+ ambiguity: { absolute: number; relative: number };
248
+ }
249
+
250
+ export interface PoseReport {
251
+ spec: string;
252
+ /** The coordinate contract, spelled out in the file rather than assumed. */
253
+ space: string;
254
+ images: string;
255
+ frame: { path: string; width: number; height: number; background: PoseBackground };
256
+ search: PoseSearch;
257
+ /** What the numbers above cannot see. Read before consuming them. */
258
+ caveats: string[];
259
+ parts: PosePart[];
260
+ }
261
+
262
+ export interface PoseOptions {
263
+ /** Directory of loose part PNGs. */
264
+ imagesDir: string;
265
+ /** One pose frame. */
266
+ framePath: string;
267
+ scale?: { min: number; max: number };
268
+ rotation?: { minDeg: number; maxDeg: number };
269
+ maxResidual?: number;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // pixels
274
+ // ---------------------------------------------------------------------------
275
+
276
+ /** One rung of a plate pyramid, flattened for the inner loops. */
277
+ interface Level {
278
+ data: Uint8Array;
279
+ width: number;
280
+ height: number;
281
+ /** Full-resolution pixels per pixel of this level. */
282
+ reduction: number;
283
+ }
284
+
285
+ function levelOf(plate: Plate, reduction: number): Level {
286
+ return { data: plate.data, width: plate.width, height: plate.height, reduction };
287
+ }
288
+
289
+ /**
290
+ * Box-filter one plate down by two.
291
+ *
292
+ * RGB is averaged **weighted by alpha** and alpha plainly: averaging colour
293
+ * straight would drag every edge pixel toward whatever the transparent
294
+ * neighbour happens to store, which for a cut-out part is usually black.
295
+ */
296
+ function halve(src: Plate): Plate {
297
+ const w = Math.max(1, src.width >> 1);
298
+ const h = Math.max(1, src.height >> 1);
299
+ const out = new Plate(w, h);
300
+ for (let y = 0; y < h; y++) {
301
+ for (let x = 0; x < w; x++) {
302
+ let sa = 0;
303
+ let sr = 0;
304
+ let sg = 0;
305
+ let sb = 0;
306
+ let n = 0;
307
+ for (let dy = 0; dy < 2; dy++) {
308
+ const sy = Math.min(src.height - 1, y * 2 + dy);
309
+ for (let dx = 0; dx < 2; dx++) {
310
+ const sx = Math.min(src.width - 1, x * 2 + dx);
311
+ const i = (sy * src.width + sx) * 4;
312
+ const a = src.data[i + 3];
313
+ sa += a;
314
+ sr += src.data[i] * a;
315
+ sg += src.data[i + 1] * a;
316
+ sb += src.data[i + 2] * a;
317
+ n++;
318
+ }
319
+ }
320
+ const o = (y * w + x) * 4;
321
+ out.data[o + 3] = Math.round(sa / n);
322
+ if (sa > 0) {
323
+ out.data[o] = Math.round(sr / sa);
324
+ out.data[o + 1] = Math.round(sg / sa);
325
+ out.data[o + 2] = Math.round(sb / sa);
326
+ }
327
+ }
328
+ }
329
+ return out;
330
+ }
331
+
332
+ /** `plate`, then every halving of it down to `minLongSide`, capped at `maxLevels`. */
333
+ function pyramid(plate: Plate, maxLevels: number, minLongSide: number): Plate[] {
334
+ const out = [plate];
335
+ while (out.length <= maxLevels) {
336
+ const top = out[out.length - 1];
337
+ if (Math.max(top.width, top.height) <= minLongSide) break;
338
+ if (top.width < 2 || top.height < 2) break;
339
+ out.push(halve(top));
340
+ }
341
+ return out;
342
+ }
343
+
344
+ /**
345
+ * What the frame's background is, read off its one-pixel border ring.
346
+ *
347
+ * ⭐ Why the background matters at all: without it, a grey part placed on grey
348
+ * emptiness scores as well as a grey part placed on the grey figure, and the
349
+ * whole silhouette signal is gone. With it, "the frame has nothing here" is the
350
+ * maximum error rather than a lucky colour match.
351
+ *
352
+ * A border that is not dominated by one colour is reported `unknown` rather than
353
+ * guessed at — the objective then reduces to plain colour matching, which is a
354
+ * weaker instrument, and the report says so instead of quietly being weaker.
355
+ */
356
+ function readBackground(frame: Plate): PoseBackground {
357
+ const w = frame.width;
358
+ const h = frame.height;
359
+ let ringCount = 0;
360
+ let transparent = 0;
361
+ const buckets = new Map<number, { n: number; r: number; g: number; b: number }>();
362
+ const visit = (x: number, y: number): void => {
363
+ const i = (y * w + x) * 4;
364
+ ringCount++;
365
+ if (frame.data[i + 3] < 8) {
366
+ transparent++;
367
+ return;
368
+ }
369
+ const r = frame.data[i];
370
+ const g = frame.data[i + 1];
371
+ const b = frame.data[i + 2];
372
+ const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
373
+ const cell = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
374
+ cell.n++;
375
+ cell.r += r;
376
+ cell.g += g;
377
+ cell.b += b;
378
+ buckets.set(key, cell);
379
+ };
380
+ for (let x = 0; x < w; x++) {
381
+ visit(x, 0);
382
+ if (h > 1) visit(x, h - 1);
383
+ }
384
+ for (let y = 1; y < h - 1; y++) {
385
+ visit(0, y);
386
+ if (w > 1) visit(w - 1, y);
387
+ }
388
+ if (ringCount === 0) return { kind: 'unknown', colour: null, borderShare: 0, materialShare: 1 };
389
+ if (transparent / ringCount >= 0.5) {
390
+ return { kind: 'transparent', colour: null, borderShare: transparent / ringCount, materialShare: 0 };
391
+ }
392
+ let best: { n: number; r: number; g: number; b: number } | null = null;
393
+ for (const cell of buckets.values()) if (best === null || cell.n > best.n) best = cell;
394
+ if (best === null || best.n / ringCount < BACKGROUND_BORDER_SHARE) {
395
+ return { kind: 'unknown', colour: null, borderShare: best ? best.n / ringCount : 0, materialShare: 1 };
396
+ }
397
+ return {
398
+ kind: 'colour',
399
+ colour: [Math.round(best.r / best.n), Math.round(best.g / best.n), Math.round(best.b / best.n)],
400
+ borderShare: best.n / ringCount,
401
+ materialShare: 0,
402
+ };
403
+ }
404
+
405
+ /**
406
+ * The frame as the objective reads it: the frame's own RGB, with alpha rewritten
407
+ * to mean **how much material is here** rather than how opaque the file is.
408
+ *
409
+ * Keeping it in a `Plate` is what lets the pyramid, `bilinear` and the nearest
410
+ * lookup all be the ones this repository already has.
411
+ */
412
+ function materialPlate(frame: Plate, background: PoseBackground): { plate: Plate; share: number } {
413
+ const out = new Plate(frame.width, frame.height);
414
+ let material = 0;
415
+ const bg = background.colour;
416
+ for (let i = 0; i < frame.data.length; i += 4) {
417
+ const a = frame.data[i + 3];
418
+ out.data[i] = frame.data[i];
419
+ out.data[i + 1] = frame.data[i + 1];
420
+ out.data[i + 2] = frame.data[i + 2];
421
+ let m: number;
422
+ if (background.kind === 'transparent') {
423
+ m = a;
424
+ } else if (background.kind === 'colour' && bg !== null) {
425
+ const d = (Math.abs(frame.data[i] - bg[0]) + Math.abs(frame.data[i + 1] - bg[1]) + Math.abs(frame.data[i + 2] - bg[2])) / 3;
426
+ m = d > BACKGROUND_TOLERANCE ? a : 0;
427
+ } else {
428
+ m = a;
429
+ }
430
+ out.data[i + 3] = m;
431
+ material += m / 255;
432
+ }
433
+ return { plate: out, share: material / Math.max(1, frame.width * frame.height) };
434
+ }
435
+
436
+ /** The box the part's material occupies, in part pixels. `null` when there is none. */
437
+ function materialBox(part: Plate): { minX: number; minY: number; maxX: number; maxY: number; weight: number } | null {
438
+ let minX = Infinity;
439
+ let minY = Infinity;
440
+ let maxX = -Infinity;
441
+ let maxY = -Infinity;
442
+ let weight = 0;
443
+ for (let y = 0; y < part.height; y++) {
444
+ for (let x = 0; x < part.width; x++) {
445
+ const a = part.data[(y * part.width + x) * 4 + 3];
446
+ if (a === 0) continue;
447
+ weight += a / 255;
448
+ if (x < minX) minX = x;
449
+ if (x > maxX) maxX = x;
450
+ if (y < minY) minY = y;
451
+ if (y > maxY) maxY = y;
452
+ }
453
+ }
454
+ if (weight === 0) return null;
455
+ return { minX, minY, maxX: maxX + 1, maxY: maxY + 1, weight };
456
+ }
457
+
458
+ // ---------------------------------------------------------------------------
459
+ // the objective
460
+ // ---------------------------------------------------------------------------
461
+
462
+ /**
463
+ * The part, reduced to a list of coloured offsets from its anchor.
464
+ *
465
+ * Offsets are in **full-resolution part pixels** whatever mip they were read
466
+ * from, so one sample set is valid at every search level: the level only decides
467
+ * what the offsets get divided by on the way in.
468
+ */
469
+ interface Samples {
470
+ u: Float64Array;
471
+ v: Float64Array;
472
+ r: Float64Array;
473
+ g: Float64Array;
474
+ b: Float64Array;
475
+ w: Float64Array;
476
+ count: number;
477
+ weight: number;
478
+ }
479
+
480
+ const EMPTY_SAMPLES: Samples = {
481
+ u: new Float64Array(0),
482
+ v: new Float64Array(0),
483
+ r: new Float64Array(0),
484
+ g: new Float64Array(0),
485
+ b: new Float64Array(0),
486
+ w: new Float64Array(0),
487
+ count: 0,
488
+ weight: 0,
489
+ };
490
+
491
+ /**
492
+ * Pick at most `cap` of a mip's material pixels, by a fixed stride over the
493
+ * material list.
494
+ *
495
+ * Deterministic on purpose — `src/` has no randomness, and a sampler that
496
+ * shuffled would make two runs of the same command disagree in the last decimal
497
+ * of every residual.
498
+ */
499
+ function buildSamples(mip: Plate, reduction: number, anchorX: number, anchorY: number, cap: number): Samples {
500
+ const idx: number[] = [];
501
+ for (let y = 0; y < mip.height; y++) {
502
+ for (let x = 0; x < mip.width; x++) {
503
+ if (mip.data[(y * mip.width + x) * 4 + 3] > 0) idx.push(y * mip.width + x);
504
+ }
505
+ }
506
+ if (idx.length === 0) return EMPTY_SAMPLES;
507
+ const count = Math.min(cap, idx.length);
508
+ const s: Samples = {
509
+ u: new Float64Array(count),
510
+ v: new Float64Array(count),
511
+ r: new Float64Array(count),
512
+ g: new Float64Array(count),
513
+ b: new Float64Array(count),
514
+ w: new Float64Array(count),
515
+ count,
516
+ weight: 0,
517
+ };
518
+ for (let k = 0; k < count; k++) {
519
+ const at = idx[Math.floor((k * idx.length) / count)];
520
+ const px = at % mip.width;
521
+ const py = (at - px) / mip.width;
522
+ const i = at * 4;
523
+ s.u[k] = (px + 0.5) * reduction - anchorX;
524
+ s.v[k] = (py + 0.5) * reduction - anchorY;
525
+ s.r[k] = mip.data[i];
526
+ s.g[k] = mip.data[i + 1];
527
+ s.b[k] = mip.data[i + 2];
528
+ s.w[k] = mip.data[i + 3] / 255;
529
+ s.weight += s.w[k];
530
+ }
531
+ return s;
532
+ }
533
+
534
+ /** Error of one part pixel against the level, nearest neighbour. 1 outside the canvas. */
535
+ function errNearest(level: Level, x: number, y: number, pr: number, pg: number, pb: number): number {
536
+ const ix = Math.floor(x);
537
+ const iy = Math.floor(y);
538
+ if (ix < 0 || iy < 0 || ix >= level.width || iy >= level.height) return 1;
539
+ const i = (iy * level.width + ix) * 4;
540
+ const m = level.data[i + 3] / 255;
541
+ if (m <= 0) return 1;
542
+ const d = (Math.abs(level.data[i] - pr) + Math.abs(level.data[i + 1] - pg) + Math.abs(level.data[i + 2] - pb)) / 765;
543
+ return m * d + (1 - m);
544
+ }
545
+
546
+ /** Same, sampled between pixel centres — what the refinement stages measure with. */
547
+ function errBilinear(level: Level, plate: Plate, x: number, y: number, pr: number, pg: number, pb: number): number {
548
+ if (x < 0 || y < 0 || x >= level.width || y >= level.height) return 1;
549
+ const [fr, fg, fb, fa] = bilinear(plate, x - 0.5, y - 0.5);
550
+ const m = fa / 255;
551
+ if (m <= 0) return 1;
552
+ const d = (Math.abs(fr - pr) + Math.abs(fg - pg) + Math.abs(fb - pb)) / 765;
553
+ return m * d + (1 - m);
554
+ }
555
+
556
+ /** A placement mid-search: the anchor's position at some level, plus the two other degrees of freedom. */
557
+ interface Candidate {
558
+ /** Anchor position in the level's own pixels. */
559
+ cx: number;
560
+ cy: number;
561
+ rotDeg: number;
562
+ /** Frame pixels per part pixel, at FULL resolution — level-independent. */
563
+ scale: number;
564
+ residual: number;
565
+ }
566
+
567
+ function residualAt(level: Level, plate: Plate, s: Samples, cand: Candidate, smooth: boolean): number {
568
+ if (s.count === 0) return 1;
569
+ const k = cand.scale / level.reduction;
570
+ const cos = Math.cos(cand.rotDeg * DEG) * k;
571
+ const sin = Math.sin(cand.rotDeg * DEG) * k;
572
+ let acc = 0;
573
+ for (let i = 0; i < s.count; i++) {
574
+ const fx = cand.cx + s.u[i] * cos - s.v[i] * sin;
575
+ const fy = cand.cy + s.u[i] * sin + s.v[i] * cos;
576
+ acc += s.w[i] * (smooth ? errBilinear(level, plate, fx, fy, s.r[i], s.g[i], s.b[i]) : errNearest(level, fx, fy, s.r[i], s.g[i], s.b[i]));
577
+ }
578
+ return acc / s.weight;
579
+ }
580
+
581
+ // ---------------------------------------------------------------------------
582
+ // the search
583
+ // ---------------------------------------------------------------------------
584
+
585
+ /**
586
+ * Every anchor cell of the coarse level, holding the best (rotation, scale) found
587
+ * for it.
588
+ *
589
+ * A per-cell best rather than a global top-K, because the thing this instrument
590
+ * must not lose is the SECOND place a part could sit — and a global top-K fills
591
+ * up with a hundred neighbours of the single best cell before it ever reaches it.
592
+ */
593
+ interface CoarseField {
594
+ residual: Float64Array;
595
+ rotDeg: Float64Array;
596
+ scale: number;
597
+ /** Grid size, which is the LEVEL's size divided by `stride`. */
598
+ cols: number;
599
+ rows: number;
600
+ /** Level pixels per grid step. */
601
+ stride: number;
602
+ }
603
+
604
+ /**
605
+ * One field per scale rung, and that separation is the load-bearing part.
606
+ *
607
+ * 🚨 A coarse level cannot compare scales. At a 4x reduction a striped torso and
608
+ * a ringed ball are both near-uniform blobs, so the rung that scores best on one
609
+ * is whichever fits deepest inside it — the smallest — and folding all rungs into
610
+ * one field bakes that preference in before any level with detail gets a vote.
611
+ * Keeping the fields apart means the coarse scan only ever answers the question it
612
+ * CAN answer — "given this size, where and at what angle?" — and every rung sends
613
+ * its own best guesses down to the levels that can tell them apart. Measured on
614
+ * the fixture, folding them cost a torso its scale (0.59 against a true 1.15) and
615
+ * cost the estimator one of two identical arms.
616
+ */
617
+ function coarseScan(level: Level, s: Samples, scale: number, rotations: number[], stride: number): CoarseField {
618
+ const cols = Math.max(1, Math.ceil(level.width / stride));
619
+ const rows = Math.max(1, Math.ceil(level.height / stride));
620
+ const field: CoarseField = {
621
+ residual: new Float64Array(cols * rows).fill(Infinity),
622
+ rotDeg: new Float64Array(cols * rows),
623
+ scale,
624
+ cols,
625
+ rows,
626
+ stride,
627
+ };
628
+ if (s.count === 0) return field;
629
+ const k = scale / level.reduction;
630
+ for (const rotDeg of rotations) {
631
+ const cos = Math.cos(rotDeg * DEG) * k;
632
+ const sin = Math.sin(rotDeg * DEG) * k;
633
+ const dx = new Float64Array(s.count);
634
+ const dy = new Float64Array(s.count);
635
+ for (let i = 0; i < s.count; i++) {
636
+ dx[i] = s.u[i] * cos - s.v[i] * sin;
637
+ dy[i] = s.u[i] * sin + s.v[i] * cos;
638
+ }
639
+ for (let gy = 0; gy < rows; gy++) {
640
+ for (let gx = 0; gx < cols; gx++) {
641
+ const cell = gy * cols + gx;
642
+ // The bound is this cell's own best so far: anything that cannot beat
643
+ // it changes nothing, so the loop may leave the moment it passes it.
644
+ const bound = field.residual[cell] * s.weight;
645
+ const ax = gx * stride + stride / 2;
646
+ const ay = gy * stride + stride / 2;
647
+ let acc = 0;
648
+ let beaten = false;
649
+ for (let i = 0; i < s.count; i++) {
650
+ acc += s.w[i] * errNearest(level, ax + dx[i], ay + dy[i], s.r[i], s.g[i], s.b[i]);
651
+ if ((i & 15) === 15 && acc >= bound) {
652
+ beaten = true;
653
+ break;
654
+ }
655
+ }
656
+ if (beaten) continue;
657
+ const residual = acc / s.weight;
658
+ if (residual < field.residual[cell]) {
659
+ field.residual[cell] = residual;
660
+ field.rotDeg[cell] = rotDeg;
661
+ }
662
+ }
663
+ }
664
+ }
665
+ return field;
666
+ }
667
+
668
+ /**
669
+ * The distinct places a part could sit, best first.
670
+ *
671
+ * A cell survives when nothing within `radius` beats it — the two-arms case is
672
+ * exactly two such cells — and the accepted list then keeps them apart so the
673
+ * refinement budget is not spent twice on one hill.
674
+ */
675
+ function localMinima(field: CoarseField, radius: number, keep: number): Candidate[] {
676
+ const found: Candidate[] = [];
677
+ for (let gy = 0; gy < field.rows; gy++) {
678
+ for (let gx = 0; gx < field.cols; gx++) {
679
+ const here = field.residual[gy * field.cols + gx];
680
+ if (!Number.isFinite(here)) continue;
681
+ let minimal = true;
682
+ for (let dy = -radius; dy <= radius && minimal; dy++) {
683
+ for (let dx = -radius; dx <= radius; dx++) {
684
+ const nx = gx + dx;
685
+ const ny = gy + dy;
686
+ if (nx < 0 || ny < 0 || nx >= field.cols || ny >= field.rows) continue;
687
+ if (field.residual[ny * field.cols + nx] < here) {
688
+ minimal = false;
689
+ break;
690
+ }
691
+ }
692
+ }
693
+ if (!minimal) continue;
694
+ const cell = gy * field.cols + gx;
695
+ found.push({
696
+ cx: gx * field.stride + field.stride / 2,
697
+ cy: gy * field.stride + field.stride / 2,
698
+ rotDeg: field.rotDeg[cell],
699
+ scale: field.scale,
700
+ residual: here,
701
+ });
702
+ }
703
+ }
704
+ found.sort((a, b) => a.residual - b.residual);
705
+ const accepted: Candidate[] = [];
706
+ for (const cand of found) {
707
+ if (accepted.length >= keep) break;
708
+ if (accepted.some((a) => Math.hypot(a.cx - cand.cx, a.cy - cand.cy) <= radius * field.stride)) continue;
709
+ accepted.push(cand);
710
+ }
711
+ return accepted;
712
+ }
713
+
714
+ /**
715
+ * Pattern search on all four degrees of freedom: probe, move to the best
716
+ * improvement, and halve the steps when none of them improves.
717
+ *
718
+ * Cheaper than a local grid by an order of magnitude and it is the same answer —
719
+ * the objective is smooth at this range, and the coarse scan has already done the
720
+ * part a local method cannot (finding the right hill).
721
+ */
722
+ function polish(
723
+ level: Level,
724
+ plate: Plate,
725
+ s: Samples,
726
+ start: Candidate,
727
+ step: { translate: number; rotate: number; scale: number },
728
+ floor: { translate: number; rotate: number; scale: number },
729
+ smooth: boolean,
730
+ /** The scale window the report declares. A polish that walked outside it would report a scale nobody searched. */
731
+ bounds: { min: number; max: number },
732
+ ): Candidate {
733
+ const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
734
+ let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
735
+ let dt = step.translate;
736
+ let dr = step.rotate;
737
+ let ds = step.scale;
738
+ for (let guard = 0; guard < 200; guard++) {
739
+ if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) break;
740
+ const probes: Candidate[] = [];
741
+ const push = (cand: Omit<Candidate, 'residual'>): void => {
742
+ probes.push({ ...cand, residual: residualAt(level, plate, s, { ...cand, residual: 0 }, smooth) });
743
+ };
744
+ if (dt > floor.translate) {
745
+ for (const [ox, oy] of [
746
+ [1, 0],
747
+ [-1, 0],
748
+ [0, 1],
749
+ [0, -1],
750
+ [1, 1],
751
+ [1, -1],
752
+ [-1, 1],
753
+ [-1, -1],
754
+ ]) {
755
+ push({ cx: cur.cx + ox * dt, cy: cur.cy + oy * dt, rotDeg: cur.rotDeg, scale: cur.scale });
756
+ }
757
+ }
758
+ if (dr > floor.rotate) {
759
+ push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg + dr, scale: cur.scale });
760
+ push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg - dr, scale: cur.scale });
761
+ }
762
+ if (ds > floor.scale) {
763
+ push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg, scale: clamp(cur.scale * (1 + ds)) });
764
+ push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg, scale: clamp(cur.scale * (1 - ds)) });
765
+ }
766
+ let best = cur;
767
+ for (const p of probes) if (p.residual < best.residual) best = p;
768
+ if (best === cur) {
769
+ dt /= 2;
770
+ dr /= 2;
771
+ ds /= 2;
772
+ continue;
773
+ }
774
+ cur = best;
775
+ }
776
+ return cur;
777
+ }
778
+
779
+ /** Candidates that walked to one optimum, collapsed to the best of them. Input must be sorted. */
780
+ function dedupe(sorted: Candidate[], within: number, degrees: number, scaleRatio: number): Candidate[] {
781
+ const out: Candidate[] = [];
782
+ for (const cand of sorted) {
783
+ const same = out.some(
784
+ (o) =>
785
+ Math.hypot(o.cx - cand.cx, o.cy - cand.cy) <= within &&
786
+ Math.abs(normaliseDegrees(o.rotDeg - cand.rotDeg)) <= degrees &&
787
+ Math.abs(Math.log(o.scale / cand.scale)) <= Math.log(scaleRatio),
788
+ );
789
+ if (!same) out.push(cand);
790
+ }
791
+ return out;
792
+ }
793
+
794
+ // ---------------------------------------------------------------------------
795
+ // measuring the answer
796
+ // ---------------------------------------------------------------------------
797
+
798
+ /** The reported numbers, taken at full resolution over EVERY part pixel rather than a sample of them. */
799
+ function measure(
800
+ level: Level,
801
+ plate: Plate,
802
+ part: Plate,
803
+ anchorX: number,
804
+ anchorY: number,
805
+ cand: Candidate,
806
+ ): Omit<PosePlacement, 'x' | 'y' | 'rotationDeg' | 'scale'> {
807
+ const cos = Math.cos(cand.rotDeg * DEG) * cand.scale;
808
+ const sin = Math.sin(cand.rotDeg * DEG) * cand.scale;
809
+ let weight = 0;
810
+ let acc = 0;
811
+ let unexplained = 0;
812
+ let off = 0;
813
+ let onMaterial = 0;
814
+ let minX = Infinity;
815
+ let minY = Infinity;
816
+ let maxX = -Infinity;
817
+ let maxY = -Infinity;
818
+ for (let y = 0; y < part.height; y++) {
819
+ for (let x = 0; x < part.width; x++) {
820
+ const i = (y * part.width + x) * 4;
821
+ const a = part.data[i + 3];
822
+ if (a === 0) continue;
823
+ const w = a / 255;
824
+ const u = x + 0.5 - anchorX;
825
+ const v = y + 0.5 - anchorY;
826
+ const fx = cand.cx + u * cos - v * sin;
827
+ const fy = cand.cy + u * sin + v * cos;
828
+ weight += w;
829
+ if (fx < minX) minX = fx;
830
+ if (fx > maxX) maxX = fx;
831
+ if (fy < minY) minY = fy;
832
+ if (fy > maxY) maxY = fy;
833
+ const inside = fx >= 0 && fy >= 0 && fx < level.width && fy < level.height;
834
+ if (!inside) off += w;
835
+ const err = errBilinear(level, plate, fx, fy, part.data[i], part.data[i + 1], part.data[i + 2]);
836
+ acc += w * err;
837
+ if (err > UNEXPLAINED_TOLERANCE) unexplained += w;
838
+ if (inside) {
839
+ const ix = Math.min(level.width - 1, Math.floor(fx));
840
+ const iy = Math.min(level.height - 1, Math.floor(fy));
841
+ onMaterial += w * (level.data[(iy * level.width + ix) * 4 + 3] / 255);
842
+ }
843
+ }
844
+ }
845
+ if (weight === 0) {
846
+ return { residual: 1, unexplained: 1, offCanvas: 1, footprint: 0, bbox: { x: 0, y: 0, width: 0, height: 0 } };
847
+ }
848
+ return {
849
+ residual: acc / weight,
850
+ unexplained: unexplained / weight,
851
+ offCanvas: off / weight,
852
+ // One part pixel covers `scale²` frame pixels, so this is the frame area the
853
+ // placement actually accounts for — which is what separates two placements
854
+ // whose per-pixel residuals are the same.
855
+ footprint: onMaterial * cand.scale * cand.scale,
856
+ bbox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
857
+ };
858
+ }
859
+
860
+ function round(n: number, places: number): number {
861
+ const f = 10 ** places;
862
+ const v = Math.round(n * f) / f;
863
+ return v === 0 ? 0 : v;
864
+ }
865
+
866
+ /** Degrees into (-180, 180], the way an editor shows a rotation. */
867
+ function normaliseDegrees(deg: number): number {
868
+ let v = ((deg % 360) + 360) % 360;
869
+ if (v > 180) v -= 360;
870
+ return v;
871
+ }
872
+
873
+ /** A finished candidate, converted into the report's own frame of reference. */
874
+ function toPlacement(part: Plate, anchorX: number, anchorY: number, cand: Candidate, stats: ReturnType<typeof measure>): PosePlacement {
875
+ const cos = Math.cos(cand.rotDeg * DEG) * cand.scale;
876
+ const sin = Math.sin(cand.rotDeg * DEG) * cand.scale;
877
+ const ou = part.width / 2 - anchorX;
878
+ const ov = part.height / 2 - anchorY;
879
+ return {
880
+ x: round(cand.cx + ou * cos - ov * sin, 3),
881
+ y: round(cand.cy + ou * sin + ov * cos, 3),
882
+ rotationDeg: round(normaliseDegrees(cand.rotDeg), 3),
883
+ scale: round(cand.scale, 5),
884
+ residual: round(stats.residual, 5),
885
+ unexplained: round(stats.unexplained, 4),
886
+ offCanvas: round(stats.offCanvas, 4),
887
+ footprint: round(stats.footprint, 1),
888
+ bbox: {
889
+ x: round(stats.bbox.x, 2),
890
+ y: round(stats.bbox.y, 2),
891
+ width: round(stats.bbox.width, 2),
892
+ height: round(stats.bbox.height, 2),
893
+ },
894
+ };
895
+ }
896
+
897
+ /**
898
+ * Is this part self-similar under rotation — a ball rather than an arm?
899
+ *
900
+ * Measured with the same objective, against the part itself: rotate it about its
901
+ * own material centre and ask how much of it still lands on itself, in the same
902
+ * colours. A disc answers ~0 at every angle; anything with a corner or a pattern
903
+ * does not.
904
+ */
905
+ function rotationSelfSimilarity(part: Plate, anchorX: number, anchorY: number): number {
906
+ const level = levelOf(part, 1);
907
+ const samples = buildSamples(part, 1, anchorX, anchorY, POLISH_SAMPLES);
908
+ if (samples.count === 0) return 1;
909
+ const at = (deg: number): number =>
910
+ residualAt(level, part, samples, { cx: anchorX, cy: anchorY, rotDeg: deg, scale: 1, residual: 0 }, true);
911
+ // ⚠️ Measured against the IDENTITY, not against zero. A part with a soft rim
912
+ // scores above zero when laid over itself unrotated — every partly transparent
913
+ // pixel pays the `1 − material` term against its own partial alpha — so an
914
+ // absolute reading calls a perfectly round anti-aliased ball asymmetric. On the
915
+ // fixture's 32px ball that baseline is most of a 0.034 absolute reading, which
916
+ // sits the wrong side of a tolerance the shape plainly deserves to pass.
917
+ const baseline = at(0);
918
+ let worst = 0;
919
+ for (let deg = 30; deg < 360; deg += 30) {
920
+ const r = at(deg) - baseline;
921
+ if (r > worst) worst = r;
922
+ }
923
+ return worst;
924
+ }
925
+
926
+ // ---------------------------------------------------------------------------
927
+ // arguments
928
+ // ---------------------------------------------------------------------------
929
+
930
+ function scaleLadder(min: number, max: number): number[] {
931
+ if (max <= min) return [min];
932
+ const octaves = Math.log2(max / min);
933
+ const steps = Math.max(1, Math.round(octaves * SCALE_STEPS_PER_OCTAVE));
934
+ const out: number[] = [];
935
+ for (let i = 0; i <= steps; i++) out.push(min * (max / min) ** (i / steps));
936
+ return out;
937
+ }
938
+
939
+ function rotationLadder(minDeg: number, maxDeg: number): number[] {
940
+ const span = maxDeg - minDeg;
941
+ if (span <= 0) return [minDeg];
942
+ // A full turn's two endpoints are the same rotation, so it gets one of them.
943
+ if (span >= 360 - 1e-9) {
944
+ const count = Math.round(360 / COARSE_ROTATION_STEP);
945
+ const out: number[] = [];
946
+ for (let i = 0; i < count; i++) out.push(minDeg + (i * 360) / count);
947
+ return out;
948
+ }
949
+ const out: number[] = [];
950
+ for (let deg = minDeg; deg <= maxDeg + 1e-9; deg += COARSE_ROTATION_STEP) out.push(deg);
951
+ if (out[out.length - 1] < maxDeg - 1e-9) out.push(maxDeg);
952
+ return out;
953
+ }
954
+
955
+ /** The PNGs in a directory, in name order — the parts, and the order the report lists them. */
956
+ export function partFiles(imagesDir: string, exclude: string): string[] {
957
+ const dir = resolve(imagesDir);
958
+ if (!existsSync(dir)) throw new PoseError(`no parts directory at ${dir}`);
959
+ if (!statSync(dir).isDirectory()) throw new PoseError(`${dir} is not a directory — --images takes the directory the part PNGs are in`);
960
+ const excluded = resolve(exclude);
961
+ const files = readdirSync(dir)
962
+ .filter((f) => f.toLowerCase().endsWith('.png'))
963
+ .map((f) => join(dir, f))
964
+ .filter((f) => resolve(f) !== excluded)
965
+ .sort();
966
+ if (files.length === 0) throw new PoseError(`no .png files in ${dir} — there is nothing to place`);
967
+ return files;
968
+ }
969
+
970
+ // ---------------------------------------------------------------------------
971
+ // the instrument
972
+ // ---------------------------------------------------------------------------
973
+
974
+ export function estimatePose(options: PoseOptions): PoseReport {
975
+ const framePath = resolve(options.framePath);
976
+ if (!existsSync(framePath)) throw new PoseError(`no pose frame at ${framePath}`);
977
+ let frame: Plate;
978
+ try {
979
+ frame = readPlate(framePath);
980
+ } catch (err) {
981
+ throw new PoseError(`cannot read the pose frame ${framePath}: ${(err as Error).message}`);
982
+ }
983
+ const paths = partFiles(options.imagesDir, framePath);
984
+
985
+ const scaleMin = options.scale?.min ?? DEFAULT_SCALE_MIN;
986
+ const scaleMax = options.scale?.max ?? DEFAULT_SCALE_MAX;
987
+ const rotMin = options.rotation?.minDeg ?? -180;
988
+ const rotMax = options.rotation?.maxDeg ?? 180;
989
+ const maxResidual = options.maxResidual ?? DEFAULT_MAX_RESIDUAL;
990
+ const scales = scaleLadder(scaleMin, scaleMax);
991
+ const rotations = rotationLadder(rotMin, rotMax);
992
+
993
+ const background = readBackground(frame);
994
+ const material = materialPlate(frame, background);
995
+ background.materialShare = round(material.share, 4);
996
+ const framePyramid = pyramid(material.plate, 6, COARSE_LONG_SIDE);
997
+ const levels = framePyramid.map((p, i) => levelOf(p, 2 ** i));
998
+
999
+ const report: PoseReport = {
1000
+ spec: POSE_SPEC,
1001
+ space:
1002
+ 'frame pixels, y down, origin top-left. (x, y) is where the part image\'s own centre lands; rotationDeg is ' +
1003
+ 'screen degrees, positive clockwise; scale is frame pixels per part pixel. Reconstruct a part pixel p as ' +
1004
+ 'centre + scale * R(rotationDeg) * (p - (width/2, height/2)). src/transform.ts converts to Spine world ' +
1005
+ '(screenToSpineDegrees, cropToSpineY).',
1006
+ images: resolve(options.imagesDir),
1007
+ frame: { path: framePath, width: frame.width, height: frame.height, background },
1008
+ search: {
1009
+ scale: { min: scaleMin, max: scaleMax, steps: scales.length },
1010
+ rotation: { minDeg: rotMin, maxDeg: rotMax, stepDeg: COARSE_ROTATION_STEP, steps: rotations.length },
1011
+ coarse: {
1012
+ frameLongSide: COARSE_LONG_SIDE,
1013
+ partSpan: COARSE_PART_SPAN,
1014
+ strideFraction: COARSE_STRIDE_FRACTION,
1015
+ framePyramid: framePyramid.length,
1016
+ },
1017
+ maxResidual,
1018
+ ambiguity: { absolute: AMBIGUITY_ABSOLUTE, relative: AMBIGUITY_RELATIVE },
1019
+ },
1020
+ caveats: [
1021
+ 'No number here is a score and none of them has a pass bar. The residual says how well the placed part ' +
1022
+ 'explains the frame under it, which is a measure of how far to trust the placement.',
1023
+ 'Residuals degrade under OCCLUSION. A part drawn behind another has the occluder\'s pixels where its own ' +
1024
+ 'should be, so its residual rises at the correct placement; `unexplained` is the share of the part that ' +
1025
+ 'disagrees, and a middling residual with a high `unexplained` usually means "right place, seen through ' +
1026
+ 'something else" rather than "wrong place". Nothing here solves for depth.',
1027
+ 'An `ambiguous` part has two or more placements this instrument cannot separate. Both are reported. ' +
1028
+ 'Choosing between them needs something it cannot see — anatomy, the other frame, or a human.',
1029
+ 'A `rotationFree` part is self-similar under rotation, so its reported rotation is a placeholder and the ' +
1030
+ 'value is yours to choose.',
1031
+ 'The search is bounded to the scale and rotation windows named in `search`, with the part anchor placed ' +
1032
+ 'only inside the frame canvas. ⚠️ A window that does not contain the true value does NOT reliably ' +
1033
+ 'refuse: a part shrunk inside the region it came from still explains those pixels, so the answer is the ' +
1034
+ 'best placement available INSIDE the window and its residual can look reasonable. That is why the window ' +
1035
+ 'is a reported field — if the numbers surprise you, check it before you trust them.',
1036
+ ],
1037
+ parts: [],
1038
+ };
1039
+
1040
+ for (const path of paths) {
1041
+ report.parts.push(
1042
+ placePart(path, frame, levels, framePyramid, scales, rotations, maxResidual, { min: scaleMin, max: scaleMax }),
1043
+ );
1044
+ }
1045
+ return report;
1046
+ }
1047
+
1048
+ function placePart(
1049
+ path: string,
1050
+ frame: Plate,
1051
+ levels: Level[],
1052
+ plates: Plate[],
1053
+ scales: number[],
1054
+ rotations: number[],
1055
+ maxResidual: number,
1056
+ scaleBounds: { min: number; max: number },
1057
+ ): PosePart {
1058
+ const scaleMin = scaleBounds.min;
1059
+ /** The scale the sample sets are sized for — the middle of the window, and NOT the scale under test. */
1060
+ const scaleReference = Math.sqrt(scaleBounds.min * scaleBounds.max);
1061
+ const name = basename(path);
1062
+ let part: Plate;
1063
+ try {
1064
+ part = readPlate(path);
1065
+ } catch (err) {
1066
+ return {
1067
+ part: name,
1068
+ path,
1069
+ width: 0,
1070
+ height: 0,
1071
+ refusal: { reason: 'empty-part', detail: `cannot decode ${name}: ${(err as Error).message}` },
1072
+ placement: null,
1073
+ alternates: [],
1074
+ ambiguous: false,
1075
+ rotationFree: false,
1076
+ rotationSelfSimilarity: 1,
1077
+ coarse: null,
1078
+ notes: [`${name} could not be decoded, so it was not placed.`],
1079
+ };
1080
+ }
1081
+ const base: PosePart = {
1082
+ part: name,
1083
+ path,
1084
+ width: part.width,
1085
+ height: part.height,
1086
+ refusal: null,
1087
+ placement: null,
1088
+ alternates: [],
1089
+ ambiguous: false,
1090
+ rotationFree: false,
1091
+ rotationSelfSimilarity: 1,
1092
+ coarse: null,
1093
+ notes: [],
1094
+ };
1095
+
1096
+ const box = materialBox(part);
1097
+ if (box === null) {
1098
+ base.refusal = { reason: 'empty-part', detail: `${name} is ${part.width}x${part.height} and every pixel of it is transparent` };
1099
+ base.notes.push(`${name} has no material to place.`);
1100
+ return base;
1101
+ }
1102
+ const tw = box.maxX - box.minX;
1103
+ const th = box.maxY - box.minY;
1104
+ const fitsUpright = tw * scaleMin <= frame.width && th * scaleMin <= frame.height;
1105
+ const fitsTurned = th * scaleMin <= frame.width && tw * scaleMin <= frame.height;
1106
+ if (!fitsUpright && !fitsTurned) {
1107
+ base.refusal = {
1108
+ reason: 'larger-than-canvas',
1109
+ detail:
1110
+ `${name}'s material is ${tw}x${th} part px; at the smallest tested scale ${scaleMin} that is ` +
1111
+ `${round(tw * scaleMin, 1)}x${round(th * scaleMin, 1)} frame px, which does not fit a ` +
1112
+ `${frame.width}x${frame.height} canvas at any rotation`,
1113
+ };
1114
+ base.notes.push(`${name} cannot be contained by this frame at any tested scale — lower --scale or check the pair.`);
1115
+ return base;
1116
+ }
1117
+
1118
+ /** Longest side of the part's material, in part pixels — the yardstick "near" is measured in. */
1119
+ const span = Math.max(tw, th);
1120
+ const anchorX = (box.minX + box.maxX) / 2;
1121
+ const anchorY = (box.minY + box.maxY) / 2;
1122
+
1123
+ // Rotation freedom is settled before the search, because a part it applies to
1124
+ // does not need a rotation ladder at all — and searching one would invent a
1125
+ // precise-looking angle for a quantity that has none.
1126
+ const selfSimilarity = rotationSelfSimilarity(part, anchorX, anchorY);
1127
+ const rotationFree = selfSimilarity <= ROTATION_FREE_TOLERANCE;
1128
+ base.rotationFree = rotationFree;
1129
+ base.rotationSelfSimilarity = round(selfSimilarity, 5);
1130
+ const searchRotations = rotationFree ? [0] : rotations;
1131
+ if (rotationFree) {
1132
+ base.notes.push(
1133
+ `${name} is self-similar under rotation (worst self-residual ${round(selfSimilarity, 4)} over 11 probes, ` +
1134
+ `tolerance ${ROTATION_FREE_TOLERANCE}), so rotation is a free degree of freedom — the reported 0° is a ` +
1135
+ 'placeholder, not a measurement.',
1136
+ );
1137
+ }
1138
+
1139
+ const partPyramid = pyramid(part, 6, 4);
1140
+ const samplesCache = new Map<string, Samples>();
1141
+ /**
1142
+ * The part's sample set for one search level.
1143
+ *
1144
+ * 🚨 The mip is chosen from the level and the MIDDLE of the scale window, never
1145
+ * from the scale being tried — and that is the whole reason scale is
1146
+ * identifiable at all. Sizing the sample set to each candidate scale looks
1147
+ * obviously right (sample the part as finely as the frame can resolve it) and
1148
+ * collapses the search: a small scale then gets a coarse, few-pixel mip whose
1149
+ * every sample is an average of a large patch, those samples land deep inside
1150
+ * the blob, and the residual goes to nothing. Measured on the fixture: a 22px
1151
+ * head found its optimum at scale 0.39 with residual 0.065, against 0.017 at
1152
+ * the true 1.15 — the search preferred a placement the objective itself scores
1153
+ * worse, because the two were not scored on the same pixels. One sample set per
1154
+ * level puts every candidate scale on the same material, and then a scale that
1155
+ * squeezes six samples into three frame pixels has to explain why they disagree.
1156
+ */
1157
+ const samplesFor = (levelReduction: number, cap: number): Samples => {
1158
+ const wanted = Math.max(0, Math.round(Math.log2(Math.max(1e-6, levelReduction / scaleReference))));
1159
+ const mip = Math.min(partPyramid.length - 1, wanted);
1160
+ const key = `${mip}:${cap}`;
1161
+ const hit = samplesCache.get(key);
1162
+ if (hit) return hit;
1163
+ const built = buildSamples(partPyramid[mip], 2 ** mip, anchorX, anchorY, cap);
1164
+ samplesCache.set(key, built);
1165
+ return built;
1166
+ };
1167
+
1168
+ // ⭐ Which level the exhaustive pass runs at is a decision about the PART, not
1169
+ // only about the frame. The pyramid stops when the frame fits in
1170
+ // COARSE_LONG_SIDE; this then walks back UP it until the part still spans
1171
+ // COARSE_PART_SPAN pixels there, because a level that has reduced the part to
1172
+ // three pixels cannot say where the part is at any price.
1173
+ let coarseIndex = levels.length - 1;
1174
+ while (coarseIndex > 0 && (span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN) coarseIndex--;
1175
+ const coarse = levels[coarseIndex];
1176
+ const spanAtCoarse = (span * scaleReference) / coarse.reduction;
1177
+ const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
1178
+ const coarseSamples = samplesFor(coarse.reduction, COARSE_SAMPLES);
1179
+ let candidates: Candidate[] = [];
1180
+ let grid = { reduction: coarse.reduction, cols: 0, rows: 0, stride };
1181
+ for (const scale of scales) {
1182
+ const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
1183
+ grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
1184
+ candidates.push(...localMinima(field, 2, MINIMA_PER_SCALE));
1185
+ }
1186
+ base.coarse = grid;
1187
+ candidates.sort((a, b) => a.residual - b.residual);
1188
+ if (candidates.length === 0) {
1189
+ base.refusal = { reason: 'no-match', detail: `${name}: the coarse scan found no finite placement in this frame` };
1190
+ base.notes.push(`${name} matched nowhere in this frame.`);
1191
+ return base;
1192
+ }
1193
+
1194
+ // Refine down the pyramid. Every level doubles the coordinates and halves the
1195
+ // steps; the candidate list narrows as it goes so the budget follows the
1196
+ // placements that are still plausible.
1197
+ //
1198
+ // 🚨 One level RE-GRIDS rotation instead of narrowing, for the same reason the
1199
+ // coarse fields are kept apart by scale: a blurred blob does not have a
1200
+ // measurable angle either, and the field keeps only one rotation per cell. On
1201
+ // the fixture the right arm was found at exactly the right PLACE carrying
1202
+ // rotation -122 degrees, which no 7.5 degree local step could ever leave — and
1203
+ // that arm is one half of the two-identical-limbs answer the whole instrument
1204
+ // exists to report. Re-gridding the ladder at the first level with real detail
1205
+ // brought it back at +35.
1206
+ const branchLevel = Math.max(0, coarseIndex - 1);
1207
+ /** How many rotations survive the re-grid at the branch level, per position. */
1208
+ const BRANCH_ROTATIONS = 3;
1209
+ let rotStep = rotationFree ? 0 : COARSE_ROTATION_STEP / 2;
1210
+ for (let li = coarseIndex; li >= 0; li--) {
1211
+ const level = levels[li];
1212
+ const plate = plates[li];
1213
+ const smooth = li !== coarseIndex;
1214
+ const branch = li === branchLevel;
1215
+ const keep = li === coarseIndex ? scales.length * MINIMA_PER_SCALE : REFINE_CANDIDATES;
1216
+ const s = samplesFor(level.reduction, li === 0 ? POLISH_SAMPLES : REFINE_SAMPLES);
1217
+ // The coarse pass only sampled every `stride` pixels, so entering the
1218
+ // refinement the anchor can be half a stride out; the first polish gets a
1219
+ // step big enough to cross that rather than a step that assumes a pixel.
1220
+ const step = { translate: li === coarseIndex ? Math.max(1.5, stride) : 1.5, rotate: rotStep, scale: 0.08 };
1221
+ const floor =
1222
+ li === 0 ? { translate: 0.05, rotate: 0.1, scale: 0.001 } : { translate: 0.25, rotate: 0.5, scale: 0.01 };
1223
+ const seeds: Candidate[] = [];
1224
+ for (const start of candidates.slice(0, keep)) {
1225
+ if (branch && !rotationFree) {
1226
+ const grid: Candidate[] = [];
1227
+ for (const rotDeg of searchRotations) {
1228
+ const probe: Candidate = { ...start, rotDeg, residual: 0 };
1229
+ probe.residual = residualAt(level, plate, s, probe, smooth);
1230
+ grid.push(probe);
1231
+ }
1232
+ grid.sort((a, b) => a.residual - b.residual);
1233
+ // One position and one scale throughout, so this dedupe is a spread over
1234
+ // rotation alone: an angle within 25 degrees of one already kept is the
1235
+ // same basin under a slightly different name.
1236
+ seeds.push(...dedupe(grid, 1, 25, Infinity).slice(0, BRANCH_ROTATIONS));
1237
+ continue;
1238
+ }
1239
+ seeds.push(start);
1240
+ }
1241
+ candidates = seeds.map((seed) => polish(level, plate, s, seed, step, floor, smooth, scaleBounds));
1242
+ candidates.sort((a, b) => a.residual - b.residual);
1243
+ // ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
1244
+ // and the radius has to scale with the PART rather than be a pixel count.
1245
+ // Narrowing by residual alone lets near-copies of the best hill fill the
1246
+ // budget and crowd the SECOND hill out, which is exactly the answer this
1247
+ // instrument exists to keep: with a fixed one-pixel radius the fixture lost
1248
+ // one of its two identical arms.
1249
+ candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
1250
+ if (li > 0) {
1251
+ candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
1252
+ // A rotation-free part keeps its step at zero all the way down, so the
1253
+ // polish never touches an angle that means nothing and the report's 0° is
1254
+ // the placeholder it says it is rather than a wandered-to number.
1255
+ if (!rotationFree) rotStep = Math.max(1, rotStep / 2);
1256
+ }
1257
+ }
1258
+
1259
+ // The one rotation family the translation scan cannot see: a part that is its
1260
+ // own mirror after a quarter or a half turn sits in the SAME place at more than
1261
+ // one angle, so the field records only whichever won. Probe them explicitly.
1262
+ if (!rotationFree && candidates.length > 0) {
1263
+ const primary = candidates[0];
1264
+ const s = samplesFor(1, POLISH_SAMPLES);
1265
+ for (const turn of [90, 180, 270]) {
1266
+ candidates.push(
1267
+ polish(
1268
+ levels[0],
1269
+ plates[0],
1270
+ s,
1271
+ { ...primary, rotDeg: primary.rotDeg + turn },
1272
+ { translate: 1.5, rotate: 4, scale: 0.04 },
1273
+ { translate: 0.05, rotate: 0.1, scale: 0.001 },
1274
+ true,
1275
+ scaleBounds,
1276
+ ),
1277
+ );
1278
+ }
1279
+ candidates.sort((a, b) => a.residual - b.residual);
1280
+ }
1281
+
1282
+ // Measured at full resolution over every pixel, then de-duplicated: two
1283
+ // candidates that walked to the same optimum are one answer, not two.
1284
+ const measured = candidates.map((cand) => ({ cand, placement: toPlacement(part, anchorX, anchorY, cand, measure(levels[0], plates[0], part, anchorX, anchorY, cand)) }));
1285
+ measured.sort((a, b) => a.placement.residual - b.placement.residual || b.placement.footprint - a.placement.footprint);
1286
+ const distinct: typeof measured = [];
1287
+ for (const m of measured) {
1288
+ const same = distinct.some(
1289
+ (d) =>
1290
+ Math.hypot(d.placement.x - m.placement.x, d.placement.y - m.placement.y) <= Math.max(1.5, 0.03 * span * m.placement.scale) &&
1291
+ Math.abs(normaliseDegrees(d.placement.rotationDeg - m.placement.rotationDeg)) <= 5 &&
1292
+ Math.abs(Math.log(d.placement.scale / m.placement.scale)) <= Math.log(1.05),
1293
+ );
1294
+ if (!same) distinct.push(m);
1295
+ }
1296
+
1297
+ const best = distinct[0].placement;
1298
+ const margin = Math.max(AMBIGUITY_ABSOLUTE, best.residual * AMBIGUITY_RELATIVE);
1299
+ const close = distinct.slice(1).filter((d) => d.placement.residual - best.residual <= margin);
1300
+ base.placement = best;
1301
+ base.alternates = close.slice(0, MAX_ALTERNATES).map((d) => d.placement);
1302
+ base.ambiguous = close.length > 0;
1303
+ if (base.ambiguous) {
1304
+ base.notes.push(
1305
+ `${name} has ${close.length + 1} placements within ${round(margin, 4)} residual of each other — all of them ` +
1306
+ 'are reported and none was picked. Two identical limbs look exactly like this; so does a part that fits ' +
1307
+ 'its own silhouette at more than one angle.',
1308
+ );
1309
+ }
1310
+ if (best.residual > maxResidual) {
1311
+ base.refusal = {
1312
+ reason: 'no-match',
1313
+ detail: `${name}: the best placement found has residual ${best.residual.toFixed(4)}, above --max-residual ${maxResidual}`,
1314
+ };
1315
+ base.notes.push(
1316
+ `${name} matches nowhere in this frame well enough to report. The best placement found is still in ` +
1317
+ '`placement` — a refusal names why not to trust it, it does not hide it.',
1318
+ );
1319
+ }
1320
+ if (best.unexplained > 0.25 && best.residual <= maxResidual) {
1321
+ base.notes.push(
1322
+ `${round(best.unexplained * 100, 1)}% of ${name}'s material disagrees with the frame at this placement. ` +
1323
+ 'Another part drawn over it is the usual reason; the placement can be right and the residual still high.',
1324
+ );
1325
+ }
1326
+ if (best.offCanvas > 0.01) {
1327
+ base.notes.push(`${round(best.offCanvas * 100, 1)}% of ${name}'s material falls outside the frame canvas at this placement.`);
1328
+ }
1329
+ return base;
1330
+ }
1331
+
1332
+ // ---------------------------------------------------------------------------
1333
+ // the console report
1334
+ // ---------------------------------------------------------------------------
1335
+
1336
+ function placementLine(p: PosePlacement): string {
1337
+ return (
1338
+ `x=${p.x.toFixed(1).padStart(7)} y=${p.y.toFixed(1).padStart(7)} rot=${p.rotationDeg.toFixed(1).padStart(7)}° ` +
1339
+ `scale=${p.scale.toFixed(3)} residual=${p.residual.toFixed(4)} unexplained=${(p.unexplained * 100).toFixed(0).padStart(3)}%`
1340
+ );
1341
+ }
1342
+
1343
+ export function poseLines(report: PoseReport): string[] {
1344
+ const bg = report.frame.background;
1345
+ const bgText =
1346
+ bg.kind === 'colour' && bg.colour !== null
1347
+ ? `rgb(${bg.colour.join(', ')}) over ${(bg.borderShare * 100).toFixed(0)}% of the border ring`
1348
+ : bg.kind === 'transparent'
1349
+ ? `transparency over ${(bg.borderShare * 100).toFixed(0)}% of the border ring`
1350
+ : 'UNKNOWN — the border ring has no dominant colour, so every pixel counts as material and the silhouette ' +
1351
+ 'signal is gone; residuals here are colour agreement only';
1352
+ const lines = [
1353
+ ` .. frame ${report.frame.path} (${report.frame.width}x${report.frame.height})`,
1354
+ ` .. ground ${bgText}`,
1355
+ ` .. parts ${report.images} (${report.parts.length} png)`,
1356
+ ` .. search scale ${report.search.scale.min}–${report.search.scale.max} in ${report.search.scale.steps} step(s) · ` +
1357
+ `rotation ${report.search.rotation.minDeg}°–${report.search.rotation.maxDeg}° step ${report.search.rotation.stepDeg}° · ` +
1358
+ `refuse above residual ${report.search.maxResidual}`,
1359
+ ];
1360
+ const width = Math.max(8, ...report.parts.map((p) => p.part.length));
1361
+ for (const part of report.parts) {
1362
+ const label = part.part.padEnd(width);
1363
+ if (part.placement === null) {
1364
+ lines.push(` REFUSE ${label} ${part.refusal?.reason ?? 'unplaced'}: ${part.refusal?.detail ?? ''}`);
1365
+ continue;
1366
+ }
1367
+ const tag = part.refusal !== null ? 'REFUSE' : part.ambiguous ? 'AMBIG ' : 'PLACE ';
1368
+ lines.push(` ${tag} ${label} ${placementLine(part.placement)}`);
1369
+ if (part.coarse !== null) {
1370
+ lines.push(
1371
+ ` ${' '.repeat(width)} found on a ${part.coarse.cols}x${part.coarse.rows} anchor grid, ` +
1372
+ `step ${part.coarse.stride} at ${part.coarse.reduction}x reduction`,
1373
+ );
1374
+ }
1375
+ if (part.rotationFree) lines.push(` ${' '.repeat(width)} rotation is a FREE degree of freedom — the 0° above is a placeholder`);
1376
+ part.alternates.forEach((alt, i) => {
1377
+ lines.push(` ${' '.repeat(width)} alt ${i + 2}: ${placementLine(alt)}`);
1378
+ });
1379
+ if (part.refusal !== null) lines.push(` ${' '.repeat(width)} ${part.refusal.reason}: ${part.refusal.detail}`);
1380
+ }
1381
+ lines.push('');
1382
+ lines.push(' .. residuals are a trust signal, not a score — nothing here has a pass bar.');
1383
+ lines.push(' .. they degrade under occlusion: a high `unexplained` on a plausible placement usually means');
1384
+ lines.push(' .. the part is drawn behind something, not that it is in the wrong place.');
1385
+ return lines;
1386
+ }