@rig-c/pose 0.7.0 → 0.8.1

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/compare.ts ADDED
@@ -0,0 +1,1046 @@
1
+ /**
2
+ * `rig-pose compare` (rig-c/pose#38): the reference (the source video's
3
+ * keypoints) against the rig's render (the same detector run on rigc's render
4
+ * of the retargeted motion), frame for frame on one time base, reported per
5
+ * body unit: head, arms, body, legs, cloth.
6
+ *
7
+ * Pure: it reads parsed inputs and returns findings; the files are read and
8
+ * written by `runcompare.ts`. The bars below were fixed in the issue before any
9
+ * render-side keypoints existed, and they are grading lines (rig-c/exam): a
10
+ * change to one is a new comment on the issue first, then a change here and in
11
+ * the README together.
12
+ *
13
+ * A finding is a run of frames over a measure's bar lasting ≥ 0.15 s (from the
14
+ * first frame's time to the end of the last frame's, as `endOn`), spans merged
15
+ * across a gap of one frame; findings are listed worst first within a measure.
16
+ */
17
+ import { isBody18, VIRTUAL, type Body18 } from './joints.ts';
18
+ import type { JointMap } from './map.ts';
19
+ import type { Joint } from './pose.ts';
20
+ import { isObject, parseJson, Problems, Refusal, refuseUnknownKeys, typeName } from './problems.ts';
21
+ import { pictureAngle, wrap } from './retarget.ts';
22
+ import { runsOf } from './switch.ts';
23
+
24
+ export const COMPARE_SPEC = 'rig-pose-compare/1';
25
+ /** Seconds: two frames are one time base when their times differ by at most this. */
26
+ export const TIME_TOLERANCE = 0.001;
27
+ /** A run over a bar lasting at least this long, in seconds, is a finding. */
28
+ export const FINDING_SECONDS = 0.15;
29
+ /** Runs over a bar this many frames apart or fewer merge into one. */
30
+ export const FINDING_GAP = 1;
31
+
32
+ /** The bars, fixed in rig-c/pose#38 before the dancer was measured. */
33
+ export const BARS = {
34
+ /** Degrees: |direction difference| of a mapped arm segment over which it is a different arm. */
35
+ armDirection: 20,
36
+ /** Degrees: the same for a mapped leg segment. */
37
+ legDirection: 20,
38
+ /** Degrees: |eye-line tilt difference|. */
39
+ headTilt: 15,
40
+ /** Mean hand-point (or wrist) score: the reference at or above this sees the hand… */
41
+ handSeen: 0.6,
42
+ /** …while the render under this does not. */
43
+ handHidden: 0.4,
44
+ /** Hip widths per frame: the render's ankle moving faster than this… */
45
+ footMoving: 0.05,
46
+ /** …while the reference's is slower than this, is a foot sliding. */
47
+ footStill: 0.02,
48
+ /** The render's range of torso length (or hip height) under this fraction of the reference's: no bounce. */
49
+ bounce: 0.5,
50
+ /** Cloth (#34's bands): lag within the reference's ± this many frames. */
51
+ clothLag: 1.0,
52
+ /** Cloth: swing ratio within the reference's ± this fraction of it. */
53
+ clothSwing: 0.25,
54
+ /** Cloth: overshoot within the reference's ± this, absolute, floored at 0. */
55
+ clothOvershoot: 0.1,
56
+ } as const;
57
+
58
+ /** #34's measure of a skirt's swing (HQ's CRITERIA.md and its `tools/metrics.py`), as ported here. */
59
+ export const CLOTH = {
60
+ /** Gaussian smoothing before differentiating: σ in frames, the kernel's half-width in frames. */
61
+ sigma: 1,
62
+ kernel: 4,
63
+ /** The lag search, frames: hem after hip is positive. */
64
+ lagFrom: -6,
65
+ lagTo: 12,
66
+ /** A hip stroke counts from this many reference hip widths (CRITERIA Revision 1: one physical size). */
67
+ stroke: 0.2,
68
+ /** Frames past the last reversal the overshoot looks at, when there is no next one. */
69
+ carryLookahead: 12,
70
+ } as const;
71
+
72
+ export const UNITS = ['head', 'arms', 'body', 'legs', 'cloth'] as const;
73
+ export type Unit = (typeof UNITS)[number];
74
+ export type Measure = 'tilt' | 'direction' | 'visibility' | 'torsoLength' | 'hipTravel' | 'footPlant' | 'lag' | 'swingRatio' | 'overshoot';
75
+ /** What a finding suggests fixing: the rig, the motion, a new drawing, the depth order (#33). Null when not derivable. */
76
+ export type FixClass = 'rig' | 'motion' | 'paint' | 'depth';
77
+
78
+ type Pt = [number, number];
79
+ export type Frames = ReadonlyArray<Partial<Record<Body18, Joint>>>;
80
+
81
+ /** One side of the comparison: its joints per frame, the frame times, and what it is called in a message. */
82
+ export interface Side {
83
+ where: string;
84
+ times: readonly number[];
85
+ frames: Frames;
86
+ }
87
+
88
+ /** A set's span from the retarget's report: the frames it shows on and the chains it depicts. */
89
+ export interface Span {
90
+ set: string;
91
+ chains: string[];
92
+ frames: [number, number];
93
+ }
94
+
95
+ /** Per frame, the mean score of each hand's 21 points; null where the file has nobody. */
96
+ export interface HandScores {
97
+ where: string;
98
+ times: number[];
99
+ r: Array<number | null>;
100
+ l: Array<number | null>;
101
+ }
102
+
103
+ /** A cloth track as read: per frame the hips' midpoint x and the hem's midpoint x, in the subject's own median hip widths. */
104
+ export interface ClothTrack {
105
+ where: string;
106
+ /** What the median hip width was measured in, and its value. */
107
+ unit: 'picture px' | 'rig world units';
108
+ hipWidth: number;
109
+ times: number[];
110
+ hip: number[];
111
+ hem: number[];
112
+ }
113
+ export interface ClothWindow {
114
+ name: string;
115
+ from: number;
116
+ to: number;
117
+ }
118
+
119
+ export interface CompareInput {
120
+ reference: Side;
121
+ render: Side;
122
+ map: JointMap;
123
+ /** The retarget's report's spans, or null when no --retarget-report was given. */
124
+ spans: Span[] | null;
125
+ /** The retarget's scale, rig units per reference picture px (its report's `scale.factor`), or null. */
126
+ retargetScale: number | null;
127
+ hands: { reference: HandScores; render: HandScores } | null;
128
+ cloth: { reference: ClothTrack; render: ClothTrack; windows: ClothWindow[] } | null;
129
+ }
130
+
131
+ export interface Worst {
132
+ frame: number;
133
+ t: number;
134
+ /** The figure the finding is ranked by (see `figureUnit`). */
135
+ figure: number;
136
+ /** The reference's and the render's own reading on that frame (angle, score, speed, range), when the figure is a difference. */
137
+ reference: number | null;
138
+ render: number | null;
139
+ }
140
+
141
+ export interface Finding {
142
+ unit: Unit;
143
+ measure: Measure;
144
+ /** What was measured: `arm_r/sleeve_r1`, `hand_r`, `foot_l`, `eye line`, `torso`, `hip`, a cloth window. */
145
+ subject: string;
146
+ /** The points it was read from (body-18, `mid_hip`, `mid_shoulder`, or the hand's points). */
147
+ points: string[];
148
+ chain: string | null;
149
+ frames: [number, number];
150
+ seconds: number;
151
+ /** Frames of the run over the bar (a merged gap is not). */
152
+ over: number;
153
+ worst: Worst;
154
+ figureUnit: 'deg' | 'score gap' | 'hip widths per frame' | 'range ratio' | 'frames' | 'ratio' | 'fraction of the hip stroke';
155
+ bar: string;
156
+ fix: FixClass | null;
157
+ /** Why that class, or why none. */
158
+ fixWhy: string;
159
+ }
160
+
161
+ export interface MeasureResult {
162
+ measure: Measure;
163
+ /** What the summary line calls it. */
164
+ label: string;
165
+ subjects: string[];
166
+ /** Frames over the bar, summed over its subjects, in runs of any length. */
167
+ overBar: number;
168
+ findings: Finding[];
169
+ /** Why it was not measured, or null. */
170
+ notMeasured: string | null;
171
+ /** Its bar, as the summary line states it. */
172
+ bar: string;
173
+ /** Where its figures came from, when that is a choice the run made (hand points or wrists). */
174
+ source: string | null;
175
+ /** The body unit's ranges, per subject. */
176
+ ranges?: Record<string, { reference: number; render: number }>;
177
+ }
178
+
179
+ export interface ClothFigures {
180
+ lag: number;
181
+ xcorrPeak: number;
182
+ swingRatio: number | null;
183
+ overshoot: number | null;
184
+ strokes: number;
185
+ }
186
+ export interface ClothWindowResult {
187
+ name: string;
188
+ t: [number, number];
189
+ frames: [number, number];
190
+ reference: ClothFigures;
191
+ render: ClothFigures;
192
+ band: { lag: [number, number]; swingRatio: [number, number] | null; overshoot: [number, number] | null };
193
+ }
194
+
195
+ export interface UnitResult {
196
+ unit: Unit;
197
+ measures: MeasureResult[];
198
+ /** Why the whole unit was not measured, or null. */
199
+ notMeasured: string | null;
200
+ }
201
+
202
+ export interface Comparison {
203
+ frames: number;
204
+ duration: number;
205
+ times: number[];
206
+ /** Median |r_hip − l_hip| per side, picture px: the unit positions are compared in. */
207
+ hipWidth: { reference: number | null; render: number | null };
208
+ /** Median |mid_shoulder − mid_hip| per side, picture px. */
209
+ torso: { reference: number | null; render: number | null };
210
+ units: UnitResult[];
211
+ /** Every finding, units in order, each measure's worst first. */
212
+ findings: Finding[];
213
+ /** Per window both sides' figures and the bands; the stroke threshold on each side (hip widths) and how the render's was carried. */
214
+ cloth: { windows: ClothWindowResult[]; stroke: { reference: number; render: number; carried: string } } | null;
215
+ }
216
+
217
+ // --- readers ---------------------------------------------------------------
218
+
219
+ export const HANDS_SPEC = 'coco-wholebody-133';
220
+ /** COCO-WholeBody's hand points: the subject's left hand 91–111, the right 112–132. */
221
+ export const HAND_POINTS = { l: [91, 111], r: [112, 132] } as const;
222
+
223
+ /**
224
+ * A COCO-WholeBody keypoint file, as HQ's extraction writes it:
225
+ * `{ spec?: "coco-wholebody-133", fps?, width?, height?, source?, frames: [{ t, points: [[x, y, score] × 133] | null }] }`.
226
+ * Only the hands' scores are read.
227
+ */
228
+ export function readHands(text: string, where: string): HandScores {
229
+ const doc = parseJson(text, where, 'COMPARE_HANDS_JSON');
230
+ const p = new Problems();
231
+ if (!isObject(doc)) {
232
+ p.add('COMPARE_HANDS', `${where} is ${typeName(doc)}; a hands file is an object { frames: [{ t, points }] }`);
233
+ p.throwIfAny();
234
+ }
235
+ const d = doc as Record<string, unknown>;
236
+ refuseUnknownKeys(p, 'COMPARE_HANDS', where, d, ['spec', 'fps', 'width', 'height', 'source', 'frames']);
237
+ if ('spec' in d && d.spec !== HANDS_SPEC) p.add('COMPARE_HANDS', `${where} "spec" is ${typeName(d.spec)}; this reader reads "${HANDS_SPEC}" (133 points, hands at 91–132)`);
238
+ const out: HandScores = { where, times: [], r: [], l: [] };
239
+ if (!Array.isArray(d.frames) || d.frames.length === 0) {
240
+ p.add('COMPARE_HANDS', `${where} "frames" is ${typeName(d.frames)}; it must be a non-empty array of { t, points }`);
241
+ } else {
242
+ d.frames.forEach((f: unknown, i: number) => {
243
+ const fw = `${where} frames[${i}]`;
244
+ if (!isObject(f) || typeof f.t !== 'number' || !Number.isFinite(f.t)) {
245
+ p.add('COMPARE_HANDS', `${fw} is ${isObject(f) ? `{ t: ${typeName(f.t)} }` : typeName(f)}; a frame is { t: seconds, points }`);
246
+ return;
247
+ }
248
+ refuseUnknownKeys(p, 'COMPARE_HANDS', fw, f, ['t', 'points']);
249
+ out.times.push(f.t);
250
+ if (f.points === null) {
251
+ out.r.push(null);
252
+ out.l.push(null);
253
+ return;
254
+ }
255
+ const pts = f.points;
256
+ if (!Array.isArray(pts) || pts.length !== 133 || !pts.every((q) => Array.isArray(q) && q.length === 3 && q.every((v) => typeof v === 'number' && Number.isFinite(v)))) {
257
+ p.add('COMPARE_HANDS', `${fw} "points" is ${typeName(pts)}${Array.isArray(pts) ? ` of ${pts.length}` : ''}; it must be 133 [x, y, score] triples (COCO-WholeBody), or null`);
258
+ out.r.push(null);
259
+ out.l.push(null);
260
+ return;
261
+ }
262
+ const mean = ([a, b]: readonly [number, number]) => {
263
+ let s = 0;
264
+ for (let k = a; k <= b; k++) s += (pts[k] as number[])[2]!;
265
+ return s / (b - a + 1);
266
+ };
267
+ out.r.push(mean(HAND_POINTS.r));
268
+ out.l.push(mean(HAND_POINTS.l));
269
+ });
270
+ }
271
+ p.throwIfAny();
272
+ return out;
273
+ }
274
+
275
+ export const CLOTH_SPEC = 'rig-c-cloth-track/0';
276
+ const CLOTH_TOP = ['spec', 'subject', 'method', 'source', 'fps', 'frames_count', 'units', 'hipWidth', 'origin', 'axes', 'skirtSwing', 'frames'];
277
+ const CLOTH_FRAME = ['i', 't', 'hip_x', 'hip_y', 'hip_w', 'tilt_deg', 'hem_mid', 'hem_l', 'hem_r', 'hem_l_y', 'hem_r_y', 'hem_band_cx', 'skirt_cx', 'skirt_top', 'skirt_bottom'];
278
+ /** The units a track's median hip width may be stated in. */
279
+ export const CLOTH_UNITS = ['picture px', 'rig world units'] as const;
280
+
281
+ /**
282
+ * A cloth track, HQ's `rig-c-cloth-track/0` (#34's skirt measure): per frame `{ i, t, hip_x, hip_y, hip_w, tilt_deg,
283
+ * hem_mid, hem_l, hem_r, … }`, every position already divided by the subject's own median hip width, origin at its
284
+ * median mid-hip, x right, y down; `hipWidth: { median, unit }` says what that width was. Only `t`, `hip_x` (the
285
+ * hips' midpoint) and `hem_mid` (the midpoint of the hem's leftmost and rightmost points, #34's hem) are read; the
286
+ * other keys are known and checked to be numbers, and an unknown key is refused.
287
+ */
288
+ export function readCloth(text: string, where: string): ClothTrack {
289
+ const doc = parseJson(text, where, 'COMPARE_CLOTH_JSON');
290
+ const p = new Problems();
291
+ if (!isObject(doc)) {
292
+ p.add('COMPARE_CLOTH', `${where} is ${typeName(doc)}; a cloth track (${CLOTH_SPEC}) is an object`);
293
+ p.throwIfAny();
294
+ }
295
+ const d = doc as Record<string, unknown>;
296
+ refuseUnknownKeys(p, 'COMPARE_CLOTH', where, d, CLOTH_TOP);
297
+ if (d.spec !== CLOTH_SPEC) p.add('COMPARE_CLOTH', `${where} "spec" is ${typeName(d.spec)}; --cloth-reference and --cloth-render read "${CLOTH_SPEC}"`);
298
+ let hipWidth = NaN;
299
+ let unit: ClothTrack['unit'] = 'picture px';
300
+ const hw = d.hipWidth;
301
+ if (!isObject(hw) || typeof hw.median !== 'number' || !(hw.median > 0) || !Number.isFinite(hw.median) || typeof hw.unit !== 'string' || !(CLOTH_UNITS as readonly string[]).includes(hw.unit)) {
302
+ p.add('COMPARE_CLOTH', `${where} "hipWidth" is ${JSON.stringify(hw) ?? typeName(hw)}; it must be { median: > 0, unit: ${CLOTH_UNITS.map((u) => `"${u}"`).join(' or ')} }`);
303
+ } else {
304
+ hipWidth = hw.median;
305
+ unit = hw.unit as ClothTrack['unit'];
306
+ }
307
+ const out: ClothTrack = { where, unit, hipWidth, times: [], hip: [], hem: [] };
308
+ if (!Array.isArray(d.frames) || d.frames.length === 0) {
309
+ p.add('COMPARE_CLOTH', `${where} "frames" is ${typeName(d.frames)}; it must be a non-empty array of { i, t, hip_x, hem_mid, … }`);
310
+ } else {
311
+ if ('frames_count' in d && d.frames_count !== d.frames.length) p.add('COMPARE_CLOTH', `${where} "frames_count" is ${typeName(d.frames_count)} and "frames" holds ${d.frames.length}`);
312
+ d.frames.forEach((f: unknown, i: number) => {
313
+ const fw = `${where} frames[${i}]`;
314
+ if (!isObject(f)) {
315
+ p.add('COMPARE_CLOTH', `${fw} is ${typeName(f)}; a frame is { i, t, hip_x, hem_mid, … }`);
316
+ return;
317
+ }
318
+ refuseUnknownKeys(p, 'COMPARE_CLOTH', fw, f, CLOTH_FRAME);
319
+ const bad: string[] = [];
320
+ for (const k of ['t', 'hip_x', 'hem_mid']) if (typeof f[k] !== 'number' || !Number.isFinite(f[k])) bad.push(`"${k}" is ${typeName(f[k])}`);
321
+ for (const k of CLOTH_FRAME) if (k in f && !['t', 'hip_x', 'hem_mid'].includes(k) && (typeof f[k] !== 'number' || !Number.isFinite(f[k]))) bad.push(`"${k}" is ${typeName(f[k])}`);
322
+ if (bad.length > 0) {
323
+ p.add('COMPARE_CLOTH', `${fw}: ${bad.join(', ')}; every value is a finite number (t in seconds, positions in hip widths)`);
324
+ return;
325
+ }
326
+ out.times.push(f.t as number);
327
+ out.hip.push(f.hip_x as number);
328
+ out.hem.push(f.hem_mid as number);
329
+ });
330
+ }
331
+ p.throwIfAny();
332
+ return out;
333
+ }
334
+
335
+ /** The spans of a `rig-pose-report/1` (`switches[].{ set, chains, frames }`), how many frames it ran over, and its `scale.factor` (null when the map had no translate). */
336
+ export function readSpans(text: string, where: string): { frames: number; spans: Span[]; scale: number | null } {
337
+ const doc = parseJson(text, where, 'COMPARE_REPORT_JSON');
338
+ const p = new Problems();
339
+ if (!isObject(doc)) {
340
+ p.add('COMPARE_REPORT', `${where} is ${typeName(doc)}; a retarget report (rig-pose-report/1) is an object`);
341
+ p.throwIfAny();
342
+ }
343
+ const d = doc as Record<string, unknown>;
344
+ if (d.spec !== 'rig-pose-report/1') p.add('COMPARE_REPORT', `${where} "spec" is ${typeName(d.spec)}; --retarget-report reads "rig-pose-report/1" (rig-pose retarget --report)`);
345
+ if (!Number.isInteger(d.frames) || (d.frames as number) < 1) p.add('COMPARE_REPORT', `${where} "frames" is ${typeName(d.frames)}; a retarget report states its frame count`);
346
+ const spans: Span[] = [];
347
+ if (!Array.isArray(d.switches)) {
348
+ p.add('COMPARE_REPORT', `${where} "switches" is ${typeName(d.switches)}; it must be the array the retarget wrote (empty when nothing switched)`);
349
+ } else {
350
+ d.switches.forEach((s: unknown, i: number) => {
351
+ const sw = `${where} switches[${i}]`;
352
+ if (!isObject(s) || typeof s.set !== 'string' || !Array.isArray(s.chains) || !s.chains.every((c) => typeof c === 'string')
353
+ || !Array.isArray(s.frames) || s.frames.length !== 2 || !s.frames.every((v) => Number.isInteger(v)) || (s.frames[0] as number) > (s.frames[1] as number)) {
354
+ p.add('COMPARE_REPORT', `${sw} is not { set: string, chains: [string], frames: [first, last] }`);
355
+ return;
356
+ }
357
+ spans.push({ set: s.set, chains: s.chains as string[], frames: [s.frames[0] as number, s.frames[1] as number] });
358
+ });
359
+ }
360
+ p.throwIfAny();
361
+ const sc = isObject(d.scale) && typeof d.scale.factor === 'number' && d.scale.factor > 0 && Number.isFinite(d.scale.factor) ? d.scale.factor : null;
362
+ return { frames: d.frames as number, spans, scale: sc };
363
+ }
364
+
365
+ /** `--cloth-windows`: `t0-t1[,t0-t1…]` in seconds, named A, B, C… in the order given. */
366
+ export function parseWindows(text: string): ClothWindow[] | string {
367
+ const out: ClothWindow[] = [];
368
+ const parts = text.split(',');
369
+ for (const [i, part] of parts.entries()) {
370
+ const m = /^\s*(\d+(?:\.\d+)?)\s*-\s*(\d+(?:\.\d+)?)\s*$/.exec(part);
371
+ if (m === null) return `"${part}" is not t0-t1 in seconds`;
372
+ const from = Number(m[1]);
373
+ const to = Number(m[2]);
374
+ if (!(to > from)) return `"${part}" does not end after it starts`;
375
+ if (i >= 26) return 'more than 26 windows';
376
+ out.push({ name: String.fromCharCode(65 + i), from, to });
377
+ }
378
+ return out;
379
+ }
380
+
381
+ // --- reading the sides -----------------------------------------------------
382
+
383
+ /** A point's position on one frame, whatever its score; null when the frame has none. */
384
+ export function positionOf(joints: Partial<Record<Body18, Joint>>, name: string): Pt | null {
385
+ if (isBody18(name)) {
386
+ const j = joints[name];
387
+ return j === undefined || j.state === 'missing' || j.at === undefined ? null : j.at;
388
+ }
389
+ const pair = VIRTUAL[name];
390
+ if (pair === undefined) return null;
391
+ const a = positionOf(joints, pair[0]);
392
+ const b = positionOf(joints, pair[1]);
393
+ return a === null || b === null ? null : [(a[0] + b[0]) / 2, (a[1] + b[1]) / 2];
394
+ }
395
+
396
+ /** A wrist's score on one frame: 0 when absent or missing (COCO's v = 0), null when it has a position but no score. */
397
+ function wristScore(joints: Partial<Record<Body18, Joint>>, name: Body18): number | null {
398
+ const j = joints[name];
399
+ if (j === undefined || j.state === 'missing') return 0;
400
+ return j.score ?? null;
401
+ }
402
+
403
+ export function median(xs: readonly number[]): number {
404
+ const s = [...xs].sort((a, b) => a - b);
405
+ const m = s.length >> 1;
406
+ return s.length % 2 === 1 ? s[m]! : (s[m - 1]! + s[m]!) / 2;
407
+ }
408
+
409
+ const dist = (a: Pt, b: Pt) => Math.hypot(b[0] - a[0], b[1] - a[1]);
410
+
411
+ function medianLength(frames: Frames, a: string, b: string): number | null {
412
+ const ls: number[] = [];
413
+ for (const f of frames) {
414
+ const pa = positionOf(f, a);
415
+ const pb = positionOf(f, b);
416
+ if (pa !== null && pb !== null) ls.push(dist(pa, pb));
417
+ }
418
+ if (ls.length === 0) return null;
419
+ const m = median(ls);
420
+ return m > 1e-9 ? m : null;
421
+ }
422
+
423
+ /** Which unit a mapped segment belongs to: two of one side's shoulder, elbow, wrist (arms) or hip, knee, ankle (legs). */
424
+ export function limbOf(a: string, b: string): 'arms' | 'legs' | null {
425
+ const side = (x: string) => (x.startsWith('r_') ? 'r' : x.startsWith('l_') ? 'l' : null);
426
+ if (a === b || side(a) === null || side(a) !== side(b)) return null;
427
+ const pa = a.slice(2);
428
+ const pb = b.slice(2);
429
+ const ARM = ['shoulder', 'elbow', 'wrist'];
430
+ const LEG = ['hip', 'knee', 'ankle'];
431
+ if (ARM.includes(pa) && ARM.includes(pb)) return 'arms';
432
+ if (LEG.includes(pa) && LEG.includes(pb)) return 'legs';
433
+ return null;
434
+ }
435
+
436
+ // --- the comparison --------------------------------------------------------
437
+
438
+ interface Series {
439
+ /** The figure on each frame, NaN where it is not measured. */
440
+ figure: number[];
441
+ over: boolean[];
442
+ reference: number[];
443
+ render: number[];
444
+ }
445
+
446
+ /** Compare the render with the reference. Refuses (by name) inputs that are not one time base or lack a mapped segment's joints. */
447
+ export function compare(input: CompareInput): Comparison {
448
+ const { reference: A, render: B, map } = input;
449
+ const p = new Problems();
450
+ const n = A.frames.length;
451
+ if (B.frames.length !== n) {
452
+ p.add('COMPARE_FRAMES', `the reference ${A.where} has ${n} frame(s) and the render ${B.where} ${B.frames.length}; compare reads one time base — render the motion at the reference's rate, frame for frame`);
453
+ } else {
454
+ sameTimes(p, 'COMPARE_TIMES', A.where, A.times, B.where, B.times);
455
+ }
456
+ if (input.hands !== null) {
457
+ for (const h of [input.hands.reference, input.hands.render]) {
458
+ if (h.times.length !== n) p.add('COMPARE_HANDS_FRAMES', `${h.where} has ${h.times.length} frame(s) and the reference ${A.where} ${n}; a hands file is read frame for frame against its sequence`);
459
+ else sameTimes(p, 'COMPARE_HANDS_TIMES', A.where, A.times, h.where, h.times);
460
+ }
461
+ }
462
+ if (input.cloth !== null) {
463
+ for (const c of [input.cloth.reference, input.cloth.render]) {
464
+ if (c.times.length !== n) p.add('COMPARE_CLOTH_FRAMES', `${c.where} has ${c.times.length} frame(s) and the reference ${A.where} ${n}; a cloth track is read frame for frame against its sequence`);
465
+ else sameTimes(p, 'COMPARE_CLOTH_TIMES', A.where, A.times, c.where, c.times);
466
+ }
467
+ }
468
+ if (input.spans !== null) {
469
+ for (const s of input.spans) {
470
+ if (s.frames[1] >= n) p.add('COMPARE_REPORT', `set "${s.set}"'s span, frames ${s.frames[0]}–${s.frames[1]}, runs past the ${n} frames compared; the retarget report is of another run`);
471
+ }
472
+ }
473
+ // Every mapped arm and leg segment, and its points on both sides.
474
+ const segments: Array<{ unit: 'arms' | 'legs'; chain: string; bone: string; joints: [string, string] }> = [];
475
+ for (const c of map.chains) {
476
+ c.bones.forEach((bone, k) => {
477
+ const joints: [string, string] = [c.joints[k]!, c.joints[k + 1]!];
478
+ const unit = limbOf(joints[0], joints[1]);
479
+ if (unit !== null) segments.push({ unit, chain: c.name, bone, joints });
480
+ });
481
+ }
482
+ const told = new Set<string>();
483
+ for (const s of segments) {
484
+ for (const side of [A, B]) {
485
+ for (const j of s.joints) {
486
+ if (told.has(`${side.where}\u0000${j}`)) continue;
487
+ if (side.frames.some((f) => positionOf(f, j) !== null)) continue;
488
+ told.add(`${side.where}\u0000${j}`);
489
+ p.add('COMPARE_JOINT_MISSING', `chain "${s.chain}" bone "${s.bone}" (${s.joints[0]} → ${s.joints[1]}): "${j}" has a position on no frame of ${side === A ? 'the reference' : 'the render'} ${side.where}; the map's segment cannot be compared`);
490
+ }
491
+ }
492
+ }
493
+ p.throwIfAny();
494
+
495
+ const times = [...A.times];
496
+ const endOf = (i: number): number => (i + 1 < n ? times[i + 1]! : n > 1 ? 2 * times[i]! - times[i - 1]! : times[i]!);
497
+ const secondsOf = (run: [number, number]) => Math.round((endOf(run[1]) - times[run[0]]!) * 1e6) / 1e6;
498
+ const duration = secondsOf([0, n - 1]);
499
+ const hipWidth = { reference: medianLength(A.frames, 'r_hip', 'l_hip'), render: medianLength(B.frames, 'r_hip', 'l_hip') };
500
+ const torso = { reference: medianLength(A.frames, 'mid_hip', 'mid_shoulder'), render: medianLength(B.frames, 'mid_hip', 'mid_shoulder') };
501
+
502
+ /** Runs over the bar lasting ≥ FINDING_SECONDS, each with its worst frame. */
503
+ const findingsOf = (s: Series, make: (run: [number, number], over: number, worst: Worst) => Finding): Finding[] => {
504
+ const out: Finding[] = [];
505
+ for (const run of runsOf(s.over, FINDING_GAP)) {
506
+ if (secondsOf(run) < FINDING_SECONDS - 1e-9) continue;
507
+ let w = -1;
508
+ let over = 0;
509
+ for (let i = run[0]; i <= run[1]; i++) {
510
+ if (!s.over[i]) continue;
511
+ over++;
512
+ if (w < 0 || s.figure[i]! > s.figure[w]!) w = i;
513
+ }
514
+ out.push(make(run, over, { frame: w, t: times[w]!, figure: s.figure[w]!, reference: s.reference[w]!, render: s.render[w]! }));
515
+ }
516
+ return out;
517
+ };
518
+ const byWorst = (a: Finding, b: Finding) => b.worst.figure - a.worst.figure || a.frames[0] - b.frames[0] || (a.subject < b.subject ? -1 : a.subject > b.subject ? 1 : 0);
519
+ const count = (xs: readonly boolean[]) => xs.reduce((k, x) => k + (x ? 1 : 0), 0);
520
+
521
+ const angleSeries = (from: string, to: string, bar: number): Series => {
522
+ const s: Series = { figure: [], over: [], reference: [], render: [] };
523
+ for (let i = 0; i < n; i++) {
524
+ const a0 = positionOf(A.frames[i]!, from);
525
+ const a1 = positionOf(A.frames[i]!, to);
526
+ const b0 = positionOf(B.frames[i]!, from);
527
+ const b1 = positionOf(B.frames[i]!, to);
528
+ if (a0 === null || a1 === null || b0 === null || b1 === null || dist(a0, a1) < 1e-9 || dist(b0, b1) < 1e-9) {
529
+ s.figure.push(NaN);
530
+ s.over.push(false);
531
+ s.reference.push(NaN);
532
+ s.render.push(NaN);
533
+ continue;
534
+ }
535
+ const ra = pictureAngle(a0, a1);
536
+ const rb = pictureAngle(b0, b1);
537
+ const d = Math.abs(wrap(rb - ra));
538
+ s.figure.push(d);
539
+ s.over.push(d > bar);
540
+ s.reference.push(ra);
541
+ s.render.push(rb);
542
+ }
543
+ return s;
544
+ };
545
+
546
+ // Fix class of a limb direction finding: inside a span of a set depicting its chain → paint; outside every one → rig.
547
+ const directionFix = (chain: string, frame: number): [FixClass | null, string] => {
548
+ if (input.spans === null) {
549
+ const depicting = map.alternates.filter((a) => a.chains.includes(chain)).map((a) => a.name);
550
+ if (depicting.length === 0) return ['rig', `no set of the map depicts chain "${chain}": the art does not show the bone's direction`];
551
+ return [null, `sets ${depicting.map((x) => `"${x}"`).join(', ')} depict chain "${chain}" and no --retarget-report says where they show`];
552
+ }
553
+ const inside = input.spans.find((s) => s.chains.includes(chain) && s.frames[0] <= frame && frame <= s.frames[1]);
554
+ if (inside !== undefined) return ['paint', `frame ${frame} is inside set "${inside.set}"'s span (frames ${inside.frames[0]}–${inside.frames[1]}): its drawing is not this pose`];
555
+ return ['rig', `frame ${frame} is outside every span of a set depicting chain "${chain}": the art does not show the bone's direction`];
556
+ };
557
+
558
+ const units: UnitResult[] = [];
559
+
560
+ // Head: the eye line's tilt.
561
+ {
562
+ const m: MeasureResult = { measure: 'tilt', label: 'eye-line tilt', subjects: ['eye line'], overBar: 0, findings: [], notMeasured: null, bar: `> ${BARS.headTilt}°`, source: null };
563
+ const s = angleSeries('r_eye', 'l_eye', BARS.headTilt);
564
+ if (s.figure.every((v) => Number.isNaN(v))) m.notMeasured = 'r_eye and l_eye are on no frame of both sides together';
565
+ else {
566
+ m.overBar = count(s.over);
567
+ m.findings = findingsOf(s, (frames, over, worst) => ({
568
+ unit: 'head', measure: 'tilt', subject: 'eye line', points: ['r_eye', 'l_eye'], chain: null, frames, seconds: secondsOf(frames), over, worst,
569
+ figureUnit: 'deg', bar: `> ${BARS.headTilt}°`, fix: null, fixWhy: 'a head tilt names no fix class in #38',
570
+ })).sort(byWorst);
571
+ }
572
+ units.push({ unit: 'head', measures: [m], notMeasured: null });
573
+ }
574
+
575
+ // Arms and legs: every mapped segment's direction.
576
+ const directions = (unit: 'arms' | 'legs'): MeasureResult => {
577
+ const bar = unit === 'arms' ? BARS.armDirection : BARS.legDirection;
578
+ const segs = segments.filter((s) => s.unit === unit);
579
+ const m: MeasureResult = { measure: 'direction', label: `direction (${segs.length} segment${segs.length === 1 ? '' : 's'})`, subjects: segs.map((s) => `${s.chain}/${s.bone}`), overBar: 0, findings: [], notMeasured: null, bar: `> ${bar}°`, source: null };
580
+ if (segs.length === 0) m.notMeasured = `the map has no ${unit === 'arms' ? 'arm' : 'leg'} segment (two of one side's ${unit === 'arms' ? 'shoulder, elbow, wrist' : 'hip, knee, ankle'})`;
581
+ for (const sg of segs) {
582
+ const s = angleSeries(sg.joints[0], sg.joints[1], bar);
583
+ m.overBar += count(s.over);
584
+ m.findings.push(...findingsOf(s, (frames, over, worst) => {
585
+ const [fix, fixWhy] = directionFix(sg.chain, worst.frame);
586
+ return { unit, measure: 'direction', subject: `${sg.chain}/${sg.bone}`, points: [...sg.joints], chain: sg.chain, frames, seconds: secondsOf(frames), over, worst, figureUnit: 'deg', bar: `> ${bar}°`, fix, fixWhy };
587
+ }));
588
+ }
589
+ m.findings.sort(byWorst);
590
+ return m;
591
+ };
592
+
593
+ // Arms: hand visibility.
594
+ const visibility = (): MeasureResult => {
595
+ const fromHands = input.hands !== null;
596
+ const m: MeasureResult = {
597
+ measure: 'visibility', label: 'hand visibility', subjects: ['hand_r', 'hand_l'], overBar: 0, findings: [], notMeasured: null, bar: `reference ≥ ${BARS.handSeen} while render < ${BARS.handHidden}`,
598
+ source: fromHands ? 'hand points (COCO-WholeBody 91–132)' : 'wrist scores (no --reference-hands/--render-hands; the hand points were not read)',
599
+ };
600
+ for (const side of ['r', 'l'] as const) {
601
+ const wrist: Body18 = side === 'r' ? 'r_wrist' : 'l_wrist';
602
+ const scoreA = (i: number) => (fromHands ? input.hands!.reference[side][i]! : wristScore(A.frames[i]!, wrist));
603
+ const scoreB = (i: number) => (fromHands ? input.hands!.render[side][i]! : wristScore(B.frames[i]!, wrist));
604
+ const s: Series = { figure: [], over: [], reference: [], render: [] };
605
+ for (let i = 0; i < n; i++) {
606
+ const a = scoreA(i);
607
+ const b = scoreB(i);
608
+ if (a === null || b === null) {
609
+ s.figure.push(NaN);
610
+ s.over.push(false);
611
+ s.reference.push(NaN);
612
+ s.render.push(NaN);
613
+ continue;
614
+ }
615
+ s.figure.push(a - b);
616
+ s.over.push(a >= BARS.handSeen && b < BARS.handHidden);
617
+ s.reference.push(a);
618
+ s.render.push(b);
619
+ }
620
+ m.overBar += count(s.over);
621
+ m.findings.push(...findingsOf(s, (frames, over, worst) => ({
622
+ unit: 'arms', measure: 'visibility', subject: `hand_${side}`,
623
+ points: fromHands ? [`wholebody ${HAND_POINTS[side][0]}–${HAND_POINTS[side][1]}`] : [wrist], chain: null, frames, seconds: secondsOf(frames), over, worst,
624
+ figureUnit: 'score gap', bar: `reference ≥ ${BARS.handSeen} while render < ${BARS.handHidden}`, fix: 'depth', fixWhy: 'a hand seen in the video and not in the render is drawn behind something (#33)',
625
+ })));
626
+ }
627
+ m.findings.sort(byWorst);
628
+ return m;
629
+ };
630
+ units.push({ unit: 'arms', measures: [directions('arms'), visibility()], notMeasured: null });
631
+
632
+ // Body: torso length and hip height, each over the whole run.
633
+ {
634
+ const ranged = (measure: 'torsoLength' | 'hipTravel', label: string, subject: string, points: string[], valueOf: (side: Side, i: number) => number | null, why: string | null): MeasureResult => {
635
+ const m: MeasureResult = { measure, label, subjects: [subject], overBar: 0, findings: [], notMeasured: why, bar: `render range < ${BARS.bounce} × reference range`, source: null };
636
+ if (why !== null) return m;
637
+ const va = Array.from({ length: n }, (_, i) => valueOf(A, i));
638
+ const vb = Array.from({ length: n }, (_, i) => valueOf(B, i));
639
+ const ga = va.filter((v): v is number => v !== null);
640
+ const gb = vb.filter((v): v is number => v !== null);
641
+ if (ga.length === 0 || gb.length === 0) {
642
+ m.notMeasured = `${points.join(' and ')} on no frame of ${ga.length === 0 ? 'the reference' : 'the render'}`;
643
+ return m;
644
+ }
645
+ const ra = Math.max(...ga) - Math.min(...ga);
646
+ const rb = Math.max(...gb) - Math.min(...gb);
647
+ m.ranges = { [subject]: { reference: ra, render: rb } };
648
+ if (rb < BARS.bounce * ra) {
649
+ const med = median(ga);
650
+ let w = -1;
651
+ for (let i = 0; i < n; i++) if (va[i] !== null && (w < 0 || Math.abs(va[i]! - med) > Math.abs(va[w]! - med))) w = i;
652
+ m.overBar = 1;
653
+ m.findings.push({
654
+ unit: 'body', measure, subject, points, chain: null, frames: [0, n - 1], seconds: duration, over: n,
655
+ worst: { frame: w, t: times[w]!, figure: rb / ra, reference: ra, render: rb },
656
+ figureUnit: 'range ratio', bar: `render range < ${BARS.bounce} × reference range`, fix: 'motion', fixWhy: 'the render does not carry the reference\'s bounce: the motion lacks it',
657
+ });
658
+ }
659
+ return m;
660
+ };
661
+ const torsoOf = (side: Side, i: number) => {
662
+ const med = side === A ? torso.reference : torso.render;
663
+ const a = positionOf(side.frames[i]!, 'mid_hip');
664
+ const b = positionOf(side.frames[i]!, 'mid_shoulder');
665
+ return med === null || a === null || b === null ? null : dist(a, b) / med;
666
+ };
667
+ const hipOf = (side: Side, i: number) => {
668
+ const hw = side === A ? hipWidth.reference : hipWidth.render;
669
+ const a = positionOf(side.frames[i]!, 'mid_hip');
670
+ return hw === null || a === null ? null : a[1] / hw;
671
+ };
672
+ const torsoWhy = torso.reference === null || torso.render === null ? `mid_hip → mid_shoulder has no length on any frame of ${torso.reference === null ? 'the reference' : 'the render'}` : null;
673
+ const hipWhy = hipWidth.reference === null || hipWidth.render === null ? `r_hip → l_hip has no length on any frame of ${hipWidth.reference === null ? 'the reference' : 'the render'}, so there is no hip width to normalise by` : null;
674
+ units.push({
675
+ unit: 'body',
676
+ measures: [
677
+ ranged('torsoLength', 'torso length', 'torso', ['mid_hip', 'mid_shoulder'], torsoOf, torsoWhy),
678
+ ranged('hipTravel', 'hip vertical travel', 'hip', ['mid_hip'], hipOf, hipWhy),
679
+ ],
680
+ notMeasured: null,
681
+ });
682
+ }
683
+
684
+ // Legs: directions, and the foot plant.
685
+ const footPlant = (): MeasureResult => {
686
+ const m: MeasureResult = { measure: 'footPlant', label: 'foot plant', subjects: ['foot_r', 'foot_l'], overBar: 0, findings: [], notMeasured: null, bar: `render > ${BARS.footMoving} while reference < ${BARS.footStill} hip widths/frame`, source: null };
687
+ if (hipWidth.reference === null || hipWidth.render === null) {
688
+ m.notMeasured = `r_hip → l_hip has no length on any frame of ${hipWidth.reference === null ? 'the reference' : 'the render'}, so there is no hip width to measure speed in`;
689
+ return m;
690
+ }
691
+ for (const side of ['r', 'l'] as const) {
692
+ const ankle = side === 'r' ? 'r_ankle' : 'l_ankle';
693
+ const speed = (s: Side, hw: number, i: number): number | null => {
694
+ if (i === 0) return null;
695
+ const a = positionOf(s.frames[i - 1]!, ankle);
696
+ const b = positionOf(s.frames[i]!, ankle);
697
+ return a === null || b === null ? null : dist(a, b) / hw;
698
+ };
699
+ const s: Series = { figure: [], over: [], reference: [], render: [] };
700
+ for (let i = 0; i < n; i++) {
701
+ const a = speed(A, hipWidth.reference, i);
702
+ const b = speed(B, hipWidth.render, i);
703
+ if (a === null || b === null) {
704
+ s.figure.push(NaN);
705
+ s.over.push(false);
706
+ s.reference.push(NaN);
707
+ s.render.push(NaN);
708
+ continue;
709
+ }
710
+ s.figure.push(b);
711
+ s.over.push(b > BARS.footMoving && a < BARS.footStill);
712
+ s.reference.push(a);
713
+ s.render.push(b);
714
+ }
715
+ m.overBar += count(s.over);
716
+ m.findings.push(...findingsOf(s, (frames, over, worst) => ({
717
+ unit: 'legs', measure: 'footPlant', subject: `foot_${side}`, points: [ankle], chain: null, frames, seconds: secondsOf(frames), over, worst,
718
+ figureUnit: 'hip widths per frame', bar: `render > ${BARS.footMoving} while reference < ${BARS.footStill} hip widths per frame`, fix: 'motion', fixWhy: 'the foot is still in the video and slides in the render: the motion moves it',
719
+ })));
720
+ }
721
+ m.findings.sort(byWorst);
722
+ return m;
723
+ };
724
+ units.push({ unit: 'legs', measures: [directions('legs'), footPlant()], notMeasured: null });
725
+
726
+ // Cloth.
727
+ let cloth: Comparison['cloth'] = null;
728
+ if (input.cloth === null) {
729
+ units.push({ unit: 'cloth', measures: [], notMeasured: 'no cloth tracks (--cloth-reference and --cloth-render)' });
730
+ } else {
731
+ const { measures, result, why } = compareCloth(input.cloth, input.retargetScale, times, torso, secondsOf);
732
+ cloth = result;
733
+ units.push({ unit: 'cloth', measures, notMeasured: why });
734
+ }
735
+
736
+ const findings = units.flatMap((u) => u.measures.flatMap((m) => m.findings));
737
+ return { frames: n, duration, times, hipWidth, torso, units, findings, cloth };
738
+ }
739
+
740
+ function sameTimes(p: Problems, code: string, aw: string, a: readonly number[], bw: string, b: readonly number[]): void {
741
+ const off: string[] = [];
742
+ let k = 0;
743
+ for (let i = 0; i < a.length; i++) {
744
+ const d = Math.abs(a[i]! - b[i]!);
745
+ if (d > TIME_TOLERANCE + 1e-12) {
746
+ k++;
747
+ if (off.length < 5) off.push(`frame ${i}: ${a[i]}s against ${b[i]}s (${(d * 1000).toFixed(2)} ms)`);
748
+ }
749
+ }
750
+ if (k > 0) p.add(code, `${bw} is not on the time base of ${aw}: ${k} frame(s) differ by more than ${TIME_TOLERANCE * 1000} ms — ${off.join('; ')}${k > off.length ? '; …' : ''}`);
751
+ }
752
+
753
+ // --- cloth (#34's three figures) -------------------------------------------
754
+
755
+ /** HQ's `smooth`: a Gaussian of σ = 1 frame over ±4 frames, the ends padded with the edge value. */
756
+ export function gaussSmooth(a: readonly number[]): number[] {
757
+ const K = CLOTH.kernel;
758
+ const g: number[] = [];
759
+ for (let k = -K; k <= K; k++) g.push(Math.exp(-(k * k) / (2 * CLOTH.sigma * CLOTH.sigma)));
760
+ const sum = g.reduce((s, v) => s + v, 0);
761
+ const at = (i: number) => a[Math.min(a.length - 1, Math.max(0, i))]!;
762
+ return a.map((_, i) => {
763
+ let s = 0;
764
+ for (let k = -K; k <= K; k++) s += (g[k + K]! / sum) * at(i + k);
765
+ return s;
766
+ });
767
+ }
768
+
769
+ /** numpy's `gradient`: central differences inside, one-sided at the ends. */
770
+ export function gradient(a: readonly number[]): number[] {
771
+ const n = a.length;
772
+ if (n < 2) return a.map(() => 0);
773
+ return a.map((_, i) => (i === 0 ? a[1]! - a[0]! : i === n - 1 ? a[n - 1]! - a[n - 2]! : (a[i + 1]! - a[i - 1]!) / 2));
774
+ }
775
+
776
+ /** The lag (frames, the hem after the hip positive) at the peak of the normalised cross-correlation, refined by a parabola. */
777
+ export function xcorrLag(x: readonly number[], y: readonly number[], lo: number = CLOTH.lagFrom, hi: number = CLOTH.lagTo): { lag: number; peak: number } {
778
+ const z = (v: readonly number[]) => {
779
+ const m = v.reduce((s, q) => s + q, 0) / v.length;
780
+ const sd = Math.sqrt(v.reduce((s, q) => s + (q - m) * (q - m), 0) / v.length);
781
+ return v.map((q) => (q - m) / (sd + 1e-12));
782
+ };
783
+ const X = z(x);
784
+ const Y = z(y);
785
+ const cs = new Map<number, number>();
786
+ for (let L = lo; L <= hi; L++) {
787
+ let s = 0;
788
+ let k = 0;
789
+ for (let i = Math.max(0, -L); i < X.length && i + L < Y.length; i++) {
790
+ s += X[i]! * Y[i + L]!;
791
+ k++;
792
+ }
793
+ cs.set(L, k > 0 ? s / k : NaN);
794
+ }
795
+ let best = lo;
796
+ for (let L = lo; L <= hi; L++) if (cs.get(L)! > cs.get(best)!) best = L;
797
+ let off = 0;
798
+ if (lo < best && best < hi) {
799
+ const c0 = cs.get(best - 1)!;
800
+ const c1 = cs.get(best)!;
801
+ const c2 = cs.get(best + 1)!;
802
+ const d = c0 - 2 * c1 + c2;
803
+ off = d !== 0 ? (0.5 * (c0 - c2)) / d : 0;
804
+ }
805
+ return { lag: best + off, peak: cs.get(best)! };
806
+ }
807
+
808
+ /** HQ's `reversals`: hip-velocity zero crossings in [f0, f1); a wiggle (two adjacent turns closer than `minexc`) is dropped as a pair. */
809
+ export function reversals(hip: readonly number[], f0: number, f1: number, minexc: number): number[] {
810
+ const v = gradient(hip);
811
+ const idx: number[] = [];
812
+ for (let i = f0 + 1; i < f1; i++) {
813
+ if (v[i - 1] === 0 || Math.sign(v[i]!) !== Math.sign(v[i - 1]!)) idx.push(Math.abs(v[i]!) < Math.abs(v[i - 1]!) ? i : i - 1);
814
+ }
815
+ let changed = true;
816
+ while (changed) {
817
+ changed = false;
818
+ for (let k = 0; k < idx.length - 1; k++) {
819
+ if (Math.abs(hip[idx[k + 1]!]! - hip[idx[k]!]!) < minexc) {
820
+ if (k > 0 && k + 2 < idx.length) idx.splice(k, 2);
821
+ else idx.splice(k === 0 ? k + 1 : k, 1);
822
+ changed = true;
823
+ break;
824
+ }
825
+ }
826
+ }
827
+ return idx;
828
+ }
829
+
830
+ /** Python's `round`: half to even. */
831
+ function roundHalfEven(x: number): number {
832
+ const r = Math.round(x);
833
+ return Math.abs(x % 1) === 0.5 && r % 2 !== 0 ? r - 1 : r;
834
+ }
835
+
836
+ /**
837
+ * #34's three figures over one window [f0, f1) of a hip-x and hem-x series (in hip widths): the lag, the median
838
+ * swing ratio and the median overshoot over the window's hip strokes of at least `minexc`. HQ's `analyse`.
839
+ */
840
+ export function clothFigures(hipRaw: readonly number[], hemRaw: readonly number[], f0: number, f1: number, minexc: number): ClothFigures {
841
+ const hip = gaussSmooth(hipRaw);
842
+ const hem = gaussSmooth(hemRaw);
843
+ const n = hem.length;
844
+ const dh = gradient(hip).slice(f0, f1);
845
+ const de = gradient(hem).slice(f0, f1);
846
+ const { lag, peak } = xcorrLag(dh, de);
847
+ const rev = reversals(hip, f0, f1, minexc);
848
+ const Lr = roundHalfEven(lag);
849
+ const clamp = (i: number) => Math.min(n - 1, Math.max(0, i));
850
+ const ratios: number[] = [];
851
+ const carries: number[] = [];
852
+ for (let k = 0; k < rev.length - 1; k++) {
853
+ const r0 = rev[k]!;
854
+ const r1 = rev[k + 1]!;
855
+ const dH = hip[r1]! - hip[r0]!;
856
+ if (Math.abs(dH) < minexc) continue;
857
+ const s = Math.sign(dH);
858
+ ratios.push((hem[clamp(r1 + Lr)]! - hem[clamp(r0 + Lr)]!) / dH);
859
+ const nxt = k + 2 < rev.length ? rev[k + 2]! : Math.min(n - 1, r1 + CLOTH.carryLookahead);
860
+ const end = Math.min(n, r1 + Math.max(2, Math.floor((nxt - r1) / 2)) + 1);
861
+ let carry = -Infinity;
862
+ for (let i = r1; i < end; i++) carry = Math.max(carry, s * (hem[i]! - hem[r1]!));
863
+ carries.push(Math.max(0, carry) / Math.abs(dH));
864
+ }
865
+ return {
866
+ lag,
867
+ xcorrPeak: peak,
868
+ swingRatio: ratios.length > 0 ? median(ratios) : null,
869
+ overshoot: carries.length > 0 ? median(carries) : null,
870
+ strokes: ratios.length,
871
+ };
872
+ }
873
+
874
+ function compareCloth(
875
+ c: NonNullable<CompareInput['cloth']>,
876
+ retargetScale: number | null,
877
+ times: readonly number[],
878
+ torso: Comparison['torso'],
879
+ secondsOf: (run: [number, number]) => number,
880
+ ): { measures: MeasureResult[]; result: Comparison['cloth']; why: string | null } {
881
+ const n = times.length;
882
+ const mk = (measure: 'lag' | 'swingRatio' | 'overshoot', label: string, bar: string): MeasureResult => ({ measure, label, subjects: c.windows.map((w) => `window ${w.name}`), overBar: 0, findings: [], notMeasured: null, bar, source: null });
883
+ const measures = [
884
+ mk('lag', 'lag', `outside the reference's ± ${BARS.clothLag} frame`),
885
+ mk('swingRatio', 'swing ratio', `outside the reference's ± ${BARS.clothSwing * 100}%`),
886
+ mk('overshoot', 'overshoot', `outside the reference's ± ${BARS.clothOvershoot}, floored at 0`),
887
+ ];
888
+ // The stroke threshold is one physical size, 0.2 reference hip widths (CRITERIA Revision 1), expressed in the
889
+ // render's own hip widths. A render track in rig world units is carried by the retarget's own scale (rig units per
890
+ // reference px); one in picture px by the torso (each side's median mid_hip → mid_shoulder), as the retarget scales the rig.
891
+ const ra = c.reference;
892
+ const rb = c.render;
893
+ let strokeRender: number;
894
+ let carried: string;
895
+ if (ra.unit !== 'picture px') {
896
+ throw new Refusal([{ code: 'COMPARE_CLOTH_UNITS', text: `the reference cloth track ${ra.where} states its hip width in ${ra.unit}; the reference is the video, measured in picture px` }]);
897
+ }
898
+ if (rb.unit === 'rig world units') {
899
+ if (retargetScale === null) {
900
+ throw new Refusal([{ code: 'COMPARE_CLOTH_SCALE', text: `the render cloth track ${rb.where} is in rig world units (hip width ${rb.hipWidth}), and the reference's in picture px; the stroke threshold is carried between them by the retarget's scale, so --retarget-report (with its scale) is needed` }]);
901
+ }
902
+ strokeRender = (CLOTH.stroke * ra.hipWidth * retargetScale) / rb.hipWidth;
903
+ carried = `0.2 × ${ra.hipWidth} px × ${retargetScale.toFixed(5)} rig units/px (the retarget's scale) ÷ ${rb.hipWidth} rig units`;
904
+ } else {
905
+ if (torso.reference === null || torso.render === null) {
906
+ return { measures: [], result: null, why: `mid_hip → mid_shoulder has no length on ${torso.reference === null ? 'the reference' : 'the render'}, and the stroke threshold is carried between the sides by it (CRITERIA Revision 1)` };
907
+ }
908
+ strokeRender = (CLOTH.stroke * (ra.hipWidth / torso.reference)) / (rb.hipWidth / torso.render);
909
+ carried = `0.2 × (${ra.hipWidth} ÷ ${torso.reference.toFixed(2)} px torso) ÷ (${rb.hipWidth} ÷ ${torso.render.toFixed(2)} px torso)`;
910
+ }
911
+ const dts = times.slice(1).map((t, i) => t - times[i]!);
912
+ const fps = dts.length === 0 ? 1 : 1 / median(dts);
913
+ const frameOf = (t: number) => Math.min(n, Math.max(0, Math.round((t - times[0]!) * fps)));
914
+ const windows: ClothWindowResult[] = [];
915
+ const p = new Problems();
916
+ for (const w of c.windows) {
917
+ const f0 = frameOf(w.from);
918
+ const f1 = frameOf(w.to);
919
+ if (f1 - f0 <= CLOTH.lagTo) p.add('COMPARE_CLOTH_WINDOW', `window ${w.name} (${w.from}–${w.to} s) is frames ${f0}–${f1 - 1} of the ${n} compared, ${Math.max(0, f1 - f0)} frame(s); the lag search reaches ${CLOTH.lagTo} frames, so a window needs more than ${CLOTH.lagTo}`);
920
+ }
921
+ p.throwIfAny();
922
+ for (const w of c.windows) {
923
+ const f0 = frameOf(w.from);
924
+ const f1 = frameOf(w.to);
925
+ const fa = clothFigures(ra.hip, ra.hem, f0, f1, CLOTH.stroke);
926
+ const fb = clothFigures(rb.hip, rb.hem, f0, f1, strokeRender);
927
+ const band: ClothWindowResult['band'] = {
928
+ lag: [fa.lag - BARS.clothLag, fa.lag + BARS.clothLag],
929
+ swingRatio: fa.swingRatio === null ? null : [Math.min(fa.swingRatio * (1 - BARS.clothSwing), fa.swingRatio * (1 + BARS.clothSwing)), Math.max(fa.swingRatio * (1 - BARS.clothSwing), fa.swingRatio * (1 + BARS.clothSwing))],
930
+ overshoot: fa.overshoot === null ? null : [Math.max(0, fa.overshoot - BARS.clothOvershoot), fa.overshoot + BARS.clothOvershoot],
931
+ };
932
+ windows.push({ name: w.name, t: [w.from, w.to], frames: [f0, f1 - 1], reference: fa, render: fb, band });
933
+ const frames: [number, number] = [f0, f1 - 1];
934
+ const check = (m: MeasureResult, ref: number | null, ren: number | null, b: [number, number] | null, unit: Finding['figureUnit'], barText: string) => {
935
+ if (ref === null || b === null) {
936
+ m.notMeasured = [m.notMeasured, `window ${w.name}: no hip stroke ≥ ${CLOTH.stroke} hip widths on the reference`].filter((x) => x !== null).join('; ');
937
+ return;
938
+ }
939
+ if (ren === null) {
940
+ m.notMeasured = [m.notMeasured, `window ${w.name}: no hip stroke ≥ ${strokeRender.toFixed(4)} of its hip widths on the render`].filter((x) => x !== null).join('; ');
941
+ return;
942
+ }
943
+ if (ren >= b[0] - 1e-12 && ren <= b[1] + 1e-12) return;
944
+ m.overBar++;
945
+ m.findings.push({
946
+ unit: 'cloth', measure: m.measure, subject: `window ${w.name}`, points: ['hem', 'hip'], chain: null, frames, seconds: secondsOf(frames), over: f1 - f0,
947
+ worst: { frame: f0, t: times[f0]!, figure: Math.abs(ren - ref), reference: ref, render: ren },
948
+ figureUnit: unit, bar: `${barText}: [${b[0].toFixed(3)}, ${b[1].toFixed(3)}]`, fix: 'rig', fixWhy: 'the cloth is the rig\'s physics',
949
+ });
950
+ };
951
+ check(measures[0]!, fa.lag, fb.lag, band.lag, 'frames', `reference ± ${BARS.clothLag} frame`);
952
+ check(measures[1]!, fa.swingRatio, fb.swingRatio, band.swingRatio, 'ratio', `reference ± ${BARS.clothSwing * 100}%`);
953
+ check(measures[2]!, fa.overshoot, fb.overshoot, band.overshoot, 'fraction of the hip stroke', `reference ± ${BARS.clothOvershoot}, floored at 0`);
954
+ }
955
+ for (const m of measures) m.findings.sort((a, b) => b.worst.figure - a.worst.figure || a.frames[0] - b.frames[0]);
956
+ return { measures, result: { windows, stroke: { reference: CLOTH.stroke, render: strokeRender, carried } }, why: null };
957
+ }
958
+
959
+ // --- lines -----------------------------------------------------------------
960
+
961
+ const deg = (v: number) => `${v.toFixed(1)}°`;
962
+ const spanOf = (frames: [number, number], times: readonly number[]): string =>
963
+ frames[0] === frames[1] ? `frame ${frames[0]} (t=${times[frames[0]]!.toFixed(3)}s)` : `frames ${frames[0]}–${frames[1]} (t=${times[frames[0]]!.toFixed(3)}–${times[frames[1]]!.toFixed(3)}s)`;
964
+
965
+ /** A finding's figure as a line reads it. */
966
+ export function figureText(f: Finding): string {
967
+ const w = f.worst;
968
+ switch (f.figureUnit) {
969
+ case 'deg':
970
+ return `${deg(w.figure)} (reference ${deg(w.reference!)}, render ${deg(w.render!)})`;
971
+ case 'score gap':
972
+ return `render ${w.render!.toFixed(2)} where the reference is ${w.reference!.toFixed(2)}`;
973
+ case 'hip widths per frame':
974
+ return `render ${w.render!.toFixed(3)} hip widths/frame where the reference is ${w.reference!.toFixed(3)}`;
975
+ case 'range ratio':
976
+ return `render range ${w.render!.toFixed(3)} = ${w.figure.toFixed(2)} × the reference's ${w.reference!.toFixed(3)}`;
977
+ default:
978
+ return `render ${w.render!.toFixed(3)}, reference ${w.reference!.toFixed(3)}`;
979
+ }
980
+ }
981
+
982
+ export function findingLine(f: Finding, times: readonly number[]): string {
983
+ const at = f.unit === 'cloth' ? '' : ` at frame ${f.worst.frame} (t=${f.worst.t.toFixed(3)}s)`;
984
+ const what = f.unit === 'body' ? `${f.subject}: no bounce — ${figureText(f)}`
985
+ : f.unit === 'cloth' ? `${f.subject} — ${figureText(f)}`
986
+ : `${f.subject} (${f.points.join(' → ')}) — worst ${figureText(f)}${at}`;
987
+ const fix = f.fix === null ? `fix: not derivable (${f.fixWhy})` : `fix: ${f.fix} (${f.fixWhy})`;
988
+ return `${f.unit} ${f.measure} ${what}; ${spanOf(f.frames, times)}, ${f.seconds.toFixed(3)}s, ${f.over} frame(s) over the bar ${f.bar}; ${fix}`;
989
+ }
990
+
991
+ /** One line per unit: its findings, each measure's worst figure, and the frames over the bar. */
992
+ export function summaryLine(u: UnitResult, times: readonly number[]): string {
993
+ const k = u.measures.reduce((s, m) => s + m.findings.length, 0);
994
+ const head = `${k} finding${k === 1 ? '' : 's'}`;
995
+ if (u.notMeasured !== null) return `${head} — not measured: ${u.notMeasured}`;
996
+ const parts = u.measures.map((m) => {
997
+ const src = m.source === null ? '' : ` from ${m.source}`;
998
+ if (m.notMeasured !== null && m.findings.length === 0 && m.ranges === undefined) return `${m.label}${src}: not measured (${m.notMeasured})`;
999
+ const nm = m.notMeasured === null ? '' : `; not measured: ${m.notMeasured}`;
1000
+ if (m.ranges !== undefined) {
1001
+ const r = Object.values(m.ranges)[0]!;
1002
+ return `${m.label}: ${m.findings.length} finding${m.findings.length === 1 ? '' : 's'}, render range ${r.render.toFixed(3)} against the reference's ${r.reference.toFixed(3)}${r.reference > 0 ? ` (${(r.render / r.reference).toFixed(2)} ×)` : ''}, bar ${m.bar}${nm}`;
1003
+ }
1004
+ const worst = m.findings[0];
1005
+ const w = worst === undefined ? 'worst —' : `worst ${figureText(worst)} on ${worst.subject}${worst.unit === 'cloth' ? '' : ` at frame ${worst.worst.frame} (t=${times[worst.worst.frame]!.toFixed(3)}s)`}`;
1006
+ const overText = u.unit === 'cloth' ? `${m.overBar} window(s) over the bar` : `${m.overBar} frame(s) over the bar`;
1007
+ return `${m.label}${src}: ${m.findings.length} finding${m.findings.length === 1 ? '' : 's'}, ${w}, ${overText} (${m.bar})${nm}`;
1008
+ });
1009
+ return `${head} — ${parts.join('; ')}`;
1010
+ }
1011
+
1012
+ /** The comparison as `rig-pose-compare/1` JSON, numbers rounded once here. */
1013
+ export function writeComparison(c: Comparison, inputs: Record<string, string | null>): string {
1014
+ const r = (v: number) => (Number.isFinite(v) ? Number(v.toFixed(4)) : null);
1015
+ const doc = {
1016
+ spec: COMPARE_SPEC,
1017
+ inputs,
1018
+ frames: c.frames,
1019
+ duration: c.duration,
1020
+ hipWidth: c.hipWidth,
1021
+ torso: c.torso,
1022
+ rule: { findingSeconds: FINDING_SECONDS, findingGap: FINDING_GAP, timeTolerance: TIME_TOLERANCE },
1023
+ bars: BARS,
1024
+ units: c.units.map((u) => ({
1025
+ unit: u.unit,
1026
+ findings: u.measures.reduce((s, m) => s + m.findings.length, 0),
1027
+ notMeasured: u.notMeasured,
1028
+ measures: u.measures.map((m) => ({
1029
+ measure: m.measure,
1030
+ subjects: m.subjects,
1031
+ source: m.source,
1032
+ findings: m.findings.length,
1033
+ overBar: m.overBar,
1034
+ notMeasured: m.notMeasured,
1035
+ bar: m.bar,
1036
+ ...(m.ranges !== undefined ? { ranges: m.ranges } : {}),
1037
+ })),
1038
+ })),
1039
+ findings: c.findings.map((f) => ({
1040
+ unit: f.unit, measure: f.measure, subject: f.subject, points: f.points, chain: f.chain, frames: f.frames, seconds: f.seconds, over: f.over,
1041
+ worst: f.worst, figureUnit: f.figureUnit, bar: f.bar, fix: f.fix, fixWhy: f.fixWhy,
1042
+ })),
1043
+ cloth: c.cloth,
1044
+ };
1045
+ return `${JSON.stringify(doc, (_k, v: unknown) => (typeof v === 'number' ? r(v) : v), 2)}\n`;
1046
+ }