spine-rigc 0.2.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/LICENSE +21 -0
- package/NOTICE.md +76 -0
- package/README.md +558 -0
- package/cli.ts +739 -0
- package/docs/AUTHORING.md +1303 -0
- package/docs/SPEC_COVERAGE.md +1109 -0
- package/package.json +65 -0
- package/src/check.ts +1714 -0
- package/src/compile.ts +1861 -0
- package/src/diff.ts +847 -0
- package/src/errors.ts +22 -0
- package/src/framing.ts +539 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +433 -0
- package/src/png.ts +50 -0
- package/src/render.ts +974 -0
- package/src/rig.ts +731 -0
- package/src/slots.ts +603 -0
- package/src/timelines.ts +253 -0
- package/src/transform.ts +130 -0
- package/src/types.ts +586 -0
- package/src/validate.ts +1586 -0
- package/tools/font5x7.ts +101 -0
- package/tools/plate.ts +286 -0
package/src/check.ts
ADDED
|
@@ -0,0 +1,1714 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rigc check — measure a candidate against reference **frames**, never against
|
|
3
|
+
* the reference file.
|
|
4
|
+
*
|
|
5
|
+
* ## The hole this closes
|
|
6
|
+
*
|
|
7
|
+
* The gate is a *validity* gate. It parses the skeleton, steps every animation,
|
|
8
|
+
* and refuses anything degenerate — and it has no opinion whatever about whether
|
|
9
|
+
* the animation is the one in the frames. Three honest ladder runs produced zero
|
|
10
|
+
* validator FAILs between them, and one of those runs shipped a build in which
|
|
11
|
+
* **every easing in the file was reversed** and came back green. Both authors
|
|
12
|
+
* closed the loop the same way, with a script they wrote themselves: pose the
|
|
13
|
+
* candidate with `spine-core`, and compare it against what they had measured off
|
|
14
|
+
* the pictures. This is that script, promoted, so the loop is
|
|
15
|
+
*
|
|
16
|
+
* build → validate → check against frames → fix
|
|
17
|
+
*
|
|
18
|
+
* and the last step is a command rather than something each author reinvents.
|
|
19
|
+
*
|
|
20
|
+
* ## 🔒 The invariant: this never reads the answer
|
|
21
|
+
*
|
|
22
|
+
* `check` opens exactly two things — **the candidate** (its skeleton, its atlas
|
|
23
|
+
* and the atlas pages) and **PNG frames** under `--frames`, plus the
|
|
24
|
+
* `frames.json` sidecar beside them. It has no code path that names
|
|
25
|
+
* `examples/`, an `export/` directory, a rung or a reference skeleton, and every
|
|
26
|
+
* read on the reference side goes through `readFrameFile`, which refuses a path
|
|
27
|
+
* that escapes `--frames` or that is neither a `.png` nor the sidecar.
|
|
28
|
+
*
|
|
29
|
+
* That is not fastidiousness. `docs/LADDER.md`'s honesty rule is the only thing
|
|
30
|
+
* that makes a rung's number mean anything, and a fidelity tool that quietly
|
|
31
|
+
* loaded the reference JSON would convert every future run from authoring into
|
|
32
|
+
* transcription without anybody noticing — the exact failure that is hardest to
|
|
33
|
+
* detect after the fact.
|
|
34
|
+
*
|
|
35
|
+
* ## What it measures
|
|
36
|
+
*
|
|
37
|
+
* Per animation, per frame:
|
|
38
|
+
*
|
|
39
|
+
* - **The framing** — where the candidate's drawn pixels sit against the
|
|
40
|
+
* reference's drawn pixels, as a scale ratio and a residual. It is reported
|
|
41
|
+
* first because it is upstream of everything else: get it wrong and every
|
|
42
|
+
* number below carries the error, disguised as motion (issue #34).
|
|
43
|
+
* - **MAE over the union alpha** — the mean absolute RGB difference between the
|
|
44
|
+
* candidate composited over the frames' background and the reference frame,
|
|
45
|
+
* averaged over the pixels either side covers. Over the union rather than the
|
|
46
|
+
* whole frame because most of a frame is background on both sides, and
|
|
47
|
+
* averaging that in makes every number small and every difference between
|
|
48
|
+
* numbers smaller.
|
|
49
|
+
* - **Per-frame change** — how much each side moved since **its own** previous
|
|
50
|
+
* frame, and whether those two agree. The only measure here that looks at a
|
|
51
|
+
* relation between two frames rather than at one, and the only one that can see
|
|
52
|
+
* a held pose that is not held or a one-frame event that never fired — see
|
|
53
|
+
* `FrameChange`.
|
|
54
|
+
* - **Per-slot tracking** — where each of the candidate's own slots landed
|
|
55
|
+
* against the reference frame. This is the part an author acts on: MAE says
|
|
56
|
+
* *how wrong*, a slot's drift says *which part, which way, how far*.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ Both of the last two are bounded by what a picture can attribute, and
|
|
59
|
+
* `src/slots.ts` owns that judgement: a slot the reference merged into a
|
|
60
|
+
* neighbour is template-matched against its own pixels rather than guessed at,
|
|
61
|
+
* and a slot nothing in its search radius matches comes back as **no match**
|
|
62
|
+
* rather than as a number. A drift printed beside the wrong part is worse than a
|
|
63
|
+
* blank, because it is actionable and wrong.
|
|
64
|
+
*/
|
|
65
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
66
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
67
|
+
import {
|
|
68
|
+
BACKGROUND,
|
|
69
|
+
frameGeometry,
|
|
70
|
+
PROTOCOL_FPS,
|
|
71
|
+
posableFromText,
|
|
72
|
+
renderFrame,
|
|
73
|
+
sampleAnimation,
|
|
74
|
+
sampleSetupPose,
|
|
75
|
+
trimmedUnionBounds,
|
|
76
|
+
viewportOfSize,
|
|
77
|
+
PAD,
|
|
78
|
+
FRAMES_SIDECAR,
|
|
79
|
+
FRAMES_SPEC,
|
|
80
|
+
type Frame,
|
|
81
|
+
type FramesSidecar,
|
|
82
|
+
type FrameSet,
|
|
83
|
+
type Viewport,
|
|
84
|
+
} from './render.ts';
|
|
85
|
+
import {
|
|
86
|
+
applyFit,
|
|
87
|
+
boxHeight,
|
|
88
|
+
boxWidth,
|
|
89
|
+
contentBoxOfPlate,
|
|
90
|
+
ContrastHistogram,
|
|
91
|
+
fitDistance,
|
|
92
|
+
fitFraming,
|
|
93
|
+
fitIsSettled,
|
|
94
|
+
fitSeparation,
|
|
95
|
+
frameContentBox,
|
|
96
|
+
isContent,
|
|
97
|
+
BACKGROUND_TOLERANCE,
|
|
98
|
+
CYCLE_PIXELS,
|
|
99
|
+
type BoxPair,
|
|
100
|
+
type ContentBox,
|
|
101
|
+
type FramingFit,
|
|
102
|
+
} from './framing.ts';
|
|
103
|
+
import { componentsOf, isAttributable, matchSlots, type SlotTrack } from './slots.ts';
|
|
104
|
+
import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
|
|
105
|
+
|
|
106
|
+
export { componentsOf, matchSlots, searchRadius, type Component, type MatchMethod, type SlotTrack } from './slots.ts';
|
|
107
|
+
export type { BoxPair, ContentBox, FramingFit } from './framing.ts';
|
|
108
|
+
|
|
109
|
+
// ---------------------------------------------------------------------------
|
|
110
|
+
// the reference side — frames only, and mechanically so
|
|
111
|
+
// ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The honesty invariant, as a function: this path is a frame under `--frames`.
|
|
115
|
+
*
|
|
116
|
+
* Every reference-side read in this module goes through it, which is what makes
|
|
117
|
+
* "`check` reads only PNG frames" a property of the code rather than a claim in
|
|
118
|
+
* a comment — a path that climbs out of the frames directory, or that is neither
|
|
119
|
+
* a PNG nor the sidecar, throws with both paths named. It is exported so the
|
|
120
|
+
* selftest can make it fire: an invariant nobody has seen refuse anything is not
|
|
121
|
+
* an invariant.
|
|
122
|
+
*/
|
|
123
|
+
export function assertFrameReadable(framesRoot: string, path: string): void {
|
|
124
|
+
const abs = resolve(path);
|
|
125
|
+
const inside = relative(resolve(framesRoot), abs);
|
|
126
|
+
if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
|
|
127
|
+
throw new CheckError(`${abs} is outside --frames ${resolve(framesRoot)}; check reads frames and nothing else`);
|
|
128
|
+
}
|
|
129
|
+
const name = basename(abs);
|
|
130
|
+
if (!name.endsWith('.png') && name !== FRAMES_SIDECAR) {
|
|
131
|
+
throw new CheckError(
|
|
132
|
+
`${abs} is neither a .png frame nor ${FRAMES_SIDECAR}; check never reads a reference skeleton — see src/check.ts`,
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function readFrameFile(framesRoot: string, path: string): Buffer {
|
|
138
|
+
assertFrameReadable(framesRoot, path);
|
|
139
|
+
return readFileSync(resolve(path));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export class CheckError extends Error {}
|
|
143
|
+
|
|
144
|
+
/** Where a frames directory's sidecar is, and which of its sets `--frames` selected. */
|
|
145
|
+
interface Located {
|
|
146
|
+
/** The skeleton root — where `frames.json` sits. */
|
|
147
|
+
root: string;
|
|
148
|
+
sidecar: FramesSidecar | null;
|
|
149
|
+
/** Set directories to compare; empty means "every set in the sidecar". */
|
|
150
|
+
only: string[];
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Resolve `--frames <dir>`: either a skeleton root holding the sidecar, or one
|
|
155
|
+
* animation directory inside one.
|
|
156
|
+
*/
|
|
157
|
+
export function locateFrames(framesDir: string): Located {
|
|
158
|
+
const dir = resolve(framesDir);
|
|
159
|
+
if (!existsSync(dir)) throw new CheckError(`no frames directory at ${dir}`);
|
|
160
|
+
if (existsSync(join(dir, FRAMES_SIDECAR))) {
|
|
161
|
+
return { root: dir, sidecar: readSidecar(dir), only: [] };
|
|
162
|
+
}
|
|
163
|
+
const parent = dirname(dir);
|
|
164
|
+
if (existsSync(join(parent, FRAMES_SIDECAR))) {
|
|
165
|
+
const sidecar = readSidecar(parent);
|
|
166
|
+
const name = basename(dir);
|
|
167
|
+
if (sidecar && sidecar.sets.some((s) => s.dir === name)) {
|
|
168
|
+
return { root: parent, sidecar, only: [name] };
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
return { root: dir, sidecar: null, only: [] };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function readSidecar(root: string): FramesSidecar | null {
|
|
175
|
+
const raw = readFrameFile(root, join(root, FRAMES_SIDECAR)).toString('utf8');
|
|
176
|
+
const parsed: unknown = JSON.parse(raw);
|
|
177
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
178
|
+
const sidecar = parsed as FramesSidecar;
|
|
179
|
+
if (sidecar.spec !== FRAMES_SPEC) {
|
|
180
|
+
throw new CheckError(
|
|
181
|
+
`${join(root, FRAMES_SIDECAR)} declares spec ${JSON.stringify(sidecar.spec)}; this build reads ${FRAMES_SPEC}`,
|
|
182
|
+
);
|
|
183
|
+
}
|
|
184
|
+
return sidecar;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** The `f0000.png` frames in a directory, by index, in index order. */
|
|
188
|
+
function framesOnDisk(root: string, dir: string): Array<{ index: number; file: string }> {
|
|
189
|
+
const abs = join(root, dir);
|
|
190
|
+
if (!existsSync(abs)) throw new CheckError(`no frame directory at ${abs}`);
|
|
191
|
+
const out: Array<{ index: number; file: string }> = [];
|
|
192
|
+
for (const name of readdirSync(abs)) {
|
|
193
|
+
const m = /^f(\d+)\.png$/.exec(name);
|
|
194
|
+
if (!m) continue;
|
|
195
|
+
out.push({ index: Number(m[1]), file: join(abs, name) });
|
|
196
|
+
}
|
|
197
|
+
out.sort((a, b) => a.index - b.index);
|
|
198
|
+
return out;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// ---------------------------------------------------------------------------
|
|
202
|
+
// the measures
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* How much a frame moved since the frame before it — on **each side separately**.
|
|
207
|
+
*
|
|
208
|
+
* ## Why this is not the MAE again
|
|
209
|
+
*
|
|
210
|
+
* Every other measure here compares the candidate against the reference *at one
|
|
211
|
+
* moment*. This one compares each side against **itself** a frame earlier, and
|
|
212
|
+
* then compares those two numbers. What that catches is a class of defect the
|
|
213
|
+
* aggregate MAE is structurally blind to, because it is small in every single
|
|
214
|
+
* frame and wrong in the relationship between them:
|
|
215
|
+
*
|
|
216
|
+
* - **A held pose that is not held.** Rung 6's reference is pixel-identical across
|
|
217
|
+
* f64–f67. A greedy key reduction had sloped a line through that plateau —
|
|
218
|
+
* legal under its own per-key tolerance, invisible to `validate`, invisible to
|
|
219
|
+
* `diff`, and worth so little MAE per frame that nothing flagged it. Re-rendering
|
|
220
|
+
* the candidate and diffing **its own** f67 against **its own** f68 showed 91 px
|
|
221
|
+
* moving where the reference moves 3.
|
|
222
|
+
* - **A one-frame event that never fires.** The same run's tracker reveal landed a
|
|
223
|
+
* fraction of a millisecond past the animation's last sample, so it never
|
|
224
|
+
* happened. `diff` read `animations.deform` and `draw_order` as matching, the
|
|
225
|
+
* gate was green, and only looking at the last two frames found it.
|
|
226
|
+
*
|
|
227
|
+
* Both were found by that run building its own render-diff outside the tool
|
|
228
|
+
* (`bench/runs/2026-08-23-rung6-1/LOOP.md` §10, issue #53). `check` has both frame
|
|
229
|
+
* sequences in hand already, so it is the tool's job and not the author's.
|
|
230
|
+
*
|
|
231
|
+
* ⚠️ Only between **adjacent** frames. A set that commits stills rather than every
|
|
232
|
+
* frame — rung 2's contact sheets ship `f0000` and `f0310` — has no frame-to-frame
|
|
233
|
+
* delta to report, and the difference between two frames 310 apart is not one. That
|
|
234
|
+
* is `null` here rather than a number about the wrong thing.
|
|
235
|
+
*/
|
|
236
|
+
export interface FrameChange {
|
|
237
|
+
/** The frame this one is measured against; always `index - 1`. */
|
|
238
|
+
previous: number;
|
|
239
|
+
/** Pixels the candidate moved since its own previous frame. */
|
|
240
|
+
candidate: number;
|
|
241
|
+
/** Pixels the reference moved since its own previous frame. */
|
|
242
|
+
reference: number;
|
|
243
|
+
/** The same two as a mean absolute RGB difference over the whole frame, 0..255. */
|
|
244
|
+
candidateMae: number;
|
|
245
|
+
referenceMae: number;
|
|
246
|
+
/**
|
|
247
|
+
* How the two compare — see `CHANGE_RATIO`.
|
|
248
|
+
*
|
|
249
|
+
* `moves` means the candidate changed materially more than the reference did
|
|
250
|
+
* here, `holds` materially less. Both are diagnoses the per-frame MAE cannot
|
|
251
|
+
* give: a frame that is merely off by a constant offset agrees on this measure.
|
|
252
|
+
*/
|
|
253
|
+
verdict: 'agrees' | 'moves' | 'holds';
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
export interface FrameCheck {
|
|
257
|
+
index: number;
|
|
258
|
+
/** The reference PNG, so a worst-frame line is directly openable. */
|
|
259
|
+
file: string;
|
|
260
|
+
/** Mean absolute RGB difference over the union alpha, 0..255. */
|
|
261
|
+
mae: number;
|
|
262
|
+
/**
|
|
263
|
+
* The same difference averaged over the WHOLE frame, background included.
|
|
264
|
+
*
|
|
265
|
+
* Reported beside `mae` and never instead of it. Most of a frame is background
|
|
266
|
+
* on both sides, so this number is small for every candidate and the gap
|
|
267
|
+
* between a good one and a bad one is smaller still — but it is the number an
|
|
268
|
+
* ad-hoc re-render check naturally computes, so a run comparing itself against
|
|
269
|
+
* an older log needs it to be able to.
|
|
270
|
+
*/
|
|
271
|
+
maeFrame: number;
|
|
272
|
+
unionPixels: number;
|
|
273
|
+
candidatePixels: number;
|
|
274
|
+
referencePixels: number;
|
|
275
|
+
components: number;
|
|
276
|
+
/** Components no slot reached — something in the shot the candidate has not drawn. */
|
|
277
|
+
unmatchedComponents: number;
|
|
278
|
+
worstSlot: string | null;
|
|
279
|
+
worstDrift: number | null;
|
|
280
|
+
/** How many slots got an attributable drift, out of how many drew anything. */
|
|
281
|
+
attributed: number;
|
|
282
|
+
drawn: number;
|
|
283
|
+
slots: SlotTrack[];
|
|
284
|
+
/** This frame against the one before it, on each side — `null` unless adjacent. */
|
|
285
|
+
change: FrameChange | null;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export interface AnimationCheck {
|
|
289
|
+
dir: string;
|
|
290
|
+
/** The animation the frames show, per the sidecar. */
|
|
291
|
+
animation: string | null;
|
|
292
|
+
/** The candidate animation played against it. */
|
|
293
|
+
candidateAnimation: string | null;
|
|
294
|
+
fps: number;
|
|
295
|
+
referenceFrames: number;
|
|
296
|
+
candidateFrames: number;
|
|
297
|
+
compared: number;
|
|
298
|
+
meanMae: number;
|
|
299
|
+
/** Mean of the per-frame whole-frame MAE — see `FrameCheck.maeFrame`. */
|
|
300
|
+
meanMaeFrame: number;
|
|
301
|
+
worstMae: number;
|
|
302
|
+
worstMaeFrame: number;
|
|
303
|
+
worstDrift: number;
|
|
304
|
+
worstDriftFrame: number;
|
|
305
|
+
worstDriftSlot: string | null;
|
|
306
|
+
/** Frames in which no slot at all could be attributed — the drift's denominator. */
|
|
307
|
+
framesWithoutDrift: number;
|
|
308
|
+
/** Adjacent frame pairs a frame-to-frame change could be measured across. */
|
|
309
|
+
changePairs: number;
|
|
310
|
+
/** How many of those the candidate's own change disagrees with the reference's. */
|
|
311
|
+
changeDisagreements: number;
|
|
312
|
+
/** The widest of those disagreements, and `-1` when there is none. */
|
|
313
|
+
worstChangeFrame: number;
|
|
314
|
+
frames: FrameCheck[];
|
|
315
|
+
notes: string[];
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** A world box and the pixel grid it was drawn into. */
|
|
319
|
+
export interface Framing {
|
|
320
|
+
x: number;
|
|
321
|
+
y: number;
|
|
322
|
+
width: number;
|
|
323
|
+
height: number;
|
|
324
|
+
/** Frame pixels per world unit. */
|
|
325
|
+
scale: number;
|
|
326
|
+
pixelWidth: number;
|
|
327
|
+
pixelHeight: number;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* How the world box the candidate was rendered into was arrived at.
|
|
332
|
+
*
|
|
333
|
+
* - `derived` — fitted to the candidate's own drawn pixels, from a start taken
|
|
334
|
+
* from its posed geometry. The default, and the only option without a sidecar.
|
|
335
|
+
* - `declared` — the box `frames.json` records, reached from that same box and
|
|
336
|
+
* kept because the candidate's own pixels land on the reference's in it. See
|
|
337
|
+
* `frameByDeclaredBox` for why a measured coincidence licenses it.
|
|
338
|
+
* - `pinned` — `--viewport`, which is a claim by the author and is not checked.
|
|
339
|
+
*/
|
|
340
|
+
export type FramingSource = 'derived' | 'declared' | 'pinned';
|
|
341
|
+
|
|
342
|
+
/** What the framing pass concluded, and how sure it is of it. */
|
|
343
|
+
export interface FramingReport {
|
|
344
|
+
/** The residual fit measured at the viewport that was used. */
|
|
345
|
+
fit: FramingFit;
|
|
346
|
+
/** How many render/measure/correct passes ran. */
|
|
347
|
+
passes: number;
|
|
348
|
+
/** Did the correction converge to the identity, or was it still moving? */
|
|
349
|
+
settled: boolean;
|
|
350
|
+
/** How the box was chosen — see `FramingSource`. */
|
|
351
|
+
source: FramingSource;
|
|
352
|
+
/**
|
|
353
|
+
* Did the correction fall into a repeating orbit instead of converging?
|
|
354
|
+
*
|
|
355
|
+
* When it did, `settled` is false and **more passes cannot help**: the loop is
|
|
356
|
+
* re-measuring states it has already been in. That is a fact about the fit
|
|
357
|
+
* having no fixed point on this shot, not about the pass budget, and the two
|
|
358
|
+
* used to print the same warning.
|
|
359
|
+
*/
|
|
360
|
+
cycled: boolean;
|
|
361
|
+
/**
|
|
362
|
+
* Do the two content boxes agree, at the viewport that was used?
|
|
363
|
+
*
|
|
364
|
+
* `fitDistance` under `COINCIDENT_PIXELS`. This is what separates "the loop
|
|
365
|
+
* fell short of its own target and the two shots are nevertheless in the same
|
|
366
|
+
* place" from "the loop fell short because these are different shapes" — the
|
|
367
|
+
* first is the tool's floor and the second is a finding.
|
|
368
|
+
*/
|
|
369
|
+
agrees: boolean;
|
|
370
|
+
/**
|
|
371
|
+
* Whether the fit was APPLIED or only measured.
|
|
372
|
+
*
|
|
373
|
+
* `--viewport` pins the box, so the fit is reported and not used — which is the
|
|
374
|
+
* most useful thing about pinning it: it separates "my keys are wrong" from
|
|
375
|
+
* "my framing is wrong", and those are two different repairs.
|
|
376
|
+
*/
|
|
377
|
+
applied: boolean;
|
|
378
|
+
/**
|
|
379
|
+
* The same two content boxes in **world units**, when the sidecar records the
|
|
380
|
+
* reference's scale.
|
|
381
|
+
*
|
|
382
|
+
* ⭐ This is the one place a difference of pure scale can show up at all. The
|
|
383
|
+
* framing deliberately absorbs it — a candidate is authored in its own
|
|
384
|
+
* coordinates, so "twice as big in world units" is a choice of units and not an
|
|
385
|
+
* error, and a tool that reported it as one would be reporting the thing it was
|
|
386
|
+
* built to be blind to. But an author who measured the shot off the frames IS
|
|
387
|
+
* working in the frames' units, and for them these two numbers are directly
|
|
388
|
+
* comparable and a 2 % disagreement is a real finding. So it is printed, with
|
|
389
|
+
* what it does and does not mean attached.
|
|
390
|
+
*/
|
|
391
|
+
units: { candidate: Extent; reference: Extent; ratio: number } | null;
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
/** A width and a height in world units. */
|
|
395
|
+
export interface Extent {
|
|
396
|
+
width: number;
|
|
397
|
+
height: number;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
export interface CheckReport {
|
|
401
|
+
candidate: { skeleton: string; atlas: string };
|
|
402
|
+
framesDir: string;
|
|
403
|
+
framesRoot: string;
|
|
404
|
+
/** How the candidate's own world box was chosen. */
|
|
405
|
+
framing: 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
|
|
406
|
+
/** The box the CANDIDATE was rendered into, at the reference's pixel size. */
|
|
407
|
+
viewport: Framing;
|
|
408
|
+
/** Where the candidate's drawn pixels ended up against the reference's. */
|
|
409
|
+
framingFit: FramingReport | null;
|
|
410
|
+
/**
|
|
411
|
+
* The box the REFERENCE was rendered into, when the sidecar records one.
|
|
412
|
+
*
|
|
413
|
+
* Informational: the two skeletons do not share a coordinate system, so these
|
|
414
|
+
* numbers are not comparable term by term. The pixel dimensions are — they are
|
|
415
|
+
* the same grid — and a difference between them is the diagnostic.
|
|
416
|
+
*/
|
|
417
|
+
referenceViewport: Framing | null;
|
|
418
|
+
background: RGBA;
|
|
419
|
+
animations: AnimationCheck[];
|
|
420
|
+
notes: string[];
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export interface CheckOptions {
|
|
424
|
+
skeletonText: string;
|
|
425
|
+
atlasText: string;
|
|
426
|
+
/** Where the atlas's page paths resolve from. */
|
|
427
|
+
atlasDir: string;
|
|
428
|
+
framesDir: string;
|
|
429
|
+
/** Labels for the report; the texts above are what is actually read. */
|
|
430
|
+
labels?: { skeleton: string; atlas: string };
|
|
431
|
+
/** Only used when there is no sidecar to take the rate from. */
|
|
432
|
+
fps?: number;
|
|
433
|
+
/**
|
|
434
|
+
* Pin the candidate's world box `x,y,width,height` instead of deriving it.
|
|
435
|
+
*
|
|
436
|
+
* Two uses, and the second is the one an authoring loop wants. It is the escape
|
|
437
|
+
* hatch when the derivation cannot work — a candidate deliberately missing a
|
|
438
|
+
* part has a different content box by construction, and pinning lets the rest
|
|
439
|
+
* of the shot still be measured. And it is the way to hold the framing FIXED
|
|
440
|
+
* across builds: the framing line is still reported, so a pinned run separates
|
|
441
|
+
* "my keys moved" from "my framing moved" without either hiding the other.
|
|
442
|
+
*/
|
|
443
|
+
viewport?: { x: number; y: number; width: number; height: number };
|
|
444
|
+
/** Play this candidate animation against the frames, when the names differ. */
|
|
445
|
+
as?: string;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ---------------------------------------------------------------------------
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Compare a candidate against a set of reference frames.
|
|
452
|
+
*
|
|
453
|
+
* ## 🧭 Why the candidate is framed by its own pixels
|
|
454
|
+
*
|
|
455
|
+
* The obvious move — render the candidate into the world box the sidecar
|
|
456
|
+
* records — is wrong, and wrong in a way that reads as a catastrophic failure
|
|
457
|
+
* rather than as a mistake: on rung 3's honest candidate it reports MAE 146/255,
|
|
458
|
+
* because that candidate put its origin on the pendulum's pivot and the
|
|
459
|
+
* reference put its own somewhere else entirely. **A candidate is authored in
|
|
460
|
+
* its own coordinate system, and under the ladder's honesty rule it could not be
|
|
461
|
+
* authored in any other** — the reference's origin is in the file the author is
|
|
462
|
+
* not allowed to open.
|
|
463
|
+
*
|
|
464
|
+
* So the candidate is framed by its own content. What changed (issue #34) is what
|
|
465
|
+
* "content" means. It used to be the union of the **posed quad corners**, and a
|
|
466
|
+
* region attachment's quad extends past its own artwork wherever the art is
|
|
467
|
+
* transparent, so an outermost corner routinely sat where no pixel was. Combined
|
|
468
|
+
* with a mapping that read only `minX`, `maxY` and the long side, that let one
|
|
469
|
+
* corner of one quad in one frame set the scale for a whole run: rung 5's first
|
|
470
|
+
* correct build reported **MAE 39.00 instead of 4.35** on a box 0.93 % narrow, and
|
|
471
|
+
* rung 4 watched a rotation the pixels cannot see move the reported MAE from 27.6
|
|
472
|
+
* to 84.9 by swinging one corner in and out of the box.
|
|
473
|
+
*
|
|
474
|
+
* Now both sides are measured the same way, **on drawn pixels**:
|
|
475
|
+
*
|
|
476
|
+
* 1. render the candidate at the frames' own rate and grid, and take the content
|
|
477
|
+
* box of what it actually draws (`src/framing.ts`);
|
|
478
|
+
* 2. take the reference's content box off the PNGs with the same predicate;
|
|
479
|
+
* 3. fit the similarity transform — uniform scale plus translation, least squares
|
|
480
|
+
* over **both** width and height — that carries one onto the other, and render
|
|
481
|
+
* through it. No single corner can set the scale, and an invisible margin
|
|
482
|
+
* cannot move it at all.
|
|
483
|
+
*
|
|
484
|
+
* The pass repeats until the correction is the identity, because the correction
|
|
485
|
+
* changes the pixels it was measured on.
|
|
486
|
+
*
|
|
487
|
+
* ⚠️ What this is still not blind to: a candidate that is missing a part, or has
|
|
488
|
+
* an extra one, genuinely has a different content box, and one uniform scale
|
|
489
|
+
* cannot make two different shapes agree. That is no longer silently spent on the
|
|
490
|
+
* framing — it is reported as the fit's **residual** and its aspect error, which
|
|
491
|
+
* is the number to read before reading a drift. `--viewport` pins the box outright
|
|
492
|
+
* when even that is not enough.
|
|
493
|
+
*/
|
|
494
|
+
export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
495
|
+
const located = locateFrames(options.framesDir);
|
|
496
|
+
const notes: string[] = [];
|
|
497
|
+
|
|
498
|
+
const posable = posableFromText(options.skeletonText, options.atlasText, options.atlasDir);
|
|
499
|
+
let background: RGBA;
|
|
500
|
+
let sets: FrameSet[];
|
|
501
|
+
let pixelWidth: number;
|
|
502
|
+
let pixelHeight: number;
|
|
503
|
+
let referenceViewport: Framing | null = null;
|
|
504
|
+
|
|
505
|
+
if (located.sidecar) {
|
|
506
|
+
const s = located.sidecar;
|
|
507
|
+
background = s.background;
|
|
508
|
+
pixelWidth = s.viewport.pixelWidth;
|
|
509
|
+
pixelHeight = s.viewport.pixelHeight;
|
|
510
|
+
referenceViewport = { ...s.viewport };
|
|
511
|
+
sets = located.only.length > 0 ? s.sets.filter((set) => located.only.includes(set.dir)) : s.sets;
|
|
512
|
+
if (options.fps !== undefined && sets.some((set) => set.fps !== options.fps)) {
|
|
513
|
+
throw new CheckError(
|
|
514
|
+
`--fps ${options.fps} disagrees with ${FRAMES_SIDECAR}, which records ` +
|
|
515
|
+
`${[...new Set(sets.map((set) => set.fps))].join(', ')} fps. The frames' own rate is the one they were ` +
|
|
516
|
+
'rendered at; drop --fps, or point --frames at the set you meant',
|
|
517
|
+
);
|
|
518
|
+
}
|
|
519
|
+
} else {
|
|
520
|
+
// No sidecar: the pixel grid comes from the frames themselves, the rate from
|
|
521
|
+
// --fps, and the background from this build's default — and the report says
|
|
522
|
+
// so rather than letting a default look like a measurement.
|
|
523
|
+
const dir = basename(located.root);
|
|
524
|
+
const parent = dirname(located.root);
|
|
525
|
+
const disk = framesOnDisk(parent, dir);
|
|
526
|
+
if (disk.length === 0) throw new CheckError(`no f####.png frames in ${located.root}`);
|
|
527
|
+
const first = readPlateFrom(located.root, disk[0].file);
|
|
528
|
+
pixelWidth = first.width;
|
|
529
|
+
pixelHeight = first.height;
|
|
530
|
+
background = BACKGROUND;
|
|
531
|
+
const fps = options.fps ?? PROTOCOL_FPS;
|
|
532
|
+
const animation = dir.replace(/@\d+(\.\d+)?fps$/, '');
|
|
533
|
+
sets = [
|
|
534
|
+
{
|
|
535
|
+
dir,
|
|
536
|
+
animation: posable.data.animations.length === 0 ? null : animation,
|
|
537
|
+
fps,
|
|
538
|
+
sampled: disk[disk.length - 1].index + 1,
|
|
539
|
+
written: disk.length,
|
|
540
|
+
stride: 1,
|
|
541
|
+
duration: disk[disk.length - 1].index / fps,
|
|
542
|
+
},
|
|
543
|
+
];
|
|
544
|
+
notes.push(
|
|
545
|
+
`no ${FRAMES_SIDECAR} at ${located.root} or beside it — this frame set predates the sidecar. The rate is ` +
|
|
546
|
+
`--fps ${fps}${options.fps === undefined ? ' (the protocol default, not a measurement of these frames)' : ''} ` +
|
|
547
|
+
`and the background is this build's default (${BACKGROUND.join(', ')}). Re-render the set with ` +
|
|
548
|
+
'bench/render_reference.ts and both become facts about the frames.',
|
|
549
|
+
);
|
|
550
|
+
// The set root is the animation directory itself here, so reads resolve
|
|
551
|
+
// against its parent the way a sidecar layout does.
|
|
552
|
+
located.root = parent;
|
|
553
|
+
located.only = [dir];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
if (sets.length === 0) throw new CheckError(`no frame set to compare in ${options.framesDir}`);
|
|
557
|
+
|
|
558
|
+
// Pose every set once. Its frames are wanted twice — to frame the candidate and
|
|
559
|
+
// to compare it — and posing twice is both slower and a chance for the framing
|
|
560
|
+
// and the comparison to disagree about what they measured.
|
|
561
|
+
const prepared = sets.map((set) => prepareSet(located.root, set, posable, options.as));
|
|
562
|
+
const pairs = prepared.flatMap((p) => p.pairs);
|
|
563
|
+
if (pairs.length === 0) {
|
|
564
|
+
notes.push('no reference frame has a candidate frame at the same index — nothing below was measured');
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
const maxSide = Math.max(pixelWidth, pixelHeight);
|
|
568
|
+
// One edge level for both sides, read off the reference frames — see
|
|
569
|
+
// `EDGE_FRACTION`. A handful of frames is enough: the level is a property of the
|
|
570
|
+
// palette, not of a pose, and reading every frame twice to learn it is waste.
|
|
571
|
+
const level = pairs.length === 0 ? BACKGROUND_TOLERANCE : edgeLevelOf(located.root, pairs, background);
|
|
572
|
+
const referenceBoxes =
|
|
573
|
+
pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
|
|
574
|
+
|
|
575
|
+
let viewport: Viewport;
|
|
576
|
+
let framingFit: FramingReport | null = null;
|
|
577
|
+
if (options.viewport) {
|
|
578
|
+
const v = options.viewport;
|
|
579
|
+
viewport = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
|
|
580
|
+
notes.push(
|
|
581
|
+
`the candidate's world box was pinned by --viewport ${v.x},${v.y},${v.width},${v.height} rather than derived ` +
|
|
582
|
+
"from its own pixels — that is a claim about the candidate's coordinates, and nothing here checks it. The " +
|
|
583
|
+
'framing line below is still measured, so it says what the pin cost.',
|
|
584
|
+
);
|
|
585
|
+
const boxes = pairUpBoxes(prepared, posable.pages, viewport, background, level, referenceBoxes);
|
|
586
|
+
if (boxes.length > 0) {
|
|
587
|
+
const fit = fitFraming(boxes);
|
|
588
|
+
framingFit = {
|
|
589
|
+
fit,
|
|
590
|
+
passes: 1,
|
|
591
|
+
settled: false,
|
|
592
|
+
source: 'pinned',
|
|
593
|
+
cycled: false,
|
|
594
|
+
agrees: fitDistance(fit) <= COINCIDENT_PIXELS,
|
|
595
|
+
applied: false,
|
|
596
|
+
units: extentsOf(fit, viewport.scale, referenceViewport),
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
} else {
|
|
600
|
+
if (referenceBoxes.every((b) => b === null)) {
|
|
601
|
+
throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
|
|
602
|
+
}
|
|
603
|
+
const framed = frameCandidate(
|
|
604
|
+
prepared,
|
|
605
|
+
posable.pages,
|
|
606
|
+
referenceBoxes,
|
|
607
|
+
background,
|
|
608
|
+
level,
|
|
609
|
+
pixelWidth,
|
|
610
|
+
pixelHeight,
|
|
611
|
+
referenceViewport,
|
|
612
|
+
);
|
|
613
|
+
viewport = framed.viewport;
|
|
614
|
+
framingFit = { ...framed.report, units: extentsOf(framed.report.fit, viewport.scale, referenceViewport) };
|
|
615
|
+
notes.push(...framingNotes(framed.report));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
const animations: AnimationCheck[] = [];
|
|
619
|
+
for (const p of prepared) {
|
|
620
|
+
animations.push(checkOneSet(located.root, p, posable, viewport, background));
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return {
|
|
624
|
+
candidate: {
|
|
625
|
+
skeleton: options.labels?.skeleton ?? '(in memory)',
|
|
626
|
+
atlas: options.labels?.atlas ?? '(in memory)',
|
|
627
|
+
},
|
|
628
|
+
framesDir: resolve(options.framesDir),
|
|
629
|
+
framesRoot: located.root,
|
|
630
|
+
framing: options.viewport
|
|
631
|
+
? 'viewport-flag'
|
|
632
|
+
: framingFit?.source === 'declared'
|
|
633
|
+
? 'frames-viewport'
|
|
634
|
+
: 'candidate-pixels',
|
|
635
|
+
viewport: {
|
|
636
|
+
x: viewport.minX,
|
|
637
|
+
y: viewport.minY,
|
|
638
|
+
width: viewport.maxX - viewport.minX,
|
|
639
|
+
height: viewport.maxY - viewport.minY,
|
|
640
|
+
scale: viewport.scale,
|
|
641
|
+
pixelWidth: viewport.width,
|
|
642
|
+
pixelHeight: viewport.height,
|
|
643
|
+
},
|
|
644
|
+
framingFit,
|
|
645
|
+
referenceViewport,
|
|
646
|
+
background,
|
|
647
|
+
animations,
|
|
648
|
+
notes,
|
|
649
|
+
};
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
function readPlateFrom(root: string, file: string): Plate {
|
|
653
|
+
readFrameFile(root, file); // the guard; readPlate does the decoding
|
|
654
|
+
return readPlate(file);
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------
|
|
658
|
+
// framing
|
|
659
|
+
// ---------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
/**
|
|
662
|
+
* How many render → measure → correct passes the framing is allowed.
|
|
663
|
+
*
|
|
664
|
+
* A faithful candidate settles on the first look. What the old ceiling of 4 read
|
|
665
|
+
* as "a jitter floor by the third or fourth pass" is, measured properly, an
|
|
666
|
+
* **orbit**: on rung 6 the correction repeats with period 4, so passes 4–7 come
|
|
667
|
+
* back as 8–11 and again as 12–15, to within 0.02 px. Stopping at 4 stopped
|
|
668
|
+
* mid-orbit, on whichever phase pass 4 happened to be — and that phase was the
|
|
669
|
+
* worst of the four, framed 0.063 % off the scale the frames were rendered at and
|
|
670
|
+
* worth **5 MAE points** on that shot (8.73 against a pinned 3.50, issue #52).
|
|
671
|
+
*
|
|
672
|
+
* One more pass reaches the orbit's closest phase and takes the same shot to 5.62;
|
|
673
|
+
* 6, 8, 12, 16 and 24 passes all return that same viewport, because `chosen`
|
|
674
|
+
* keeps the closest pass and the orbit has no better one. So the ceiling is raised
|
|
675
|
+
* to two full periods — enough to see every phase of an orbit this size — and
|
|
676
|
+
* `fitSeparation` stops the loop the moment it recognises one, which costs the
|
|
677
|
+
* cycling case one pass rather than four. A shot that is genuinely still
|
|
678
|
+
* converging is unaffected: it settles and breaks out first.
|
|
679
|
+
*
|
|
680
|
+
* ⚠️ This is not a way to reach the right framing. Nothing in an extent fit can
|
|
681
|
+
* be: at the box `frames.json` records — the one the frames were actually drawn
|
|
682
|
+
* at — rung 6's edge residual is 0.41 px rms, **worse** than the 0.23 px the orbit
|
|
683
|
+
* reaches, because the candidate's silhouette genuinely differs and the best fit
|
|
684
|
+
* of two extents is not the best alignment of two pictures (`fitFraming`, "the
|
|
685
|
+
* floor, stated plainly"). That is what `frameByDeclaredBox` is for.
|
|
686
|
+
*/
|
|
687
|
+
const FRAMING_PASSES = 8;
|
|
688
|
+
|
|
689
|
+
/**
|
|
690
|
+
* How far the candidate's drawn pixels may sit from the reference's and still be
|
|
691
|
+
* called the same place, in pixels.
|
|
692
|
+
*
|
|
693
|
+
* One pixel, and the margin on either side of it is enormous rather than fine.
|
|
694
|
+
* What this threshold has to separate is a candidate authored in the frames' own
|
|
695
|
+
* world coordinates from one authored in its own, and those differ by an origin
|
|
696
|
+
* or a unit — tens to hundreds of pixels — not by a fraction of one. Rung 6's
|
|
697
|
+
* candidate reads 0.45 px in the declared box; rung 3's mechanical transcription
|
|
698
|
+
* reads 0.07; a rig scaled by 2 % reads about 5 before the scale is taken out and
|
|
699
|
+
* about 0.07 after. Nothing measured so far lands between 1 and 5.
|
|
700
|
+
*/
|
|
701
|
+
export const COINCIDENT_PIXELS = 1;
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* The two content boxes in world units, each divided by its own render scale.
|
|
705
|
+
*
|
|
706
|
+
* `null` without a sidecar: the reference's scale is the only thing that makes
|
|
707
|
+
* its pixels into units, and a frame set that predates `frames.json` does not
|
|
708
|
+
* record one. Inventing a default there would print a number that looks measured.
|
|
709
|
+
*/
|
|
710
|
+
function extentsOf(
|
|
711
|
+
fit: FramingFit,
|
|
712
|
+
candidateScale: number,
|
|
713
|
+
referenceViewport: Framing | null,
|
|
714
|
+
): { candidate: Extent; reference: Extent; ratio: number } | null {
|
|
715
|
+
if (!referenceViewport || candidateScale <= 0 || referenceViewport.scale <= 0) return null;
|
|
716
|
+
const candidate = {
|
|
717
|
+
width: boxWidth(fit.candidate) / candidateScale,
|
|
718
|
+
height: boxHeight(fit.candidate) / candidateScale,
|
|
719
|
+
};
|
|
720
|
+
const reference = {
|
|
721
|
+
width: boxWidth(fit.reference) / referenceViewport.scale,
|
|
722
|
+
height: boxHeight(fit.reference) / referenceViewport.scale,
|
|
723
|
+
};
|
|
724
|
+
const area = reference.width * reference.height;
|
|
725
|
+
// Area rather than either side: one ratio for a shot whose two axes can differ.
|
|
726
|
+
const ratio = area > 0 ? Math.sqrt((candidate.width * candidate.height) / area) : 1;
|
|
727
|
+
return { candidate, reference, ratio };
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
/** How many reference frames the edge level is estimated from. */
|
|
731
|
+
const LEVEL_SAMPLES = 8;
|
|
732
|
+
|
|
733
|
+
/** The edge threshold both sides are measured with — see `EDGE_FRACTION`. */
|
|
734
|
+
function edgeLevelOf(root: string, pairs: FramePair[], background: RGBA): number {
|
|
735
|
+
const histogram = new ContrastHistogram();
|
|
736
|
+
const step = Math.max(1, Math.ceil(pairs.length / LEVEL_SAMPLES));
|
|
737
|
+
for (let i = 0; i < pairs.length; i += step) histogram.add(readPlateFrom(root, pairs[i].file), background);
|
|
738
|
+
return histogram.level();
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
/** Each reference frame's own content box, and a check that they are one grid. */
|
|
742
|
+
function referenceContentBoxes(
|
|
743
|
+
root: string,
|
|
744
|
+
pairs: FramePair[],
|
|
745
|
+
background: RGBA,
|
|
746
|
+
level: number,
|
|
747
|
+
pixelWidth: number,
|
|
748
|
+
pixelHeight: number,
|
|
749
|
+
): Array<ContentBox | null> {
|
|
750
|
+
return pairs.map((pair) => {
|
|
751
|
+
const plate = readPlateFrom(root, pair.file);
|
|
752
|
+
if (plate.width !== pixelWidth || plate.height !== pixelHeight) {
|
|
753
|
+
throw new CheckError(
|
|
754
|
+
`${pair.file} is ${plate.width}x${plate.height} but the viewport says ${pixelWidth}x${pixelHeight}; ` +
|
|
755
|
+
'the frames and the sidecar disagree about their own size',
|
|
756
|
+
);
|
|
757
|
+
}
|
|
758
|
+
return contentBoxOfPlate(plate, background, level);
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/**
|
|
763
|
+
* The two content boxes of every frame that has both, in one array.
|
|
764
|
+
*
|
|
765
|
+
* Per frame rather than unioned, because that is what the fit is made from — see
|
|
766
|
+
* `fitFraming`. `referenceBoxes` is indexed the same way `prepared.flatMap(pairs)`
|
|
767
|
+
* is, which is the order it was built in.
|
|
768
|
+
*/
|
|
769
|
+
function pairUpBoxes(
|
|
770
|
+
prepared: PreparedSet[],
|
|
771
|
+
pages: Map<string, Plate>,
|
|
772
|
+
viewport: Viewport,
|
|
773
|
+
background: RGBA,
|
|
774
|
+
level: number,
|
|
775
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
776
|
+
): BoxPair[] {
|
|
777
|
+
const out: BoxPair[] = [];
|
|
778
|
+
let at = 0;
|
|
779
|
+
for (const p of prepared) {
|
|
780
|
+
for (const pair of p.pairs) {
|
|
781
|
+
const reference = referenceBoxes[at++];
|
|
782
|
+
const candidate = frameContentBox(pair.frame, pages, viewport, background, level);
|
|
783
|
+
if (candidate && reference) out.push({ candidate, reference });
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
return out;
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/** One measured pass of the framing loop. */
|
|
790
|
+
interface FramingPass {
|
|
791
|
+
viewport: Viewport;
|
|
792
|
+
fit: FramingFit;
|
|
793
|
+
distance: number;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
/** A chain of passes and why it stopped. */
|
|
797
|
+
interface FramingChain {
|
|
798
|
+
passes: FramingPass[];
|
|
799
|
+
settled: boolean;
|
|
800
|
+
cycled: boolean;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
/**
|
|
804
|
+
* Render → measure → correct, from one starting viewport, until it stops.
|
|
805
|
+
*
|
|
806
|
+
* ## Why it iterates
|
|
807
|
+
*
|
|
808
|
+
* The correction is measured on a render, and applying it changes the render it
|
|
809
|
+
* was measured on. One pass leaves the candidate close; a second measures what is
|
|
810
|
+
* left. It stops as soon as the correction is the identity to within
|
|
811
|
+
* `SETTLED_PIXELS`, which for a faithful candidate is the first look.
|
|
812
|
+
*
|
|
813
|
+
* ## And why it also watches for an orbit
|
|
814
|
+
*
|
|
815
|
+
* When the fit has no fixed point on a shot — which happens whenever the
|
|
816
|
+
* candidate's silhouette genuinely differs, because the fit registers extent and
|
|
817
|
+
* extent is not alignment — the sequence does not wander and does not converge. It
|
|
818
|
+
* **cycles**, and every further pass re-measures a state it has already been in at
|
|
819
|
+
* the cost of a full re-render of every frame. `fitSeparation` recognises that in
|
|
820
|
+
* one comparison per pass, so a cycling shot stops one pass after its orbit closes
|
|
821
|
+
* instead of burning the whole budget, and the report can say which of the two
|
|
822
|
+
* happened. Rung 6 cycles with period 4 (issue #52).
|
|
823
|
+
*/
|
|
824
|
+
function runFramingChain(
|
|
825
|
+
seed: Viewport,
|
|
826
|
+
prepared: PreparedSet[],
|
|
827
|
+
pages: Map<string, Plate>,
|
|
828
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
829
|
+
background: RGBA,
|
|
830
|
+
level: number,
|
|
831
|
+
pixelWidth: number,
|
|
832
|
+
pixelHeight: number,
|
|
833
|
+
cap: number,
|
|
834
|
+
): FramingChain {
|
|
835
|
+
let viewport = seed;
|
|
836
|
+
const passes: FramingPass[] = [];
|
|
837
|
+
for (let pass = 1; pass <= cap; pass++) {
|
|
838
|
+
const boxes = pairUpBoxes(prepared, pages, viewport, background, level, referenceBoxes);
|
|
839
|
+
// A viewport the candidate draws nothing into ends the chain rather than
|
|
840
|
+
// failing it, and both ways of reaching one are real. The declared box gets
|
|
841
|
+
// there on its first pass whenever the candidate is authored somewhere else
|
|
842
|
+
// entirely — rung 1's `drop` candidate is nowhere near the frames' own world
|
|
843
|
+
// box — which is the declared path being refused, not an error. And a chain
|
|
844
|
+
// that does not converge can walk its own box off its content later on, where
|
|
845
|
+
// the answer is the closest pass already measured. The caller decides what an
|
|
846
|
+
// empty chain means; only the fitted path treats it as a failure.
|
|
847
|
+
if (boxes.length === 0) return { passes, settled: false, cycled: false };
|
|
848
|
+
const fit = fitFraming(boxes);
|
|
849
|
+
const cycled = passes.some((seen) => fitSeparation(fit, seen.fit) < CYCLE_PIXELS);
|
|
850
|
+
passes.push({ viewport, fit, distance: fitDistance(fit) });
|
|
851
|
+
if (fitIsSettled(fit)) return { passes, settled: true, cycled: false };
|
|
852
|
+
if (cycled) return { passes, settled: false, cycled: true };
|
|
853
|
+
viewport = applyFit(viewport, fit, pixelWidth, pixelHeight);
|
|
854
|
+
}
|
|
855
|
+
return { passes, settled: false, cycled: false };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* The pass whose correction is closest to the identity.
|
|
860
|
+
*
|
|
861
|
+
* ⚠️ The **closest** pass rather than the last, and the viewport handed back is
|
|
862
|
+
* therefore one that was actually MEASURED, with the fit beside it being what it
|
|
863
|
+
* still leaves over — never a correction applied on the way out and never looked
|
|
864
|
+
* at. Near the answer the correction can jitter or orbit instead of converging, so
|
|
865
|
+
* taking the last pass would hand back whichever phase the loop happened to stop
|
|
866
|
+
* on, and applying one more unverified correction is a coin flip. A report that
|
|
867
|
+
* describes a viewport nobody rendered is worse than a slightly worse viewport.
|
|
868
|
+
*/
|
|
869
|
+
function closestPass(chain: FramingChain): FramingPass {
|
|
870
|
+
let best = chain.passes[0];
|
|
871
|
+
for (const pass of chain.passes) if (pass.distance < best.distance) best = pass;
|
|
872
|
+
return best;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Put the candidate's drawn pixels on the reference's drawn pixels.
|
|
877
|
+
*
|
|
878
|
+
* Two ways in, and the second is tried first because when it applies it is exact
|
|
879
|
+
* rather than estimated — see `frameByDeclaredBox`. The fitted path is the general
|
|
880
|
+
* one and the only one available without a sidecar.
|
|
881
|
+
*
|
|
882
|
+
* ## Why the fitted start is trimmed
|
|
883
|
+
*
|
|
884
|
+
* The starting box is the union of the posed quads **trimmed to their opaque
|
|
885
|
+
* texels**, not the quads themselves. It is only a starting point — the framing is
|
|
886
|
+
* fitted on rendered pixels either way — but the fit's landing point depends on
|
|
887
|
+
* where it starts, so a start that moved with an invisible margin would leave the
|
|
888
|
+
* margin able to move the answer after all, by a fraction of a pixel instead of by
|
|
889
|
+
* two. With the trim, art padded on both sides is byte-identical work: same start,
|
|
890
|
+
* same passes, same numbers. `selftest` C03 asserts exactly that.
|
|
891
|
+
*/
|
|
892
|
+
function frameCandidate(
|
|
893
|
+
prepared: PreparedSet[],
|
|
894
|
+
pages: Map<string, Plate>,
|
|
895
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
896
|
+
background: RGBA,
|
|
897
|
+
level: number,
|
|
898
|
+
pixelWidth: number,
|
|
899
|
+
pixelHeight: number,
|
|
900
|
+
referenceViewport: Framing | null,
|
|
901
|
+
): { viewport: Viewport; report: Omit<FramingReport, 'units'> } {
|
|
902
|
+
const declared = frameByDeclaredBox(
|
|
903
|
+
prepared,
|
|
904
|
+
pages,
|
|
905
|
+
referenceBoxes,
|
|
906
|
+
background,
|
|
907
|
+
level,
|
|
908
|
+
pixelWidth,
|
|
909
|
+
pixelHeight,
|
|
910
|
+
referenceViewport,
|
|
911
|
+
);
|
|
912
|
+
if (declared) return declared;
|
|
913
|
+
|
|
914
|
+
const quads = trimmedUnionBounds(
|
|
915
|
+
prepared.map((p) => p.pairs.map((pair) => pair.frame)),
|
|
916
|
+
pages,
|
|
917
|
+
);
|
|
918
|
+
if (!Number.isFinite(quads.minX)) {
|
|
919
|
+
throw new CheckError('the candidate posed no drawable attachment in any frame that was compared');
|
|
920
|
+
}
|
|
921
|
+
const pad = Math.max(quads.maxX - quads.minX, quads.maxY - quads.minY) * PAD;
|
|
922
|
+
const world = {
|
|
923
|
+
minX: quads.minX - pad,
|
|
924
|
+
minY: quads.minY - pad,
|
|
925
|
+
maxX: quads.maxX + pad,
|
|
926
|
+
maxY: quads.maxY + pad,
|
|
927
|
+
};
|
|
928
|
+
const maxSide = Math.max(pixelWidth, pixelHeight);
|
|
929
|
+
const seed = viewportOfSize(
|
|
930
|
+
world.minX,
|
|
931
|
+
world.minY,
|
|
932
|
+
world.maxX - world.minX,
|
|
933
|
+
world.maxY - world.minY,
|
|
934
|
+
maxSide / Math.max(world.maxX - world.minX, world.maxY - world.minY),
|
|
935
|
+
pixelWidth,
|
|
936
|
+
pixelHeight,
|
|
937
|
+
);
|
|
938
|
+
const chain = runFramingChain(
|
|
939
|
+
seed,
|
|
940
|
+
prepared,
|
|
941
|
+
pages,
|
|
942
|
+
referenceBoxes,
|
|
943
|
+
background,
|
|
944
|
+
level,
|
|
945
|
+
pixelWidth,
|
|
946
|
+
pixelHeight,
|
|
947
|
+
FRAMING_PASSES,
|
|
948
|
+
);
|
|
949
|
+
if (chain.passes.length === 0) {
|
|
950
|
+
throw new CheckError('the candidate drew no pixel in any frame that was compared');
|
|
951
|
+
}
|
|
952
|
+
const chosen = closestPass(chain);
|
|
953
|
+
return {
|
|
954
|
+
viewport: chosen.viewport,
|
|
955
|
+
report: {
|
|
956
|
+
fit: chosen.fit,
|
|
957
|
+
passes: chain.passes.length,
|
|
958
|
+
settled: chain.settled,
|
|
959
|
+
source: 'derived',
|
|
960
|
+
cycled: chain.cycled,
|
|
961
|
+
agrees: chosen.distance <= COINCIDENT_PIXELS,
|
|
962
|
+
applied: true,
|
|
963
|
+
},
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* The box `frames.json` records, used as the candidate's own — when, and only
|
|
969
|
+
* when, the candidate's pixels are measured to land in it.
|
|
970
|
+
*
|
|
971
|
+
* ## 🔒 This is not reading the answer
|
|
972
|
+
*
|
|
973
|
+
* The thing the honesty rule protects is the reference **skeleton** — its bones,
|
|
974
|
+
* its keys, its curves — and none of that is here. `frames.json` is a sidecar
|
|
975
|
+
* `check` already reads, whose `viewport` it already prints, and which
|
|
976
|
+
* [`docs/AUTHORING.md`](../docs/AUTHORING.md) §9 already tells an author to hand
|
|
977
|
+
* back through `--viewport`. What changes is only that the tool now *checks* the
|
|
978
|
+
* condition the guide asks the author to assert, instead of requiring them to
|
|
979
|
+
* notice it and type it.
|
|
980
|
+
*
|
|
981
|
+
* ## Why a declared box beats a fitted one
|
|
982
|
+
*
|
|
983
|
+
* A fit is an estimate with a floor. `fitFraming` registers two shots by their
|
|
984
|
+
* **extent**, so when the candidate's silhouette genuinely differs the best fit of
|
|
985
|
+
* the extents is about a third of a pixel away from the best alignment of the
|
|
986
|
+
* pictures — and on a small high-contrast shot a third of a pixel is several MAE.
|
|
987
|
+
* Rung 6 measures that floor as a 5-point tax: 8.73 fitted against 3.50 in the box
|
|
988
|
+
* the frames were actually drawn at, with every content box, residual and rms
|
|
989
|
+
* under the method's own noise (issue #52).
|
|
990
|
+
*
|
|
991
|
+
* The declared box has no such floor. It is not an estimate of where the frames
|
|
992
|
+
* were drawn; it is where they were drawn. So the only question is whether it
|
|
993
|
+
* applies to *this* candidate, and that is one measurement: render the candidate
|
|
994
|
+
* into the declared box, fit, and keep the box when the correction it asks for is
|
|
995
|
+
* under `COINCIDENT_PIXELS`. Nothing is corrected and nothing is iterated —
|
|
996
|
+
* either the candidate is in the frames' coordinates or it is not.
|
|
997
|
+
*
|
|
998
|
+
* ⚠️ **Correcting the declared box makes it worse, measured.** The obvious extra
|
|
999
|
+
* step — accept it, then run the usual passes from there to polish — was written
|
|
1000
|
+
* and measured, and it walks off the answer: rung 5's candidate, whose author
|
|
1001
|
+
* matched the reference's world box by hand, reads **4.35** at the declared box and
|
|
1002
|
+
* **6.24** after three refining passes, and 4.35 is the figure `fitFraming` records
|
|
1003
|
+
* as this shot's correct framing. Rung 6 reads 3.50 at the box and drifts the same
|
|
1004
|
+
* way. The refinement is the extent fit, and the extent fit is exactly what the
|
|
1005
|
+
* declared box is here to avoid.
|
|
1006
|
+
*
|
|
1007
|
+
* A candidate authored in its own coordinates — the ordinary case, and the one the
|
|
1008
|
+
* ladder's honesty rule guarantees — misses by a wide margin and is framed by the
|
|
1009
|
+
* fitted path exactly as before. Rung 3's candidate put its origin on the pendulum's
|
|
1010
|
+
* pivot and the reference put its own elsewhere; in the declared box that candidate
|
|
1011
|
+
* reports MAE 146/255, and its fit says so long before the pixels are ever compared.
|
|
1012
|
+
* Rung 1's `drop` candidate draws nothing at all in the declared box.
|
|
1013
|
+
*
|
|
1014
|
+
* ⚠️ So does a rig in the frames' coordinates at **different units**, which is a
|
|
1015
|
+
* choice the framing must stay blind to (`selftest` C04). It is refused here and
|
|
1016
|
+
* framed by the fitted path, where the blindness lives — and it therefore pays the
|
|
1017
|
+
* fit's floor where a same-units candidate does not. Recovering the unit from the
|
|
1018
|
+
* fit and scaling the declared box by it was measured too: on a rig scaled by 2 %
|
|
1019
|
+
* it recovers 1.0196 against a true 1.02 and lands two thirds of the way back, which
|
|
1020
|
+
* is more machinery for a case no candidate in the corpus has and still not exact.
|
|
1021
|
+
*/
|
|
1022
|
+
function frameByDeclaredBox(
|
|
1023
|
+
prepared: PreparedSet[],
|
|
1024
|
+
pages: Map<string, Plate>,
|
|
1025
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
1026
|
+
background: RGBA,
|
|
1027
|
+
level: number,
|
|
1028
|
+
pixelWidth: number,
|
|
1029
|
+
pixelHeight: number,
|
|
1030
|
+
referenceViewport: Framing | null,
|
|
1031
|
+
): { viewport: Viewport; report: Omit<FramingReport, 'units'> } | null {
|
|
1032
|
+
if (!referenceViewport || referenceViewport.scale <= 0) return null;
|
|
1033
|
+
const viewport = viewportOfSize(
|
|
1034
|
+
referenceViewport.x,
|
|
1035
|
+
referenceViewport.y,
|
|
1036
|
+
referenceViewport.width,
|
|
1037
|
+
referenceViewport.height,
|
|
1038
|
+
referenceViewport.scale,
|
|
1039
|
+
pixelWidth,
|
|
1040
|
+
pixelHeight,
|
|
1041
|
+
);
|
|
1042
|
+
const boxes = pairUpBoxes(prepared, pages, viewport, background, level, referenceBoxes);
|
|
1043
|
+
// Nothing drawn in the declared box is the loudest possible "not these
|
|
1044
|
+
// coordinates", not a failure: rung 1's `drop` candidate is nowhere near it.
|
|
1045
|
+
if (boxes.length === 0) return null;
|
|
1046
|
+
const fit = fitFraming(boxes);
|
|
1047
|
+
const distance = fitDistance(fit);
|
|
1048
|
+
if (distance > COINCIDENT_PIXELS) return null;
|
|
1049
|
+
return {
|
|
1050
|
+
viewport,
|
|
1051
|
+
report: {
|
|
1052
|
+
fit,
|
|
1053
|
+
passes: 1,
|
|
1054
|
+
settled: fitIsSettled(fit),
|
|
1055
|
+
source: 'declared',
|
|
1056
|
+
cycled: false,
|
|
1057
|
+
agrees: true,
|
|
1058
|
+
applied: true,
|
|
1059
|
+
},
|
|
1060
|
+
};
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* What the framing pass concluded, in the words that tell the three cases apart.
|
|
1065
|
+
*
|
|
1066
|
+
* The distinction the report used to be missing (issue #52): "did not settle, and
|
|
1067
|
+
* the two shots are nevertheless in the same place" is the tool reaching its own
|
|
1068
|
+
* floor, "did not settle, and they are not" is a finding about the candidate, and
|
|
1069
|
+
* "the correction is cycling" says more passes cannot change either answer.
|
|
1070
|
+
*/
|
|
1071
|
+
function framingNotes(report: Omit<FramingReport, 'units'>): string[] {
|
|
1072
|
+
if (report.source === 'declared') {
|
|
1073
|
+
return [
|
|
1074
|
+
`the candidate's world box was taken from ${FRAMES_SIDECAR} rather than fitted, because rendering it into ` +
|
|
1075
|
+
`that box put its own drawn pixels on the reference's to within ${report.fit.rms.toFixed(2)} px rms — so ` +
|
|
1076
|
+
"the candidate is authored in the frames' own coordinates, measured rather than assumed, and the box they " +
|
|
1077
|
+
'were rendered at is exact where a fit of it is an estimate. The framing line below is still measured; what ' +
|
|
1078
|
+
`it leaves over is the extent fit's own floor${report.settled ? '' : ', which is why it does not read as the identity'}. ` +
|
|
1079
|
+
'Pass --viewport to override.',
|
|
1080
|
+
];
|
|
1081
|
+
}
|
|
1082
|
+
if (report.settled) return [];
|
|
1083
|
+
const how = report.cycled
|
|
1084
|
+
? `the framing correction fell into a repeating orbit after ${report.passes} pass(es) rather than settling, so ` +
|
|
1085
|
+
'more passes cannot help: the fit has no fixed point on this shot'
|
|
1086
|
+
: `the framing did not settle in ${report.passes} pass(es)`;
|
|
1087
|
+
return [
|
|
1088
|
+
report.agrees
|
|
1089
|
+
? `${how}. The two content boxes nevertheless agree to within ${report.fit.rms.toFixed(2)} px rms, so this is ` +
|
|
1090
|
+
"the fit's own floor and not a shape mismatch — the fit registers extent, and on a silhouette that differs " +
|
|
1091
|
+
'anywhere the best fit of the extents is not the best alignment of the pictures. Pass --viewport to pin the ' +
|
|
1092
|
+
'box when you know your own coordinates.'
|
|
1093
|
+
: `${how}, and the two content boxes do not agree either — the correction below is what is left over after ` +
|
|
1094
|
+
'the closest pass. A residual much larger than a pixel means the two shots are different shapes, which is ' +
|
|
1095
|
+
'a finding about the candidate rather than about the loop.',
|
|
1096
|
+
];
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ---------------------------------------------------------------------------
|
|
1100
|
+
// posing the candidate against one frame set
|
|
1101
|
+
// ---------------------------------------------------------------------------
|
|
1102
|
+
|
|
1103
|
+
/** One reference frame and the candidate frame that shares its index. */
|
|
1104
|
+
interface FramePair {
|
|
1105
|
+
index: number;
|
|
1106
|
+
file: string;
|
|
1107
|
+
frame: Frame;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
/** One frame set, posed and paired up with the frames on disk. */
|
|
1111
|
+
interface PreparedSet {
|
|
1112
|
+
set: FrameSet;
|
|
1113
|
+
candidateAnimation: string | null;
|
|
1114
|
+
candidateFrames: number;
|
|
1115
|
+
referenceFrames: number;
|
|
1116
|
+
pairs: FramePair[];
|
|
1117
|
+
notes: string[];
|
|
1118
|
+
/** Set when nothing could be compared at all, saying why. */
|
|
1119
|
+
missing: string | null;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function prepareSet(
|
|
1123
|
+
root: string,
|
|
1124
|
+
set: FrameSet,
|
|
1125
|
+
posable: ReturnType<typeof posableFromText>,
|
|
1126
|
+
as: string | undefined,
|
|
1127
|
+
): PreparedSet {
|
|
1128
|
+
const notes: string[] = [];
|
|
1129
|
+
const wanted = as ?? set.animation;
|
|
1130
|
+
const have = posable.data.animations.map((a) => a.name);
|
|
1131
|
+
const disk = framesOnDisk(root, set.dir);
|
|
1132
|
+
|
|
1133
|
+
if (wanted !== null && !have.includes(wanted)) {
|
|
1134
|
+
return {
|
|
1135
|
+
set,
|
|
1136
|
+
candidateAnimation: null,
|
|
1137
|
+
candidateFrames: 0,
|
|
1138
|
+
referenceFrames: disk.length,
|
|
1139
|
+
pairs: [],
|
|
1140
|
+
notes: [],
|
|
1141
|
+
missing:
|
|
1142
|
+
`the candidate has no animation called ${JSON.stringify(wanted)} — it has [${have.join(', ') || 'none'}]. ` +
|
|
1143
|
+
'Nothing was compared for this set; name the candidate animation with --as <name> if it is called ' +
|
|
1144
|
+
'something else.',
|
|
1145
|
+
};
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
let candidateFrames: Frame[];
|
|
1149
|
+
let candidateAnimation: string | null;
|
|
1150
|
+
if (wanted === null) {
|
|
1151
|
+
if (have.length > 0) {
|
|
1152
|
+
notes.push(
|
|
1153
|
+
`these frames are a setup pose (the skeleton that made them has no animation), but the candidate has ` +
|
|
1154
|
+
`[${have.join(', ')}] — the setup pose is what was compared`,
|
|
1155
|
+
);
|
|
1156
|
+
}
|
|
1157
|
+
candidateFrames = sampleSetupPose(posable.data);
|
|
1158
|
+
candidateAnimation = null;
|
|
1159
|
+
} else {
|
|
1160
|
+
candidateFrames = sampleAnimation(posable.data, wanted, set.fps);
|
|
1161
|
+
candidateAnimation = wanted;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
if (candidateFrames.length !== set.sampled) {
|
|
1165
|
+
notes.push(
|
|
1166
|
+
`the candidate samples to ${candidateFrames.length} frame(s) at ${set.fps} fps where the reference sampled ` +
|
|
1167
|
+
`${set.sampled} — the two animations do not last the same time, and only the frames both have were compared`,
|
|
1168
|
+
);
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
const byIndex = new Map<number, Frame>();
|
|
1172
|
+
for (const frame of candidateFrames) byIndex.set(frame.index, frame);
|
|
1173
|
+
const pairs: FramePair[] = [];
|
|
1174
|
+
for (const { index, file } of disk) {
|
|
1175
|
+
const frame = byIndex.get(index);
|
|
1176
|
+
if (frame) pairs.push({ index, file, frame });
|
|
1177
|
+
}
|
|
1178
|
+
if (pairs.length === 0 && disk.length > 0) {
|
|
1179
|
+
notes.push(`none of the ${disk.length} reference frame(s) has a candidate frame at the same index`);
|
|
1180
|
+
}
|
|
1181
|
+
return {
|
|
1182
|
+
set,
|
|
1183
|
+
candidateAnimation,
|
|
1184
|
+
candidateFrames: candidateFrames.length,
|
|
1185
|
+
referenceFrames: disk.length,
|
|
1186
|
+
pairs,
|
|
1187
|
+
notes,
|
|
1188
|
+
missing: null,
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function checkOneSet(
|
|
1193
|
+
root: string,
|
|
1194
|
+
prepared: PreparedSet,
|
|
1195
|
+
posable: ReturnType<typeof posableFromText>,
|
|
1196
|
+
viewport: Viewport,
|
|
1197
|
+
background: RGBA,
|
|
1198
|
+
): AnimationCheck {
|
|
1199
|
+
const { set } = prepared;
|
|
1200
|
+
const blank: AnimationCheck = {
|
|
1201
|
+
dir: set.dir,
|
|
1202
|
+
animation: set.animation,
|
|
1203
|
+
candidateAnimation: prepared.candidateAnimation,
|
|
1204
|
+
fps: set.fps,
|
|
1205
|
+
referenceFrames: prepared.referenceFrames,
|
|
1206
|
+
candidateFrames: prepared.candidateFrames,
|
|
1207
|
+
compared: 0,
|
|
1208
|
+
meanMae: 0,
|
|
1209
|
+
meanMaeFrame: 0,
|
|
1210
|
+
worstMae: 0,
|
|
1211
|
+
worstMaeFrame: -1,
|
|
1212
|
+
worstDrift: 0,
|
|
1213
|
+
worstDriftFrame: -1,
|
|
1214
|
+
worstDriftSlot: null,
|
|
1215
|
+
framesWithoutDrift: 0,
|
|
1216
|
+
changePairs: 0,
|
|
1217
|
+
changeDisagreements: 0,
|
|
1218
|
+
worstChangeFrame: -1,
|
|
1219
|
+
frames: [],
|
|
1220
|
+
notes: prepared.missing ? [prepared.missing] : prepared.notes,
|
|
1221
|
+
};
|
|
1222
|
+
if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
|
|
1223
|
+
|
|
1224
|
+
const frames: FrameCheck[] = [];
|
|
1225
|
+
let maeSum = 0;
|
|
1226
|
+
let maeFrameSum = 0;
|
|
1227
|
+
let worstMae = 0;
|
|
1228
|
+
let worstMaeFrame = -1;
|
|
1229
|
+
let worstDrift = 0;
|
|
1230
|
+
let worstDriftFrame = -1;
|
|
1231
|
+
let worstDriftSlot: string | null = null;
|
|
1232
|
+
let framesWithoutDrift = 0;
|
|
1233
|
+
let changePairs = 0;
|
|
1234
|
+
let changeDisagreements = 0;
|
|
1235
|
+
let worstChangeFrame = -1;
|
|
1236
|
+
let worstChangeGap = 0;
|
|
1237
|
+
// The previous frame's two plates, kept so each side can be compared against
|
|
1238
|
+
// ITSELF a frame earlier. Both are already rendered or read for this frame, so
|
|
1239
|
+
// holding one frame of each costs one extra plate and no extra work.
|
|
1240
|
+
let previous: { index: number; candidate: Plate; reference: Plate } | null = null;
|
|
1241
|
+
|
|
1242
|
+
for (const { index, file, frame } of prepared.pairs) {
|
|
1243
|
+
const reference = readPlateFrom(root, file);
|
|
1244
|
+
const rendered = renderFrame(frame, posable.pages, viewport, background);
|
|
1245
|
+
const check = checkOneFrame(index, file, frame, posable.pages, viewport, background, reference, rendered);
|
|
1246
|
+
check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
|
|
1247
|
+
previous = { index, candidate: rendered, reference };
|
|
1248
|
+
frames.push(check);
|
|
1249
|
+
maeSum += check.mae;
|
|
1250
|
+
maeFrameSum += check.maeFrame;
|
|
1251
|
+
if (check.attributed === 0) framesWithoutDrift++;
|
|
1252
|
+
if (check.change) {
|
|
1253
|
+
changePairs++;
|
|
1254
|
+
if (check.change.verdict !== 'agrees') {
|
|
1255
|
+
changeDisagreements++;
|
|
1256
|
+
const gap = Math.abs(check.change.candidate - check.change.reference);
|
|
1257
|
+
if (gap > worstChangeGap) {
|
|
1258
|
+
worstChangeGap = gap;
|
|
1259
|
+
worstChangeFrame = index;
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
}
|
|
1263
|
+
if (check.mae > worstMae) {
|
|
1264
|
+
worstMae = check.mae;
|
|
1265
|
+
worstMaeFrame = index;
|
|
1266
|
+
}
|
|
1267
|
+
if (check.worstDrift !== null && check.worstDrift > worstDrift) {
|
|
1268
|
+
worstDrift = check.worstDrift;
|
|
1269
|
+
worstDriftFrame = index;
|
|
1270
|
+
worstDriftSlot = check.worstSlot;
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
return {
|
|
1275
|
+
...blank,
|
|
1276
|
+
compared: frames.length,
|
|
1277
|
+
meanMae: maeSum / frames.length,
|
|
1278
|
+
meanMaeFrame: maeFrameSum / frames.length,
|
|
1279
|
+
worstMae,
|
|
1280
|
+
worstMaeFrame,
|
|
1281
|
+
worstDrift,
|
|
1282
|
+
worstDriftFrame,
|
|
1283
|
+
worstDriftSlot,
|
|
1284
|
+
framesWithoutDrift,
|
|
1285
|
+
changePairs,
|
|
1286
|
+
changeDisagreements,
|
|
1287
|
+
worstChangeFrame,
|
|
1288
|
+
frames,
|
|
1289
|
+
notes: prepared.notes,
|
|
1290
|
+
};
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
/**
|
|
1294
|
+
* How far a channel must move for a pixel to count as having **changed**.
|
|
1295
|
+
*
|
|
1296
|
+
* The same threshold `isContent` uses to decide there is anything there at all,
|
|
1297
|
+
* and for the same reason: below it the difference is the rasteriser's own last
|
|
1298
|
+
* bit, and a measure that counts those reports every frame as moving.
|
|
1299
|
+
*/
|
|
1300
|
+
export const CHANGE_TOLERANCE = BACKGROUND_TOLERANCE;
|
|
1301
|
+
|
|
1302
|
+
/**
|
|
1303
|
+
* How many times more one side has to move than the other to be a disagreement,
|
|
1304
|
+
* when **both** of them moved.
|
|
1305
|
+
*
|
|
1306
|
+
* Four, with `CHANGE_EXCESS` beside it, because a ratio alone means nothing on
|
|
1307
|
+
* small counts. Together the two read: *four times as much, and at least two dozen
|
|
1308
|
+
* pixels more.*
|
|
1309
|
+
*/
|
|
1310
|
+
export const CHANGE_RATIO = 4;
|
|
1311
|
+
|
|
1312
|
+
/**
|
|
1313
|
+
* ...and how many pixels more, when both sides moved.
|
|
1314
|
+
*
|
|
1315
|
+
* Measured rather than picked. Across the corpus's two mechanically faithful
|
|
1316
|
+
* transcriptions — the same skeleton on both sides, where the true answer is
|
|
1317
|
+
* "identical" — the largest excess between two adjacent frames that clears
|
|
1318
|
+
* `CHANGE_RATIO` at all is **12 px**, on one pair out of 152. Twenty-four is double
|
|
1319
|
+
* that, and the case it has to keep is rung 6's broken plateau at 91 against 3.
|
|
1320
|
+
*/
|
|
1321
|
+
export const CHANGE_EXCESS = 24;
|
|
1322
|
+
|
|
1323
|
+
/**
|
|
1324
|
+
* Did this side move materially more than that one?
|
|
1325
|
+
*
|
|
1326
|
+
* ⭐ **Stillness is categorical and gets no floor**, which is the reason this is a
|
|
1327
|
+
* predicate and not a threshold. A held pose is held *exactly* — rung 6's reference
|
|
1328
|
+
* is pixel-identical across f64-f67 — and a one-frame event is as small as the
|
|
1329
|
+
* thing it reveals, which on that same shot is **three pixels**. A floor big enough
|
|
1330
|
+
* to be safe about a moving frame would be big enough to hide both, so the two
|
|
1331
|
+
* regimes are separated instead: against a still side, moving at all is the
|
|
1332
|
+
* finding; against a moving side, `CHANGE_RATIO` and `CHANGE_EXCESS` apply.
|
|
1333
|
+
* Measured: neither faithful transcription has a single pair where one side is
|
|
1334
|
+
* still and the other is not.
|
|
1335
|
+
*/
|
|
1336
|
+
function disagrees(mine: number, theirs: number): boolean {
|
|
1337
|
+
if (mine === 0) return false;
|
|
1338
|
+
if (theirs === 0) return true;
|
|
1339
|
+
return mine > theirs * CHANGE_RATIO && mine - theirs > CHANGE_EXCESS;
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
/**
|
|
1343
|
+
* One frame against the frame before it, on each side, and what that says.
|
|
1344
|
+
*
|
|
1345
|
+
* ⚠️ Over the **whole frame**, and not over either side's content mask the way the
|
|
1346
|
+
* MAE is. The omission is deliberate: a change is a change wherever it happens, and
|
|
1347
|
+
* masking it would hide precisely the case where one side draws something the other
|
|
1348
|
+
* does not — which is half of what this measure exists for. A one-frame reveal
|
|
1349
|
+
* appears on background pixels by definition.
|
|
1350
|
+
*/
|
|
1351
|
+
function frameChange(
|
|
1352
|
+
previous: { index: number; candidate: Plate; reference: Plate },
|
|
1353
|
+
candidate: Plate,
|
|
1354
|
+
reference: Plate,
|
|
1355
|
+
): FrameChange {
|
|
1356
|
+
const mine = plateDelta(previous.candidate, candidate);
|
|
1357
|
+
const theirs = plateDelta(previous.reference, reference);
|
|
1358
|
+
return {
|
|
1359
|
+
previous: previous.index,
|
|
1360
|
+
candidate: mine.pixels,
|
|
1361
|
+
reference: theirs.pixels,
|
|
1362
|
+
candidateMae: mine.mae,
|
|
1363
|
+
referenceMae: theirs.mae,
|
|
1364
|
+
verdict: disagrees(mine.pixels, theirs.pixels)
|
|
1365
|
+
? 'moves'
|
|
1366
|
+
: disagrees(theirs.pixels, mine.pixels)
|
|
1367
|
+
? 'holds'
|
|
1368
|
+
: 'agrees',
|
|
1369
|
+
};
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* Changed pixels and mean absolute RGB difference between two plates of one size.
|
|
1374
|
+
*
|
|
1375
|
+
* Straight over `Plate.data` rather than through `Plate.get`, because this runs
|
|
1376
|
+
* twice per compared frame over the whole grid and `get` allocates a four-element
|
|
1377
|
+
* array per pixel. On the ladder's largest set that difference is most of what this
|
|
1378
|
+
* measure costs.
|
|
1379
|
+
*/
|
|
1380
|
+
function plateDelta(before: Plate, after: Plate): { pixels: number; mae: number } {
|
|
1381
|
+
const a = before.data;
|
|
1382
|
+
const b = after.data;
|
|
1383
|
+
const count = after.width * after.height;
|
|
1384
|
+
let pixels = 0;
|
|
1385
|
+
let sum = 0;
|
|
1386
|
+
for (let i = 0; i < count * 4; i += 4) {
|
|
1387
|
+
const dr = Math.abs(a[i] - b[i]);
|
|
1388
|
+
const dg = Math.abs(a[i + 1] - b[i + 1]);
|
|
1389
|
+
const db = Math.abs(a[i + 2] - b[i + 2]);
|
|
1390
|
+
sum += dr + dg + db;
|
|
1391
|
+
if (dr > CHANGE_TOLERANCE || dg > CHANGE_TOLERANCE || db > CHANGE_TOLERANCE) pixels++;
|
|
1392
|
+
}
|
|
1393
|
+
return { pixels, mae: sum / 3 / count };
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
function checkOneFrame(
|
|
1397
|
+
index: number,
|
|
1398
|
+
file: string,
|
|
1399
|
+
frame: Frame,
|
|
1400
|
+
pages: Map<string, Plate>,
|
|
1401
|
+
viewport: Viewport,
|
|
1402
|
+
background: RGBA,
|
|
1403
|
+
reference: Plate,
|
|
1404
|
+
/** The candidate's own frame, rendered by the caller — it needs it too. */
|
|
1405
|
+
rendered: Plate,
|
|
1406
|
+
): FrameCheck {
|
|
1407
|
+
const { coverage, footprints } = frameGeometry(frame, pages, viewport);
|
|
1408
|
+
|
|
1409
|
+
let union = 0;
|
|
1410
|
+
let candidatePixels = 0;
|
|
1411
|
+
let referencePixels = 0;
|
|
1412
|
+
let sum = 0;
|
|
1413
|
+
let sumAll = 0;
|
|
1414
|
+
for (let y = 0; y < viewport.height; y++) {
|
|
1415
|
+
for (let x = 0; x < viewport.width; x++) {
|
|
1416
|
+
const inCandidate = coverage[y * viewport.width + x] === 1;
|
|
1417
|
+
const inReference = isContent(reference, x, y, background);
|
|
1418
|
+
if (inCandidate) candidatePixels++;
|
|
1419
|
+
if (inReference) referencePixels++;
|
|
1420
|
+
const a = rendered.get(x, y);
|
|
1421
|
+
const b = reference.get(x, y);
|
|
1422
|
+
const delta = (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
|
|
1423
|
+
sumAll += delta;
|
|
1424
|
+
if (!inCandidate && !inReference) continue;
|
|
1425
|
+
union++;
|
|
1426
|
+
sum += delta;
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
|
|
1430
|
+
const components = componentsOf(reference, background);
|
|
1431
|
+
const { tracks, matchedComponents } = matchSlots(footprints, components, {
|
|
1432
|
+
frame,
|
|
1433
|
+
pages,
|
|
1434
|
+
viewport,
|
|
1435
|
+
background,
|
|
1436
|
+
reference,
|
|
1437
|
+
});
|
|
1438
|
+
|
|
1439
|
+
let worstDrift: number | null = null;
|
|
1440
|
+
let worstSlot: string | null = null;
|
|
1441
|
+
let attributed = 0;
|
|
1442
|
+
let drawn = 0;
|
|
1443
|
+
for (const track of tracks) {
|
|
1444
|
+
if (track.candidate !== null) drawn++;
|
|
1445
|
+
if (!isAttributable(track)) continue;
|
|
1446
|
+
attributed++;
|
|
1447
|
+
if (worstDrift === null || (track.drift as number) > worstDrift) {
|
|
1448
|
+
worstDrift = track.drift;
|
|
1449
|
+
worstSlot = track.slot;
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
return {
|
|
1454
|
+
index,
|
|
1455
|
+
file,
|
|
1456
|
+
mae: union === 0 ? 0 : sum / union,
|
|
1457
|
+
maeFrame: sumAll / (viewport.width * viewport.height),
|
|
1458
|
+
unionPixels: union,
|
|
1459
|
+
candidatePixels,
|
|
1460
|
+
referencePixels,
|
|
1461
|
+
components: components.length,
|
|
1462
|
+
unmatchedComponents: components.length - matchedComponents,
|
|
1463
|
+
worstSlot,
|
|
1464
|
+
worstDrift,
|
|
1465
|
+
attributed,
|
|
1466
|
+
drawn,
|
|
1467
|
+
slots: tracks,
|
|
1468
|
+
// Filled in by the caller, which is the only place that has the frame before
|
|
1469
|
+
// this one — see `frameChange`.
|
|
1470
|
+
change: null,
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// ---------------------------------------------------------------------------
|
|
1475
|
+
// the report
|
|
1476
|
+
// ---------------------------------------------------------------------------
|
|
1477
|
+
|
|
1478
|
+
/** How many worst frames a set prints when the whole set is too long to list. */
|
|
1479
|
+
export const WORST_FRAMES = 8;
|
|
1480
|
+
/** Sets no longer than this print every frame. */
|
|
1481
|
+
const LIST_EVERY = 24;
|
|
1482
|
+
|
|
1483
|
+
const f2 = (n: number): string => n.toFixed(2);
|
|
1484
|
+
|
|
1485
|
+
export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }): string[] {
|
|
1486
|
+
const lines: string[] = [];
|
|
1487
|
+
lines.push(` candidate ${report.candidate.skeleton}`);
|
|
1488
|
+
lines.push(` atlas ${report.candidate.atlas}`);
|
|
1489
|
+
lines.push(` frames ${report.framesDir}`);
|
|
1490
|
+
const v = report.viewport;
|
|
1491
|
+
const how =
|
|
1492
|
+
report.framing === 'candidate-pixels'
|
|
1493
|
+
? "fitted to the candidate's own drawn pixels"
|
|
1494
|
+
: report.framing === 'frames-viewport'
|
|
1495
|
+
? `${FRAMES_SIDECAR}'s own box — the candidate measured into it`
|
|
1496
|
+
: '--viewport';
|
|
1497
|
+
lines.push(
|
|
1498
|
+
` framed to ${v.pixelWidth}x${v.pixelHeight}px ${v.scale.toFixed(6)} px/unit ` +
|
|
1499
|
+
`world x[${v.x.toFixed(1)} .. ${(v.x + v.width).toFixed(1)}] y[${v.y.toFixed(1)} .. ${(v.y + v.height).toFixed(1)}] (${how})`,
|
|
1500
|
+
);
|
|
1501
|
+
const r = report.referenceViewport;
|
|
1502
|
+
if (r) {
|
|
1503
|
+
lines.push(
|
|
1504
|
+
` reference ${r.pixelWidth}x${r.pixelHeight}px ${r.scale.toFixed(6)} px/unit ` +
|
|
1505
|
+
`world x[${r.x.toFixed(1)} .. ${(r.x + r.width).toFixed(1)}] y[${r.y.toFixed(1)} .. ${(r.y + r.height).toFixed(1)}] (${FRAMES_SIDECAR})`,
|
|
1506
|
+
);
|
|
1507
|
+
lines.push(' ⤷ the two world boxes are different coordinate systems and do not compare; the pixel grid does.');
|
|
1508
|
+
}
|
|
1509
|
+
for (const line of framingLines(report)) lines.push(line);
|
|
1510
|
+
for (const note of report.notes) lines.push(` ⚠️ ${note}`);
|
|
1511
|
+
lines.push('');
|
|
1512
|
+
|
|
1513
|
+
for (const anim of report.animations) {
|
|
1514
|
+
const played =
|
|
1515
|
+
anim.candidateAnimation !== null
|
|
1516
|
+
? `candidate animation ${JSON.stringify(anim.candidateAnimation)}`
|
|
1517
|
+
: anim.animation === null
|
|
1518
|
+
? 'setup pose'
|
|
1519
|
+
: 'nothing in the candidate to play against it';
|
|
1520
|
+
lines.push(` ── ${anim.dir} — ${played}, ${anim.fps} fps ──`);
|
|
1521
|
+
lines.push(
|
|
1522
|
+
` frames ${anim.referenceFrames} on disk, candidate samples ${anim.candidateFrames}, ${anim.compared} compared`,
|
|
1523
|
+
);
|
|
1524
|
+
for (const note of anim.notes) lines.push(` ⚠️ ${note}`);
|
|
1525
|
+
if (anim.compared === 0) {
|
|
1526
|
+
lines.push('');
|
|
1527
|
+
continue;
|
|
1528
|
+
}
|
|
1529
|
+
lines.push(
|
|
1530
|
+
` MAE mean ${f2(anim.meanMae)} worst ${f2(anim.worstMae)} at f${String(anim.worstMaeFrame).padStart(4, '0')}` +
|
|
1531
|
+
` (0..255 over the union alpha; over the whole frame, mean ${f2(anim.meanMaeFrame)})`,
|
|
1532
|
+
);
|
|
1533
|
+
const blind =
|
|
1534
|
+
anim.framesWithoutDrift === 0
|
|
1535
|
+
? ''
|
|
1536
|
+
: ` (${anim.framesWithoutDrift} of ${anim.compared} frame(s) attributed no slot at all)`;
|
|
1537
|
+
lines.push(
|
|
1538
|
+
anim.worstDriftFrame < 0
|
|
1539
|
+
? ` slot drift no slot could be attributed in any of the ${anim.compared} frame(s) — read the MAE instead`
|
|
1540
|
+
: ` slot drift worst ${anim.worstDrift.toFixed(1)} px ${JSON.stringify(anim.worstDriftSlot)} at ` +
|
|
1541
|
+
`f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
|
|
1542
|
+
);
|
|
1543
|
+
lines.push(changeSummary(anim));
|
|
1544
|
+
lines.push('');
|
|
1545
|
+
|
|
1546
|
+
const listed = framesToList(anim, opts?.allFrames === true);
|
|
1547
|
+
const heading =
|
|
1548
|
+
listed.length === anim.frames.length
|
|
1549
|
+
? 'every frame'
|
|
1550
|
+
: `the ${listed.length} frames worth reading — worst by MAE, plus every frame whose own change disagrees`;
|
|
1551
|
+
lines.push(` ${heading}, in index order`);
|
|
1552
|
+
lines.push(' frame MAE union px Δpx ref Δ worst slot drift how slots note');
|
|
1553
|
+
for (const frame of listed) {
|
|
1554
|
+
const worst = frame.slots.find((s) => s.slot === frame.worstSlot) ?? null;
|
|
1555
|
+
const drift = frame.worstDrift === null ? ' —' : `${frame.worstDrift.toFixed(1).padStart(5)}`;
|
|
1556
|
+
const how =
|
|
1557
|
+
worst === null
|
|
1558
|
+
? '— '
|
|
1559
|
+
: worst.method === 'template'
|
|
1560
|
+
? `tmpl ${(worst.confidence ?? 0).toFixed(2)}`
|
|
1561
|
+
: 'component ';
|
|
1562
|
+
const note = [changeNote(frame.change), frame.unmatchedComponents > 0 ? `${frame.unmatchedComponents} reference component(s) no slot reaches` : '']
|
|
1563
|
+
.filter(Boolean)
|
|
1564
|
+
.join('; ');
|
|
1565
|
+
const change = frame.change
|
|
1566
|
+
? `${String(frame.change.candidate).padStart(7)}${String(frame.change.reference).padStart(7)}`
|
|
1567
|
+
: `${'—'.padStart(7)}${'—'.padStart(7)}`;
|
|
1568
|
+
lines.push(
|
|
1569
|
+
` f${String(frame.index).padStart(4, '0')} ${f2(frame.mae).padStart(8)} ${String(frame.unionPixels).padStart(9)} ${change} ` +
|
|
1570
|
+
`${(frame.worstSlot ?? '—').padEnd(20)} ${drift} ${how.padEnd(9)} ${String(frame.attributed)}/${String(frame.drawn)}` +
|
|
1571
|
+
`${note ? ` ${note}` : ''}`,
|
|
1572
|
+
);
|
|
1573
|
+
}
|
|
1574
|
+
lines.push('');
|
|
1575
|
+
}
|
|
1576
|
+
|
|
1577
|
+
lines.push(' MAE is the mean absolute RGB difference over the pixels either side covers, so it is');
|
|
1578
|
+
lines.push(' read against 255 and not against a threshold: there is no pass mark here any more than');
|
|
1579
|
+
lines.push(' there is one in `diff`. Read the framing line first: it is upstream of every number');
|
|
1580
|
+
lines.push(' below, and a residual much wider than a pixel moves all of them at once.');
|
|
1581
|
+
lines.push(' The slots column is how many of the drawn slots could be attributed at all. A drift');
|
|
1582
|
+
lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
|
|
1583
|
+
lines.push(' merged it into a neighbour; the number beside it is how much better that match was');
|
|
1584
|
+
lines.push(' than its best rival, and a slot that matched nothing at all is left out of the count.');
|
|
1585
|
+
lines.push(' `Δpx` and `ref Δ` are how many pixels each side moved since ITS OWN previous frame —');
|
|
1586
|
+
lines.push(' not against each other. They are the only columns that can see a held pose that is');
|
|
1587
|
+
lines.push(' not held, or a one-frame event that never fired: both are small in every frame and');
|
|
1588
|
+
lines.push(' wrong only in the relation between two, which is where the MAE cannot look.');
|
|
1589
|
+
return lines;
|
|
1590
|
+
}
|
|
1591
|
+
|
|
1592
|
+
/**
|
|
1593
|
+
* The frames worth printing: the worst by MAE, plus every change disagreement.
|
|
1594
|
+
*
|
|
1595
|
+
* The union matters rather than being tidy. The defects `FrameChange` exists to
|
|
1596
|
+
* catch are **cheap in MAE by construction** — a plateau sloped through by a
|
|
1597
|
+
* fraction of a pixel, a three-pixel reveal that did not fire — so a listing
|
|
1598
|
+
* ranked by MAE is exactly the listing that leaves them out. Rung 6's f65–f68 sit
|
|
1599
|
+
* near the bottom of that ranking.
|
|
1600
|
+
*/
|
|
1601
|
+
function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
|
|
1602
|
+
if (allFrames || anim.frames.length <= LIST_EVERY) return anim.frames;
|
|
1603
|
+
const chosen = new Set(
|
|
1604
|
+
[...anim.frames]
|
|
1605
|
+
.sort((a, b) => b.mae - a.mae)
|
|
1606
|
+
.slice(0, WORST_FRAMES)
|
|
1607
|
+
.map((f) => f.index),
|
|
1608
|
+
);
|
|
1609
|
+
for (const frame of anim.frames) if (frame.change && frame.change.verdict !== 'agrees') chosen.add(frame.index);
|
|
1610
|
+
return anim.frames.filter((f) => chosen.has(f.index));
|
|
1611
|
+
}
|
|
1612
|
+
|
|
1613
|
+
/** The per-frame change measure, as the animation's own summary line. */
|
|
1614
|
+
function changeSummary(anim: AnimationCheck): string {
|
|
1615
|
+
if (anim.changePairs === 0) {
|
|
1616
|
+
return (
|
|
1617
|
+
' per-frame no two compared frames are adjacent, so nothing was measured about how much this shot ' +
|
|
1618
|
+
'changes from frame to frame'
|
|
1619
|
+
);
|
|
1620
|
+
}
|
|
1621
|
+
if (anim.changeDisagreements === 0) {
|
|
1622
|
+
return ` per-frame all ${anim.changePairs} adjacent pair(s) change by as much as the reference's own frames do`;
|
|
1623
|
+
}
|
|
1624
|
+
const worst = anim.frames.find((f) => f.index === anim.worstChangeFrame);
|
|
1625
|
+
const at =
|
|
1626
|
+
worst && worst.change
|
|
1627
|
+
? `; worst f${String(worst.index).padStart(4, '0')}, yours moved ${worst.change.candidate} px where the ` +
|
|
1628
|
+
`reference moved ${worst.change.reference}`
|
|
1629
|
+
: '';
|
|
1630
|
+
return (
|
|
1631
|
+
` per-frame ${anim.changeDisagreements} of ${anim.changePairs} adjacent pair(s) change by a different ` +
|
|
1632
|
+
`amount than the reference does${at}`
|
|
1633
|
+
);
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
/** What one frame's change disagreement says, in the words that name the defect. */
|
|
1637
|
+
function changeNote(change: FrameChange | null): string {
|
|
1638
|
+
if (!change || change.verdict === 'agrees') return '';
|
|
1639
|
+
if (change.verdict === 'moves') {
|
|
1640
|
+
return change.reference === 0
|
|
1641
|
+
? 'the reference holds still here and yours does not'
|
|
1642
|
+
: `yours moves ${(change.candidate / Math.max(1, change.reference)).toFixed(0)}x the reference`;
|
|
1643
|
+
}
|
|
1644
|
+
return change.candidate === 0
|
|
1645
|
+
? 'the reference moves here and yours holds still'
|
|
1646
|
+
: `yours moves ${(change.reference / Math.max(1, change.candidate)).toFixed(0)}x less than the reference`;
|
|
1647
|
+
}
|
|
1648
|
+
|
|
1649
|
+
/**
|
|
1650
|
+
* What the fit did, in one word.
|
|
1651
|
+
*
|
|
1652
|
+
* The declared box is never "unsettled" — it was not being iterated towards, it
|
|
1653
|
+
* was measured and kept, and `coincident` says the measurement that kept it.
|
|
1654
|
+
*/
|
|
1655
|
+
function convergence(framing: FramingReport): string {
|
|
1656
|
+
if (framing.settled) return 'settled';
|
|
1657
|
+
if (framing.source === 'declared') return 'coincident';
|
|
1658
|
+
return framing.cycled ? 'cycling' : 'unsettled';
|
|
1659
|
+
}
|
|
1660
|
+
|
|
1661
|
+
/** The framing, as the line an author reads before anything else. */
|
|
1662
|
+
function framingLines(report: CheckReport): string[] {
|
|
1663
|
+
const framing = report.framingFit;
|
|
1664
|
+
if (!framing) return [];
|
|
1665
|
+
const { fit } = framing;
|
|
1666
|
+
const c = fit.candidate;
|
|
1667
|
+
const r = fit.reference;
|
|
1668
|
+
const percent = (n: number): string => `${n >= 0 ? '+' : ''}${(n * 100).toFixed(2)}%`;
|
|
1669
|
+
const box = (b: ContentBox): string =>
|
|
1670
|
+
`${boxWidth(b).toFixed(1)}x${boxHeight(b).toFixed(1)}px at (${b.left.toFixed(1)}, ${b.top.toFixed(1)})`;
|
|
1671
|
+
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
1672
|
+
const out = [
|
|
1673
|
+
` content candidate ${box(c)} reference ${box(r)} (union over ${fit.frames} frame(s))`,
|
|
1674
|
+
` ⤷ fit x${fit.scale.toFixed(6)} offset ${signed(fit.dx)}, ${signed(fit.dy)} px ` +
|
|
1675
|
+
`rms ${fit.rms.toFixed(2)} px over ${fit.frames * 4} edge(s) ` +
|
|
1676
|
+
`union residual ${signed(fit.residualWidth)} x ${signed(fit.residualHeight)} px ` +
|
|
1677
|
+
`aspect ${percent(fit.aspectError)}` +
|
|
1678
|
+
(framing.applied
|
|
1679
|
+
? ` (${framing.source}, ${framing.passes} pass(es), ${convergence(framing)})`
|
|
1680
|
+
: ' (measured, NOT applied — --viewport pinned)'),
|
|
1681
|
+
];
|
|
1682
|
+
const spread = Math.max(Math.abs(fit.residualWidth), Math.abs(fit.residualHeight));
|
|
1683
|
+
if (spread > 1) {
|
|
1684
|
+
const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
|
|
1685
|
+
out.push(
|
|
1686
|
+
` ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
|
|
1687
|
+
`${axis} and ${Math.abs(fit.residualHeight).toFixed(1)} px ` +
|
|
1688
|
+
`${fit.residualHeight > 0 ? 'taller' : 'shorter'} than the reference's. One uniform scale cannot absorb ` +
|
|
1689
|
+
'that: something reaches somewhere nothing in the frames does, or is a different size. Read it before ' +
|
|
1690
|
+
'reading a drift.',
|
|
1691
|
+
);
|
|
1692
|
+
}
|
|
1693
|
+
if (fit.rms > 1) {
|
|
1694
|
+
out.push(
|
|
1695
|
+
` ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
|
|
1696
|
+
'scale and offset puts the two shots on each other — they are different shapes, not the same shape ' +
|
|
1697
|
+
'misframed.',
|
|
1698
|
+
);
|
|
1699
|
+
}
|
|
1700
|
+
const units = framing.units;
|
|
1701
|
+
if (units) {
|
|
1702
|
+
out.push(
|
|
1703
|
+
` in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
|
|
1704
|
+
`reference ${units.reference.width.toFixed(1)} x ${units.reference.height.toFixed(1)} ` +
|
|
1705
|
+
`x${units.ratio.toFixed(4)}`,
|
|
1706
|
+
);
|
|
1707
|
+
out.push(
|
|
1708
|
+
' ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ' +
|
|
1709
|
+
'purpose — a rig is authored in its own coordinates — so this is the only place one shows. It compares ' +
|
|
1710
|
+
'only if you measured the shot in the frames’ own units.',
|
|
1711
|
+
);
|
|
1712
|
+
}
|
|
1713
|
+
return out;
|
|
1714
|
+
}
|