spine-rigc 0.2.1 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +231 -6
- package/cli.ts +39 -5
- package/docs/AUTHORING.md +611 -24
- package/docs/SPEC_COVERAGE.md +16 -10
- package/package.json +5 -2
- package/src/chains.ts +170 -0
- package/src/check.ts +1013 -92
- package/src/compile.ts +325 -6
- package/src/ladder.ts +1 -1
- package/src/render.ts +22 -2
- package/src/rig.ts +169 -6
- package/src/timelines.ts +9 -5
- package/src/types.ts +80 -1
- package/src/validate.ts +192 -2
package/src/check.ts
CHANGED
|
@@ -77,6 +77,7 @@ import {
|
|
|
77
77
|
PAD,
|
|
78
78
|
FRAMES_SIDECAR,
|
|
79
79
|
FRAMES_SPEC,
|
|
80
|
+
type Footprint,
|
|
80
81
|
type Frame,
|
|
81
82
|
type FramesSidecar,
|
|
82
83
|
type FrameSet,
|
|
@@ -93,6 +94,7 @@ import {
|
|
|
93
94
|
fitIsSettled,
|
|
94
95
|
fitSeparation,
|
|
95
96
|
frameContentBox,
|
|
97
|
+
unionBoxes,
|
|
96
98
|
isContent,
|
|
97
99
|
BACKGROUND_TOLERANCE,
|
|
98
100
|
CYCLE_PIXELS,
|
|
@@ -100,10 +102,12 @@ import {
|
|
|
100
102
|
type ContentBox,
|
|
101
103
|
type FramingFit,
|
|
102
104
|
} from './framing.ts';
|
|
103
|
-
import { componentsOf, isAttributable, matchSlots, type SlotTrack } from './slots.ts';
|
|
105
|
+
import { componentsOf, isAttributable, matchSlots, searchRadius, type SlotTrack } from './slots.ts';
|
|
106
|
+
import { chainsOf, type BoneChain } from './chains.ts';
|
|
104
107
|
import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
|
|
105
108
|
|
|
106
109
|
export { componentsOf, matchSlots, searchRadius, type Component, type MatchMethod, type SlotTrack } from './slots.ts';
|
|
110
|
+
export { chainsOf, chainBySlot, type BoneChain } from './chains.ts';
|
|
107
111
|
export type { BoxPair, ContentBox, FramingFit } from './framing.ts';
|
|
108
112
|
|
|
109
113
|
// ---------------------------------------------------------------------------
|
|
@@ -259,6 +263,25 @@ export interface FrameCheck {
|
|
|
259
263
|
file: string;
|
|
260
264
|
/** Mean absolute RGB difference over the union alpha, 0..255. */
|
|
261
265
|
mae: number;
|
|
266
|
+
/**
|
|
267
|
+
* The same total difference over the REFERENCE's own drawn pixels alone.
|
|
268
|
+
*
|
|
269
|
+
* ⭐ The figure to optimise against, and the reason is the denominator. `mae`
|
|
270
|
+
* divides by the pixels either side drew, and the candidate owns half of that:
|
|
271
|
+
* drawing something large and mostly transparent adds many cheap pixels to the
|
|
272
|
+
* union and the *mean falls*, so an optimiser can buy a better score by growing
|
|
273
|
+
* (issue #119 — a muzzle flare walked its own scale to 13x doing exactly this).
|
|
274
|
+
* This denominator is the reference's, which nothing the candidate does can
|
|
275
|
+
* move, so the only way down is to draw the reference's picture.
|
|
276
|
+
*
|
|
277
|
+
* ⚠️ Not bounded by 255, and deliberately: a candidate that draws far more than
|
|
278
|
+
* the reference has more absolute error than the reference has pixels to carry
|
|
279
|
+
* it, and the figure says so instead of saturating.
|
|
280
|
+
*
|
|
281
|
+
* `mae` is still the right figure for comparing two builds of the same rig,
|
|
282
|
+
* where the union is near enough the same on both sides.
|
|
283
|
+
*/
|
|
284
|
+
maeReference: number;
|
|
262
285
|
/**
|
|
263
286
|
* The same difference averaged over the WHOLE frame, background included.
|
|
264
287
|
*
|
|
@@ -285,6 +308,60 @@ export interface FrameCheck {
|
|
|
285
308
|
change: FrameChange | null;
|
|
286
309
|
}
|
|
287
310
|
|
|
311
|
+
/**
|
|
312
|
+
* One bone chain's slice of a set — the row an author reads before deciding what
|
|
313
|
+
* to re-key.
|
|
314
|
+
*
|
|
315
|
+
* The chains come from the CANDIDATE's bone tree (`src/chains.ts` owns the rule
|
|
316
|
+
* and the reasoning); the reference stays pixels, so this is a decomposition of
|
|
317
|
+
* your own figure and never a reading of the answer.
|
|
318
|
+
*/
|
|
319
|
+
export interface ChainCheck {
|
|
320
|
+
/** The chain, named as `src/chains.ts` names it. */
|
|
321
|
+
chain: string;
|
|
322
|
+
/** How many slots it owns. */
|
|
323
|
+
slots: number;
|
|
324
|
+
/** How many of those drew anything in at least one compared frame. */
|
|
325
|
+
drewSlots: number;
|
|
326
|
+
/** The worst attributable slot drift anywhere in it, in frame pixels. */
|
|
327
|
+
worstDrift: number;
|
|
328
|
+
/** Which slot that was, and in which frame — `null`/`-1` when none was attributable. */
|
|
329
|
+
worstDriftSlot: string | null;
|
|
330
|
+
worstDriftFrame: number;
|
|
331
|
+
/** The mean of every attributable slot drift in it, over `driftSamples` of them. */
|
|
332
|
+
meanDrift: number;
|
|
333
|
+
driftSamples: number;
|
|
334
|
+
/** How many frames contributed at least one of those samples. */
|
|
335
|
+
driftFrames: number;
|
|
336
|
+
/**
|
|
337
|
+
* The absolute RGB difference attributed to this chain, summed over the
|
|
338
|
+
* REFERENCE's own drawn pixels — never over the union.
|
|
339
|
+
*
|
|
340
|
+
* ⭐ The denominator lesson from issue #119, applied to a share. A reference
|
|
341
|
+
* pixel goes to the chain whose ink is nearest to it, so the chains partition
|
|
342
|
+
* the reference's drawn pixels and the shares add up to the whole. What the
|
|
343
|
+
* candidate controls is only *which* chain a pixel lands in, and growing a
|
|
344
|
+
* chain's ink pulls MORE of the reference's pixels — and their error — into it.
|
|
345
|
+
* There is no move here that makes a chain look better by drawing more, which is
|
|
346
|
+
* exactly what the union MAE could not say.
|
|
347
|
+
*/
|
|
348
|
+
error: number;
|
|
349
|
+
/** How many reference-drawn pixels it took, summed over frames. */
|
|
350
|
+
referencePixels: number;
|
|
351
|
+
/**
|
|
352
|
+
* `error` per pixel it took — the MAE *inside* this chain, 0..255.
|
|
353
|
+
*
|
|
354
|
+
* Printed beside the share because the share alone confounds being wrong with
|
|
355
|
+
* being big: spineboy's head, goggles, eye and mouth are one chain covering a
|
|
356
|
+
* lot of the figure, so it can carry a third of the error at a per-pixel figure
|
|
357
|
+
* below the run's own mean. The share says where the error IS; this says whether
|
|
358
|
+
* the chain is actually worse than the rest of the figure.
|
|
359
|
+
*/
|
|
360
|
+
mae: number;
|
|
361
|
+
/** `error` over the set's own total, 0..1 — see `AnimationCheck.chainDenominator`. */
|
|
362
|
+
maeShare: number;
|
|
363
|
+
}
|
|
364
|
+
|
|
288
365
|
export interface AnimationCheck {
|
|
289
366
|
dir: string;
|
|
290
367
|
/** The animation the frames show, per the sidecar. */
|
|
@@ -296,6 +373,18 @@ export interface AnimationCheck {
|
|
|
296
373
|
candidateFrames: number;
|
|
297
374
|
compared: number;
|
|
298
375
|
meanMae: number;
|
|
376
|
+
/** Mean of the per-frame reference-denominator MAE — see `FrameCheck.maeReference`. */
|
|
377
|
+
meanMaeReference: number;
|
|
378
|
+
/**
|
|
379
|
+
* How much this set draws, against how much the reference draws: the mean over
|
|
380
|
+
* its frames of `candidatePixels / referencePixels`.
|
|
381
|
+
*
|
|
382
|
+
* 1 means the two shots put ink on the same amount of the frame. Above 1 the
|
|
383
|
+
* candidate is drawing more than the reference does, which is the move that
|
|
384
|
+
* makes the union MAE cheaper — see `OVERDRAW_RATIO`, which is where the
|
|
385
|
+
* threshold and the corpus it came from are written down.
|
|
386
|
+
*/
|
|
387
|
+
drawnRatio: number;
|
|
299
388
|
/** Mean of the per-frame whole-frame MAE — see `FrameCheck.maeFrame`. */
|
|
300
389
|
meanMaeFrame: number;
|
|
301
390
|
worstMae: number;
|
|
@@ -311,7 +400,38 @@ export interface AnimationCheck {
|
|
|
311
400
|
changeDisagreements: number;
|
|
312
401
|
/** The widest of those disagreements, and `-1` when there is none. */
|
|
313
402
|
worstChangeFrame: number;
|
|
403
|
+
/**
|
|
404
|
+
* This set, broken down by the candidate's own bone chains — see `ChainCheck`.
|
|
405
|
+
*
|
|
406
|
+
* Chains that own no slot at all are left out: they have nothing to attribute.
|
|
407
|
+
* They are still in `CheckReport.chains`, so the roster stays a complete account
|
|
408
|
+
* of where every bone went.
|
|
409
|
+
*/
|
|
410
|
+
chains: ChainCheck[];
|
|
411
|
+
/**
|
|
412
|
+
* The set's whole difference over the reference's own drawn pixels — the
|
|
413
|
+
* denominator every `ChainCheck.maeShare` divides by.
|
|
414
|
+
*
|
|
415
|
+
* The same numerator `meanMaeReference` averages, kept as a total because a
|
|
416
|
+
* share needs the total and a mean has already divided it away.
|
|
417
|
+
*/
|
|
418
|
+
chainDenominator: number;
|
|
419
|
+
/** The part of it no chain could take, because the candidate drew nothing at all. */
|
|
420
|
+
unattributedError: number;
|
|
314
421
|
frames: FrameCheck[];
|
|
422
|
+
/**
|
|
423
|
+
* The box THIS set's candidate frames were rendered into.
|
|
424
|
+
*
|
|
425
|
+
* Under the default per-shot scope every set carries its own, and they are
|
|
426
|
+
* different boxes; under `--framing shared` they are all the same one. Either
|
|
427
|
+
* way it is here rather than only at the top of the report, because it is
|
|
428
|
+
* upstream of every number in this row.
|
|
429
|
+
*/
|
|
430
|
+
viewport: Framing;
|
|
431
|
+
/** How this set's box was chosen — see `FramingSource`. */
|
|
432
|
+
framing: FramingHow;
|
|
433
|
+
/** Where this set's drawn pixels ended up against the reference's. */
|
|
434
|
+
framingFit: FramingReport | null;
|
|
315
435
|
notes: string[];
|
|
316
436
|
}
|
|
317
437
|
|
|
@@ -339,6 +459,44 @@ export interface Framing {
|
|
|
339
459
|
*/
|
|
340
460
|
export type FramingSource = 'derived' | 'declared' | 'pinned';
|
|
341
461
|
|
|
462
|
+
/** The same three, named for the report line rather than for the code path. */
|
|
463
|
+
export type FramingHow = 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Whether the framing is decided per frame set, or once across every set.
|
|
467
|
+
*
|
|
468
|
+
* ## What `per-shot` actually scopes, and why only that
|
|
469
|
+
*
|
|
470
|
+
* A framing is decided over the frames it is measured on, so pointing `check` at a
|
|
471
|
+
* skeleton root used to decide ONE for every set under it — and one badly-framed
|
|
472
|
+
* shot was then paid for by all the others. Measured on the spineboy rung, 8 shots
|
|
473
|
+
* and 147 frames: `idle` read **41.59** MAE at the root against the **18.77** it
|
|
474
|
+
* reads on its own frames, with not one key different (issue #100).
|
|
475
|
+
*
|
|
476
|
+
* `per-shot` moves exactly one decision into the set: **whether the box
|
|
477
|
+
* `frames.json` records is this set's box too.** That decision is a measurement
|
|
478
|
+
* with no floor — either the set's own drawn pixels land in the declared box or
|
|
479
|
+
* they do not — and over the union one shot that does not can put the pooled
|
|
480
|
+
* correction over `COINCIDENT_PIXELS` and take every other shot down with it. Per
|
|
481
|
+
* set, the ones that qualify read exactly what pinning by hand reads.
|
|
482
|
+
*
|
|
483
|
+
* ⚠️ It does NOT fit a separate chain per set, and that is a measured decision
|
|
484
|
+
* rather than a simplification. Per-set FITTING is worse: `fitFraming` registers
|
|
485
|
+
* extent, extent is not alignment, and one shot's frames do not constrain that
|
|
486
|
+
* enough — spineboy's `hit` reads 92.36 fitted on its own against 60.59 in the
|
|
487
|
+
* shared fit, and its two-frame `shoot@30fps` set reads 101.94 against 42.98. So a
|
|
488
|
+
* set that cannot take the declared box is measured in the shared framing, where
|
|
489
|
+
* every frame in the run constrains the answer.
|
|
490
|
+
*
|
|
491
|
+
* `shared` is the old behaviour, and it answers one question well: *does a single
|
|
492
|
+
* box serve every set?* The report prints that fit either way — see
|
|
493
|
+
* `CheckReport.sharedFraming`.
|
|
494
|
+
*
|
|
495
|
+
* ⚠️ The two are different measurements and their absolute numbers are not
|
|
496
|
+
* comparable across builds. The report says which one it did.
|
|
497
|
+
*/
|
|
498
|
+
export type FramingScope = 'per-shot' | 'shared';
|
|
499
|
+
|
|
342
500
|
/** What the framing pass concluded, and how sure it is of it. */
|
|
343
501
|
export interface FramingReport {
|
|
344
502
|
/** The residual fit measured at the viewport that was used. */
|
|
@@ -401,12 +559,35 @@ export interface CheckReport {
|
|
|
401
559
|
candidate: { skeleton: string; atlas: string };
|
|
402
560
|
framesDir: string;
|
|
403
561
|
framesRoot: string;
|
|
404
|
-
/**
|
|
405
|
-
|
|
562
|
+
/** One framing per set, or one across every set — see `FramingScope`. */
|
|
563
|
+
framingScope: FramingScope;
|
|
564
|
+
/**
|
|
565
|
+
* How the candidate's own world box was chosen, when ONE box covers the run.
|
|
566
|
+
*
|
|
567
|
+
* `null` under a per-shot scope with more than one set compared: there is no
|
|
568
|
+
* single answer then, and each `AnimationCheck` carries its own. A run that
|
|
569
|
+
* compared exactly one set fills these in whatever the scope, because for one
|
|
570
|
+
* set the two scopes are the same measurement.
|
|
571
|
+
*/
|
|
572
|
+
framing: FramingHow | null;
|
|
406
573
|
/** The box the CANDIDATE was rendered into, at the reference's pixel size. */
|
|
407
|
-
viewport: Framing;
|
|
574
|
+
viewport: Framing | null;
|
|
408
575
|
/** Where the candidate's drawn pixels ended up against the reference's. */
|
|
409
576
|
framingFit: FramingReport | null;
|
|
577
|
+
/**
|
|
578
|
+
* The framing ONE shared box gives across every set compared.
|
|
579
|
+
*
|
|
580
|
+
* Under `per-shot` it is both reported and used: every set that cannot take the
|
|
581
|
+
* frames' own declared box is measured in it. It is also the figure that says
|
|
582
|
+
* *why* a whole-root run is a different measurement — a set that reads well in
|
|
583
|
+
* the declared box and badly here is a set the old whole-root run was measuring
|
|
584
|
+
* through somebody else's silhouette, which is what `idle` reading 41.59 against
|
|
585
|
+
* 18.77 was (issue #100).
|
|
586
|
+
*
|
|
587
|
+
* `null` when the scope is already shared — `framingFit` is that number then —
|
|
588
|
+
* and when only one set was compared, where the two scopes are the same thing.
|
|
589
|
+
*/
|
|
590
|
+
sharedFraming: FramingReport | null;
|
|
410
591
|
/**
|
|
411
592
|
* The box the REFERENCE was rendered into, when the sidecar records one.
|
|
412
593
|
*
|
|
@@ -416,6 +597,14 @@ export interface CheckReport {
|
|
|
416
597
|
*/
|
|
417
598
|
referenceViewport: Framing | null;
|
|
418
599
|
background: RGBA;
|
|
600
|
+
/**
|
|
601
|
+
* The candidate's bone tree, cut into chains — the roster the report prints.
|
|
602
|
+
*
|
|
603
|
+
* Printed rather than assumed, because a decomposition an author has to guess at
|
|
604
|
+
* is one they will read wrong: the table says `front-thigh` and the roster says
|
|
605
|
+
* which bones and which slots that name covers. `src/chains.ts` owns the rule.
|
|
606
|
+
*/
|
|
607
|
+
chains: BoneChain[];
|
|
419
608
|
animations: AnimationCheck[];
|
|
420
609
|
notes: string[];
|
|
421
610
|
}
|
|
@@ -443,6 +632,15 @@ export interface CheckOptions {
|
|
|
443
632
|
viewport?: { x: number; y: number; width: number; height: number };
|
|
444
633
|
/** Play this candidate animation against the frames, when the names differ. */
|
|
445
634
|
as?: string;
|
|
635
|
+
/**
|
|
636
|
+
* Fit one framing per frame set, or one across every set compared.
|
|
637
|
+
*
|
|
638
|
+
* Defaults to `per-shot`. See `FramingScope` for what the choice costs and why
|
|
639
|
+
* this is the default; it has no effect when only one set is compared, and none
|
|
640
|
+
* when `viewport` pins the box (a pin is a claim about the candidate's own
|
|
641
|
+
* coordinates, and those do not change between shots).
|
|
642
|
+
*/
|
|
643
|
+
framing?: FramingScope;
|
|
446
644
|
}
|
|
447
645
|
|
|
448
646
|
// ---------------------------------------------------------------------------
|
|
@@ -572,34 +770,56 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
572
770
|
const referenceBoxes =
|
|
573
771
|
pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
|
|
574
772
|
|
|
575
|
-
|
|
576
|
-
|
|
773
|
+
const scope: FramingScope = options.framing ?? 'per-shot';
|
|
774
|
+
const slices = sliceBySet(prepared, referenceBoxes);
|
|
775
|
+
/** One per prepared set, in `prepared` order. */
|
|
776
|
+
const framings: SetFraming[] = [];
|
|
777
|
+
let topViewport: Viewport | null = null;
|
|
778
|
+
let topHow: FramingHow | null = null;
|
|
779
|
+
let topFit: FramingReport | null = null;
|
|
780
|
+
let sharedFraming: FramingReport | null = null;
|
|
781
|
+
|
|
782
|
+
const reportFor = (fit: FramingFit, at: Viewport, over: Omit<FramingReport, 'units' | 'fit'>): FramingReport => ({
|
|
783
|
+
...over,
|
|
784
|
+
fit,
|
|
785
|
+
units: extentsOf(fit, at.scale, referenceViewport),
|
|
786
|
+
});
|
|
787
|
+
|
|
577
788
|
if (options.viewport) {
|
|
789
|
+
// A pin is a claim about the CANDIDATE's own coordinates, and those do not
|
|
790
|
+
// change between shots — so one box covers the run whatever the scope. The
|
|
791
|
+
// per-set fits below are free: every frame is measured in that one box once,
|
|
792
|
+
// and splitting the result per set costs nothing.
|
|
578
793
|
const v = options.viewport;
|
|
579
|
-
|
|
794
|
+
const pinned = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
|
|
580
795
|
notes.push(
|
|
581
796
|
`the candidate's world box was pinned by --viewport ${v.x},${v.y},${v.width},${v.height} rather than derived ` +
|
|
582
797
|
"from its own pixels — that is a claim about the candidate's coordinates, and nothing here checks it. The " +
|
|
583
798
|
'framing line below is still measured, so it says what the pin cost.',
|
|
584
799
|
);
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
agrees: fitDistance(fit) <= COINCIDENT_PIXELS,
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
};
|
|
800
|
+
const pinnedShape = { passes: 1, settled: false, source: 'pinned' as const, cycled: false, applied: false };
|
|
801
|
+
const perSet = prepared.map((p, i) =>
|
|
802
|
+
pairUpBoxes([p], posable.pages, pinned, background, level, slices[i]),
|
|
803
|
+
);
|
|
804
|
+
for (const boxes of perSet) {
|
|
805
|
+
const fit = boxes.length === 0 ? null : fitFraming(boxes);
|
|
806
|
+
framings.push({
|
|
807
|
+
viewport: pinned,
|
|
808
|
+
how: 'viewport-flag',
|
|
809
|
+
fit: fit === null ? null : reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS }),
|
|
810
|
+
notes: [],
|
|
811
|
+
});
|
|
598
812
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
813
|
+
const all = perSet.flat();
|
|
814
|
+
topViewport = pinned;
|
|
815
|
+
topHow = 'viewport-flag';
|
|
816
|
+
if (all.length > 0) {
|
|
817
|
+
const fit = fitFraming(all);
|
|
818
|
+
topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS });
|
|
602
819
|
}
|
|
820
|
+
} else if (referenceBoxes.every((b) => b === null)) {
|
|
821
|
+
throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
|
|
822
|
+
} else if (scope === 'shared' || prepared.length === 1) {
|
|
603
823
|
const framed = frameCandidate(
|
|
604
824
|
prepared,
|
|
605
825
|
posable.pages,
|
|
@@ -610,14 +830,115 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
610
830
|
pixelHeight,
|
|
611
831
|
referenceViewport,
|
|
612
832
|
);
|
|
613
|
-
|
|
614
|
-
|
|
833
|
+
const fit = { ...framed.report, units: extentsOf(framed.report.fit, framed.viewport.scale, referenceViewport) };
|
|
834
|
+
const how = HOW_BY_SOURCE[framed.report.source];
|
|
835
|
+
for (let i = 0; i < prepared.length; i++) framings.push({ viewport: framed.viewport, how, fit, notes: [] });
|
|
836
|
+
topViewport = framed.viewport;
|
|
837
|
+
topHow = how;
|
|
838
|
+
topFit = fit;
|
|
615
839
|
notes.push(...framingNotes(framed.report));
|
|
840
|
+
if (prepared.length > 1) {
|
|
841
|
+
notes.push(
|
|
842
|
+
`one framing was fitted across all ${prepared.length} frame set(s) (--framing shared). Its absolute numbers ` +
|
|
843
|
+
'are not comparable with a per-shot run, and one badly-fitted set moves every other set in it.',
|
|
844
|
+
);
|
|
845
|
+
}
|
|
846
|
+
} else {
|
|
847
|
+
// Per shot: the DECLARED BOX is decided per set, and every set that does not
|
|
848
|
+
// qualify for it is measured in the one shared framing.
|
|
849
|
+
//
|
|
850
|
+
// ## Why the split falls exactly there, and not "fit each set on its own"
|
|
851
|
+
//
|
|
852
|
+
// The obvious reading of issue #100 is that each set should get its own fitted
|
|
853
|
+
// framing. It was written that way and measured, and it is worse — on the
|
|
854
|
+
// spineboy rung, per-set fitting reads `hit` **92.36** against the shared
|
|
855
|
+
// fit's 60.59 and `shoot@30fps` **101.94** against 42.98 (a two-frame set,
|
|
856
|
+
// framed 24 % off). The reason is `fitFraming`'s own: it registers **extent**,
|
|
857
|
+
// and extent is not alignment, so on a shot whose silhouette genuinely differs
|
|
858
|
+
// the chain has a local minimum of the correction that is not a minimum of the
|
|
859
|
+
// difference. More frames constrain that; one shot's worth does not.
|
|
860
|
+
//
|
|
861
|
+
// What actually produced the good column in that run is the other half — the
|
|
862
|
+
// box `frames.json` records, which is not an estimate of anything and has no
|
|
863
|
+
// floor. Over the union it was refused, because ONE badly-fitted shot put the
|
|
864
|
+
// pooled correction over `COINCIDENT_PIXELS` and the whole root fell back to a
|
|
865
|
+
// fit. Per set, the four sets that ARE in the frames' coordinates take it and
|
|
866
|
+
// read exactly what pinning by hand reads: `idle` **18.77** against 41.59,
|
|
867
|
+
// `walk` 32.00 against 45.33.
|
|
868
|
+
//
|
|
869
|
+
// So a set is framed by the frames' own box when its OWN pixels land there,
|
|
870
|
+
// and by the shared fit otherwise. Every set is then at least as well framed
|
|
871
|
+
// as a whole-root run framed it, and four of spineboy's sixteen much better.
|
|
872
|
+
const shared = frameCandidate(
|
|
873
|
+
prepared,
|
|
874
|
+
posable.pages,
|
|
875
|
+
referenceBoxes,
|
|
876
|
+
background,
|
|
877
|
+
level,
|
|
878
|
+
pixelWidth,
|
|
879
|
+
pixelHeight,
|
|
880
|
+
referenceViewport,
|
|
881
|
+
);
|
|
882
|
+
const sharedShape: SetFraming = {
|
|
883
|
+
viewport: shared.viewport,
|
|
884
|
+
how: HOW_BY_SOURCE[shared.report.source],
|
|
885
|
+
fit: { ...shared.report, units: extentsOf(shared.report.fit, shared.viewport.scale, referenceViewport) },
|
|
886
|
+
notes: [],
|
|
887
|
+
};
|
|
888
|
+
sharedFraming = sharedShape.fit;
|
|
889
|
+
let own = 0;
|
|
890
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
891
|
+
const p = prepared[i];
|
|
892
|
+
const declared =
|
|
893
|
+
p.pairs.length === 0
|
|
894
|
+
? null
|
|
895
|
+
: frameByDeclaredBox(
|
|
896
|
+
[p],
|
|
897
|
+
posable.pages,
|
|
898
|
+
slices[i],
|
|
899
|
+
background,
|
|
900
|
+
level,
|
|
901
|
+
pixelWidth,
|
|
902
|
+
pixelHeight,
|
|
903
|
+
referenceViewport,
|
|
904
|
+
);
|
|
905
|
+
if (!declared) {
|
|
906
|
+
framings.push({ ...sharedShape, notes: framingNotes(shared.report) });
|
|
907
|
+
continue;
|
|
908
|
+
}
|
|
909
|
+
own++;
|
|
910
|
+
framings.push({
|
|
911
|
+
viewport: declared.viewport,
|
|
912
|
+
how: HOW_BY_SOURCE[declared.report.source],
|
|
913
|
+
fit: { ...declared.report, units: extentsOf(declared.report.fit, declared.viewport.scale, referenceViewport) },
|
|
914
|
+
notes: framingNotes(declared.report),
|
|
915
|
+
});
|
|
916
|
+
}
|
|
917
|
+
notes.push(
|
|
918
|
+
`the framing was decided per frame set: ${own} of ${prepared.length} set(s) were measured in ` +
|
|
919
|
+
`${FRAMES_SIDECAR}'s own box because their own pixels land there, and the rest in the one shared framing on ` +
|
|
920
|
+
'the "shared box" line. A set framed by the frames\' own box cannot be moved by any other set. --framing ' +
|
|
921
|
+
'shared measures every set in the shared framing instead, which is a different measurement and not ' +
|
|
922
|
+
'comparable with this one.',
|
|
923
|
+
);
|
|
616
924
|
}
|
|
617
925
|
|
|
926
|
+
// The candidate's own decomposition, derived once and used by every set — see
|
|
927
|
+
// `src/chains.ts`. Reading the CANDIDATE's tree is what keeps this on the right
|
|
928
|
+
// side of the honesty rule: the reference is still nothing but pixels.
|
|
929
|
+
const chains = chainsOf(
|
|
930
|
+
posable.data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
|
|
931
|
+
posable.data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
932
|
+
);
|
|
933
|
+
const chainOfSlot = new Map<string, number>();
|
|
934
|
+
chains.forEach((chain, index) => {
|
|
935
|
+
for (const slot of chain.slots) chainOfSlot.set(slot, index);
|
|
936
|
+
});
|
|
937
|
+
|
|
618
938
|
const animations: AnimationCheck[] = [];
|
|
619
|
-
for (
|
|
620
|
-
|
|
939
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
940
|
+
const f = framings[i];
|
|
941
|
+
animations.push(checkOneSet(located.root, prepared[i], posable, f, background, chains, chainOfSlot));
|
|
621
942
|
}
|
|
622
943
|
|
|
623
944
|
return {
|
|
@@ -627,28 +948,64 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
627
948
|
},
|
|
628
949
|
framesDir: resolve(options.framesDir),
|
|
629
950
|
framesRoot: located.root,
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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,
|
|
951
|
+
framingScope: scope,
|
|
952
|
+
framing: topHow,
|
|
953
|
+
viewport: topViewport === null ? null : framingOfViewport(topViewport),
|
|
954
|
+
framingFit: topFit,
|
|
955
|
+
sharedFraming,
|
|
645
956
|
referenceViewport,
|
|
646
957
|
background,
|
|
958
|
+
chains,
|
|
647
959
|
animations,
|
|
648
960
|
notes,
|
|
649
961
|
};
|
|
650
962
|
}
|
|
651
963
|
|
|
964
|
+
/** The framing one prepared set was measured in. */
|
|
965
|
+
interface SetFraming {
|
|
966
|
+
viewport: Viewport;
|
|
967
|
+
how: FramingHow;
|
|
968
|
+
fit: FramingReport | null;
|
|
969
|
+
notes: string[];
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
/** `FramingSource` said in the report's own words. */
|
|
973
|
+
const HOW_BY_SOURCE: Record<FramingSource, FramingHow> = {
|
|
974
|
+
derived: 'candidate-pixels',
|
|
975
|
+
declared: 'frames-viewport',
|
|
976
|
+
pinned: 'viewport-flag',
|
|
977
|
+
};
|
|
978
|
+
|
|
979
|
+
/**
|
|
980
|
+
* `referenceBoxes` cut into one array per prepared set, in `prepared` order.
|
|
981
|
+
*
|
|
982
|
+
* The array is built by `prepared.flatMap((p) => p.pairs)`, so this is the inverse
|
|
983
|
+
* of that flatten and nothing else. It exists because a per-shot framing measures
|
|
984
|
+
* one set at a time and `frameCandidate` indexes its boxes the flat way.
|
|
985
|
+
*/
|
|
986
|
+
function sliceBySet(prepared: PreparedSet[], referenceBoxes: Array<ContentBox | null>): Array<Array<ContentBox | null>> {
|
|
987
|
+
const out: Array<Array<ContentBox | null>> = [];
|
|
988
|
+
let at = 0;
|
|
989
|
+
for (const p of prepared) {
|
|
990
|
+
out.push(referenceBoxes.slice(at, at + p.pairs.length));
|
|
991
|
+
at += p.pairs.length;
|
|
992
|
+
}
|
|
993
|
+
return out;
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/** A rendering viewport as the report states it. */
|
|
997
|
+
function framingOfViewport(v: Viewport): Framing {
|
|
998
|
+
return {
|
|
999
|
+
x: v.minX,
|
|
1000
|
+
y: v.minY,
|
|
1001
|
+
width: v.maxX - v.minX,
|
|
1002
|
+
height: v.maxY - v.minY,
|
|
1003
|
+
scale: v.scale,
|
|
1004
|
+
pixelWidth: v.width,
|
|
1005
|
+
pixelHeight: v.height,
|
|
1006
|
+
};
|
|
1007
|
+
}
|
|
1008
|
+
|
|
652
1009
|
function readPlateFrom(root: string, file: string): Plate {
|
|
653
1010
|
readFrameFile(root, file); // the guard; readPlate does the decoding
|
|
654
1011
|
return readPlate(file);
|
|
@@ -793,6 +1150,12 @@ interface FramingPass {
|
|
|
793
1150
|
distance: number;
|
|
794
1151
|
}
|
|
795
1152
|
|
|
1153
|
+
/** A framing for one run of sets: the box, and what it still leaves over. */
|
|
1154
|
+
interface FramedSets {
|
|
1155
|
+
viewport: Viewport;
|
|
1156
|
+
report: Omit<FramingReport, 'units'>;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
796
1159
|
/** A chain of passes and why it stopped. */
|
|
797
1160
|
interface FramingChain {
|
|
798
1161
|
passes: FramingPass[];
|
|
@@ -898,7 +1261,7 @@ function frameCandidate(
|
|
|
898
1261
|
pixelWidth: number,
|
|
899
1262
|
pixelHeight: number,
|
|
900
1263
|
referenceViewport: Framing | null,
|
|
901
|
-
):
|
|
1264
|
+
): FramedSets {
|
|
902
1265
|
const declared = frameByDeclaredBox(
|
|
903
1266
|
prepared,
|
|
904
1267
|
pages,
|
|
@@ -909,34 +1272,14 @@ function frameCandidate(
|
|
|
909
1272
|
pixelHeight,
|
|
910
1273
|
referenceViewport,
|
|
911
1274
|
);
|
|
1275
|
+
// The declared-box probe measures every frame in `frames.json`'s own box
|
|
1276
|
+
// whether or not it ends up being used, and that measurement is the only one
|
|
1277
|
+
// taken in a box every set shares. Handing it back is what lets a per-shot run
|
|
1278
|
+
// report `sharedFit` without a second render — see `CheckReport.sharedFit`.
|
|
912
1279
|
if (declared) return declared;
|
|
913
1280
|
|
|
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
1281
|
const chain = runFramingChain(
|
|
939
|
-
|
|
1282
|
+
seedFromGeometry(prepared, pages, referenceBoxes, pixelWidth, pixelHeight),
|
|
940
1283
|
prepared,
|
|
941
1284
|
pages,
|
|
942
1285
|
referenceBoxes,
|
|
@@ -964,6 +1307,88 @@ function frameCandidate(
|
|
|
964
1307
|
};
|
|
965
1308
|
}
|
|
966
1309
|
|
|
1310
|
+
/**
|
|
1311
|
+
* The starting viewport, from the candidate's own posed geometry laid onto the
|
|
1312
|
+
* reference's own drawn extent.
|
|
1313
|
+
*
|
|
1314
|
+
* ## Why the reference's extent and not the frame
|
|
1315
|
+
*
|
|
1316
|
+
* The seed used to scale the candidate's trimmed quads to **fill the frame**, and
|
|
1317
|
+
* that is an assumption about the reference: that the shot its frames show was
|
|
1318
|
+
* framed around itself. Over a whole skeleton root it holds well enough, because
|
|
1319
|
+
* the sidecar's one box was chosen to hold every set. Over one SHORT set it can be
|
|
1320
|
+
* badly wrong — rung 3's `light` covers about half of the box its frames were
|
|
1321
|
+
* rendered in, so filling the frame starts it near 2x too large, and the chain
|
|
1322
|
+
* walks that back by only a few per cent a pass: `--frames <root>/light` on a
|
|
1323
|
+
* candidate in its own coordinates read **MAE 141** with a framing 65 % off, after
|
|
1324
|
+
* spending its whole pass budget (issue #100).
|
|
1325
|
+
*
|
|
1326
|
+
* The reference's own content box is already measured, on the same frames, with
|
|
1327
|
+
* the same predicate — it is what the fit is trying to reach. Starting there costs
|
|
1328
|
+
* nothing and starts the chain where it used to end up: the same shot now settles
|
|
1329
|
+
* on the first or second pass.
|
|
1330
|
+
*
|
|
1331
|
+
* The scale matches the two boxes by **area** rather than by either side, because
|
|
1332
|
+
* a candidate whose silhouette differs has two different side ratios and picking
|
|
1333
|
+
* one of them would seed the chain with that difference as a scale error.
|
|
1334
|
+
*
|
|
1335
|
+
* ⚠️ Falls back to filling the frame when there is no reference box to aim at —
|
|
1336
|
+
* every frame unreadable, or a set with nothing on disk.
|
|
1337
|
+
*/
|
|
1338
|
+
function seedFromGeometry(
|
|
1339
|
+
prepared: PreparedSet[],
|
|
1340
|
+
pages: Map<string, Plate>,
|
|
1341
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
1342
|
+
pixelWidth: number,
|
|
1343
|
+
pixelHeight: number,
|
|
1344
|
+
): Viewport {
|
|
1345
|
+
const quads = trimmedUnionBounds(
|
|
1346
|
+
prepared.map((p) => p.pairs.map((pair) => pair.frame)),
|
|
1347
|
+
pages,
|
|
1348
|
+
);
|
|
1349
|
+
if (!Number.isFinite(quads.minX)) {
|
|
1350
|
+
throw new CheckError('the candidate posed no drawable attachment in any frame that was compared');
|
|
1351
|
+
}
|
|
1352
|
+
const pad = Math.max(quads.maxX - quads.minX, quads.maxY - quads.minY) * PAD;
|
|
1353
|
+
const world = {
|
|
1354
|
+
minX: quads.minX - pad,
|
|
1355
|
+
minY: quads.minY - pad,
|
|
1356
|
+
maxX: quads.maxX + pad,
|
|
1357
|
+
maxY: quads.maxY + pad,
|
|
1358
|
+
};
|
|
1359
|
+
const worldWidth = world.maxX - world.minX;
|
|
1360
|
+
const worldHeight = world.maxY - world.minY;
|
|
1361
|
+
|
|
1362
|
+
let reference: ContentBox | null = null;
|
|
1363
|
+
for (const box of referenceBoxes) reference = unionBoxes(reference, box);
|
|
1364
|
+
if (reference !== null && boxWidth(reference) > 0 && boxHeight(reference) > 0) {
|
|
1365
|
+
// The reference's box is the trimmed content, so pad it the same way the
|
|
1366
|
+
// candidate's is before the two are matched — otherwise the pad is a scale
|
|
1367
|
+
// error the chain then has to undo.
|
|
1368
|
+
const refWidth = boxWidth(reference) * (1 + 2 * PAD);
|
|
1369
|
+
const refHeight = boxHeight(reference) * (1 + 2 * PAD);
|
|
1370
|
+
const scale = Math.sqrt((refWidth * refHeight) / (worldWidth * worldHeight));
|
|
1371
|
+
const left = reference.left - boxWidth(reference) * PAD;
|
|
1372
|
+
const top = reference.top - boxHeight(reference) * PAD;
|
|
1373
|
+
// `projector` is px = (wx - minX)·k and py = (maxY - wy)·k, so putting the
|
|
1374
|
+
// candidate's padded box on the reference's is one subtraction per axis.
|
|
1375
|
+
const minX = world.minX - left / scale;
|
|
1376
|
+
const maxY = world.maxY + top / scale;
|
|
1377
|
+
return viewportOfSize(minX, maxY - pixelHeight / scale, pixelWidth / scale, pixelHeight / scale, scale, pixelWidth, pixelHeight);
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const maxSide = Math.max(pixelWidth, pixelHeight);
|
|
1381
|
+
return viewportOfSize(
|
|
1382
|
+
world.minX,
|
|
1383
|
+
world.minY,
|
|
1384
|
+
worldWidth,
|
|
1385
|
+
worldHeight,
|
|
1386
|
+
maxSide / Math.max(worldWidth, worldHeight),
|
|
1387
|
+
pixelWidth,
|
|
1388
|
+
pixelHeight,
|
|
1389
|
+
);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
967
1392
|
/**
|
|
968
1393
|
* The box `frames.json` records, used as the candidate's own — when, and only
|
|
969
1394
|
* when, the candidate's pixels are measured to land in it.
|
|
@@ -1193,10 +1618,14 @@ function checkOneSet(
|
|
|
1193
1618
|
root: string,
|
|
1194
1619
|
prepared: PreparedSet,
|
|
1195
1620
|
posable: ReturnType<typeof posableFromText>,
|
|
1196
|
-
|
|
1621
|
+
framing: SetFraming,
|
|
1197
1622
|
background: RGBA,
|
|
1623
|
+
chains: BoneChain[],
|
|
1624
|
+
/** Slot name → its index in `chains`. */
|
|
1625
|
+
chainOfSlot: Map<string, number>,
|
|
1198
1626
|
): AnimationCheck {
|
|
1199
1627
|
const { set } = prepared;
|
|
1628
|
+
const viewport = framing.viewport;
|
|
1200
1629
|
const blank: AnimationCheck = {
|
|
1201
1630
|
dir: set.dir,
|
|
1202
1631
|
animation: set.animation,
|
|
@@ -1206,6 +1635,8 @@ function checkOneSet(
|
|
|
1206
1635
|
candidateFrames: prepared.candidateFrames,
|
|
1207
1636
|
compared: 0,
|
|
1208
1637
|
meanMae: 0,
|
|
1638
|
+
meanMaeReference: 0,
|
|
1639
|
+
drawnRatio: 1,
|
|
1209
1640
|
meanMaeFrame: 0,
|
|
1210
1641
|
worstMae: 0,
|
|
1211
1642
|
worstMaeFrame: -1,
|
|
@@ -1216,13 +1647,21 @@ function checkOneSet(
|
|
|
1216
1647
|
changePairs: 0,
|
|
1217
1648
|
changeDisagreements: 0,
|
|
1218
1649
|
worstChangeFrame: -1,
|
|
1650
|
+
chains: [],
|
|
1651
|
+
chainDenominator: 0,
|
|
1652
|
+
unattributedError: 0,
|
|
1219
1653
|
frames: [],
|
|
1220
|
-
|
|
1654
|
+
viewport: framingOfViewport(viewport),
|
|
1655
|
+
framing: framing.how,
|
|
1656
|
+
framingFit: framing.fit,
|
|
1657
|
+
notes: prepared.missing ? [prepared.missing] : [...framing.notes, ...prepared.notes],
|
|
1221
1658
|
};
|
|
1222
1659
|
if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
|
|
1223
1660
|
|
|
1224
1661
|
const frames: FrameCheck[] = [];
|
|
1225
1662
|
let maeSum = 0;
|
|
1663
|
+
let maeReferenceSum = 0;
|
|
1664
|
+
let drawnRatioSum = 0;
|
|
1226
1665
|
let maeFrameSum = 0;
|
|
1227
1666
|
let worstMae = 0;
|
|
1228
1667
|
let worstMaeFrame = -1;
|
|
@@ -1238,15 +1677,34 @@ function checkOneSet(
|
|
|
1238
1677
|
// ITSELF a frame earlier. Both are already rendered or read for this frame, so
|
|
1239
1678
|
// holding one frame of each costs one extra plate and no extra work.
|
|
1240
1679
|
let previous: { index: number; candidate: Plate; reference: Plate } | null = null;
|
|
1680
|
+
const tally: ChainTally = {
|
|
1681
|
+
error: new Array<number>(chains.length).fill(0),
|
|
1682
|
+
pixels: new Array<number>(chains.length).fill(0),
|
|
1683
|
+
unattributed: 0,
|
|
1684
|
+
total: 0,
|
|
1685
|
+
};
|
|
1241
1686
|
|
|
1242
1687
|
for (const { index, file, frame } of prepared.pairs) {
|
|
1243
1688
|
const reference = readPlateFrom(root, file);
|
|
1244
1689
|
const rendered = renderFrame(frame, posable.pages, viewport, background);
|
|
1245
|
-
const check = checkOneFrame(
|
|
1690
|
+
const check = checkOneFrame(
|
|
1691
|
+
index,
|
|
1692
|
+
file,
|
|
1693
|
+
frame,
|
|
1694
|
+
posable.pages,
|
|
1695
|
+
viewport,
|
|
1696
|
+
background,
|
|
1697
|
+
reference,
|
|
1698
|
+
rendered,
|
|
1699
|
+
chainOfSlot,
|
|
1700
|
+
tally,
|
|
1701
|
+
);
|
|
1246
1702
|
check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
|
|
1247
1703
|
previous = { index, candidate: rendered, reference };
|
|
1248
1704
|
frames.push(check);
|
|
1249
1705
|
maeSum += check.mae;
|
|
1706
|
+
maeReferenceSum += check.maeReference;
|
|
1707
|
+
drawnRatioSum += check.referencePixels === 0 ? 1 : check.candidatePixels / check.referencePixels;
|
|
1250
1708
|
maeFrameSum += check.maeFrame;
|
|
1251
1709
|
if (check.attributed === 0) framesWithoutDrift++;
|
|
1252
1710
|
if (check.change) {
|
|
@@ -1274,7 +1732,12 @@ function checkOneSet(
|
|
|
1274
1732
|
return {
|
|
1275
1733
|
...blank,
|
|
1276
1734
|
compared: frames.length,
|
|
1735
|
+
chains: chainChecks(chains, frames, tally),
|
|
1736
|
+
chainDenominator: tally.total,
|
|
1737
|
+
unattributedError: tally.unattributed,
|
|
1277
1738
|
meanMae: maeSum / frames.length,
|
|
1739
|
+
meanMaeReference: maeReferenceSum / frames.length,
|
|
1740
|
+
drawnRatio: drawnRatioSum / frames.length,
|
|
1278
1741
|
meanMaeFrame: maeFrameSum / frames.length,
|
|
1279
1742
|
worstMae,
|
|
1280
1743
|
worstMaeFrame,
|
|
@@ -1286,7 +1749,7 @@ function checkOneSet(
|
|
|
1286
1749
|
changeDisagreements,
|
|
1287
1750
|
worstChangeFrame,
|
|
1288
1751
|
frames,
|
|
1289
|
-
notes: prepared.notes,
|
|
1752
|
+
notes: [...framing.notes, ...prepared.notes],
|
|
1290
1753
|
};
|
|
1291
1754
|
}
|
|
1292
1755
|
|
|
@@ -1393,6 +1856,176 @@ function plateDelta(before: Plate, after: Plate): { pixels: number; mae: number
|
|
|
1393
1856
|
return { pixels, mae: sum / 3 / count };
|
|
1394
1857
|
}
|
|
1395
1858
|
|
|
1859
|
+
/**
|
|
1860
|
+
* Roll a set's frames up into one row per chain — the dashboard's rows.
|
|
1861
|
+
*
|
|
1862
|
+
* A chain that owns no slot is left out: it has nothing to attribute, and a row of
|
|
1863
|
+
* dashes in every set is noise in a table read sixteen times. The roster at the
|
|
1864
|
+
* foot of the report still lists it, so the account of where every bone went stays
|
|
1865
|
+
* complete.
|
|
1866
|
+
*/
|
|
1867
|
+
function chainChecks(chains: BoneChain[], frames: FrameCheck[], tally: ChainTally): ChainCheck[] {
|
|
1868
|
+
const out: ChainCheck[] = [];
|
|
1869
|
+
chains.forEach((chain, index) => {
|
|
1870
|
+
if (chain.slots.length === 0) return;
|
|
1871
|
+
const own = new Set(chain.slots);
|
|
1872
|
+
const drew = new Set<string>();
|
|
1873
|
+
let worstDrift = 0;
|
|
1874
|
+
let worstDriftSlot: string | null = null;
|
|
1875
|
+
let worstDriftFrame = -1;
|
|
1876
|
+
let driftSum = 0;
|
|
1877
|
+
let driftSamples = 0;
|
|
1878
|
+
let driftFrames = 0;
|
|
1879
|
+
for (const frame of frames) {
|
|
1880
|
+
let sampled = false;
|
|
1881
|
+
for (const track of frame.slots) {
|
|
1882
|
+
if (!own.has(track.slot)) continue;
|
|
1883
|
+
if (track.candidate !== null) drew.add(track.slot);
|
|
1884
|
+
if (!isAttributable(track)) continue;
|
|
1885
|
+
const drift = track.drift as number;
|
|
1886
|
+
driftSum += drift;
|
|
1887
|
+
driftSamples++;
|
|
1888
|
+
sampled = true;
|
|
1889
|
+
if (drift > worstDrift) {
|
|
1890
|
+
worstDrift = drift;
|
|
1891
|
+
worstDriftSlot = track.slot;
|
|
1892
|
+
worstDriftFrame = frame.index;
|
|
1893
|
+
}
|
|
1894
|
+
}
|
|
1895
|
+
if (sampled) driftFrames++;
|
|
1896
|
+
}
|
|
1897
|
+
out.push({
|
|
1898
|
+
chain: chain.name,
|
|
1899
|
+
slots: chain.slots.length,
|
|
1900
|
+
drewSlots: drew.size,
|
|
1901
|
+
worstDrift,
|
|
1902
|
+
worstDriftSlot,
|
|
1903
|
+
worstDriftFrame,
|
|
1904
|
+
meanDrift: driftSamples === 0 ? 0 : driftSum / driftSamples,
|
|
1905
|
+
driftSamples,
|
|
1906
|
+
driftFrames,
|
|
1907
|
+
error: tally.error[index],
|
|
1908
|
+
referencePixels: tally.pixels[index],
|
|
1909
|
+
mae: tally.pixels[index] === 0 ? 0 : tally.error[index] / tally.pixels[index],
|
|
1910
|
+
maeShare: tally.total === 0 ? 0 : tally.error[index] / tally.total,
|
|
1911
|
+
});
|
|
1912
|
+
});
|
|
1913
|
+
return out;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/**
|
|
1917
|
+
* A set's error, being split between the candidate's chains as its frames are read.
|
|
1918
|
+
*
|
|
1919
|
+
* Carried across frames rather than parked on each `FrameCheck` because a share is
|
|
1920
|
+
* a fact about the SET — and because a per-frame array of it would land in every
|
|
1921
|
+
* `--json` report and every `bench.json` for a number nobody reads per frame.
|
|
1922
|
+
*/
|
|
1923
|
+
interface ChainTally {
|
|
1924
|
+
/** Absolute difference over reference-drawn pixels attributed to each chain. */
|
|
1925
|
+
error: number[];
|
|
1926
|
+
/** How many such pixels each chain took. */
|
|
1927
|
+
pixels: number[];
|
|
1928
|
+
/** The same, over reference pixels no chain could take — the candidate drew nothing. */
|
|
1929
|
+
unattributed: number;
|
|
1930
|
+
/** Every reference-drawn pixel's difference, chain or not: the share's denominator. */
|
|
1931
|
+
total: number;
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
/** How much a diagonal step costs the chamfer pass below. */
|
|
1935
|
+
const DIAGONAL_STEP = Math.SQRT2;
|
|
1936
|
+
|
|
1937
|
+
/**
|
|
1938
|
+
* Give every pixel of the frame the chain whose ink is nearest to it.
|
|
1939
|
+
*
|
|
1940
|
+
* Two chamfer passes over the owner mask — forward then backward, propagating
|
|
1941
|
+
* (distance, label) together. It is an approximate Euclidean transform and that is
|
|
1942
|
+
* enough: what it decides is which of a handful of well-separated regions a pixel
|
|
1943
|
+
* belongs to, not a distance anybody reads.
|
|
1944
|
+
*
|
|
1945
|
+
* ⚠️ Nearest **ink the candidate drew**, so a chain that draws nothing seeds
|
|
1946
|
+
* nothing and is handed no pixels at all — its share reads 0 % while its slots are
|
|
1947
|
+
* missing entirely. That is why the table prints `drewSlots` beside the share: 0 %
|
|
1948
|
+
* on `0/3 slots` is the loudest row here, not the quietest one.
|
|
1949
|
+
*
|
|
1950
|
+
* The distance comes back with the label because the caller bounds it — see
|
|
1951
|
+
* `chainRadii`.
|
|
1952
|
+
*/
|
|
1953
|
+
function nearestOwner(owner: Int32Array, width: number, height: number): { label: Int32Array; dist: Float32Array } {
|
|
1954
|
+
const label = Int32Array.from(owner);
|
|
1955
|
+
const dist = new Float32Array(width * height);
|
|
1956
|
+
for (let i = 0; i < label.length; i++) dist[i] = label[i] >= 0 ? 0 : Infinity;
|
|
1957
|
+
const relax = (at: number, from: number, step: number): void => {
|
|
1958
|
+
const reach = dist[from] + step;
|
|
1959
|
+
if (reach >= dist[at]) return;
|
|
1960
|
+
dist[at] = reach;
|
|
1961
|
+
label[at] = label[from];
|
|
1962
|
+
};
|
|
1963
|
+
for (let y = 0; y < height; y++) {
|
|
1964
|
+
for (let x = 0; x < width; x++) {
|
|
1965
|
+
const at = y * width + x;
|
|
1966
|
+
if (x > 0) relax(at, at - 1, 1);
|
|
1967
|
+
if (y > 0) {
|
|
1968
|
+
relax(at, at - width, 1);
|
|
1969
|
+
if (x > 0) relax(at, at - width - 1, DIAGONAL_STEP);
|
|
1970
|
+
if (x + 1 < width) relax(at, at - width + 1, DIAGONAL_STEP);
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
for (let y = height - 1; y >= 0; y--) {
|
|
1975
|
+
for (let x = width - 1; x >= 0; x--) {
|
|
1976
|
+
const at = y * width + x;
|
|
1977
|
+
if (x + 1 < width) relax(at, at + 1, 1);
|
|
1978
|
+
if (y + 1 < height) {
|
|
1979
|
+
relax(at, at + width, 1);
|
|
1980
|
+
if (x + 1 < width) relax(at, at + width + 1, DIAGONAL_STEP);
|
|
1981
|
+
if (x > 0) relax(at, at + width - 1, DIAGONAL_STEP);
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1984
|
+
}
|
|
1985
|
+
return { label, dist };
|
|
1986
|
+
}
|
|
1987
|
+
|
|
1988
|
+
/**
|
|
1989
|
+
* How far each chain's attribution may reach, in frame pixels.
|
|
1990
|
+
*
|
|
1991
|
+
* The same judgement `src/slots.ts` makes about a slot — *past about its own long
|
|
1992
|
+
* side a part no longer overlaps where it was, and something out there is another
|
|
1993
|
+
* object rather than this one moved* — applied to the chain's own drawn box. Past
|
|
1994
|
+
* it, reference ink is left **unattributed** instead of being handed to whichever
|
|
1995
|
+
* chain happens to be nearest.
|
|
1996
|
+
*
|
|
1997
|
+
* ⚠️ This is the bound that keeps the dashboard honest about its own limits, and
|
|
1998
|
+
* it is a bound rather than a fix. Nothing candidate-side can know which part of
|
|
1999
|
+
* the REFERENCE a pixel belonged to; nearest-ink is a good guess while the figure
|
|
2000
|
+
* is roughly in place and a bad one once a part has left. So a part displaced past
|
|
2001
|
+
* its own size stops being blamed on its neighbour and starts showing up in the
|
|
2002
|
+
* `(unattributed)` row, next to the `reference component(s) no slot reaches` count
|
|
2003
|
+
* that says the same thing a different way.
|
|
2004
|
+
*/
|
|
2005
|
+
function chainRadii(
|
|
2006
|
+
footprints: Map<string, Footprint>,
|
|
2007
|
+
chainOfSlot: Map<string, number>,
|
|
2008
|
+
chains: number,
|
|
2009
|
+
): Float64Array {
|
|
2010
|
+
const minX = new Float64Array(chains).fill(Infinity);
|
|
2011
|
+
const minY = new Float64Array(chains).fill(Infinity);
|
|
2012
|
+
const maxX = new Float64Array(chains).fill(-Infinity);
|
|
2013
|
+
const maxY = new Float64Array(chains).fill(-Infinity);
|
|
2014
|
+
for (const [slot, foot] of footprints) {
|
|
2015
|
+
const chain = chainOfSlot.get(slot);
|
|
2016
|
+
if (chain === undefined || foot.pixels === 0) continue;
|
|
2017
|
+
if (foot.minX < minX[chain]) minX[chain] = foot.minX;
|
|
2018
|
+
if (foot.minY < minY[chain]) minY[chain] = foot.minY;
|
|
2019
|
+
if (foot.maxX > maxX[chain]) maxX[chain] = foot.maxX;
|
|
2020
|
+
if (foot.maxY > maxY[chain]) maxY[chain] = foot.maxY;
|
|
2021
|
+
}
|
|
2022
|
+
const out = new Float64Array(chains);
|
|
2023
|
+
for (let i = 0; i < chains; i++) {
|
|
2024
|
+
out[i] = maxX[i] < minX[i] ? -1 : searchRadius(maxX[i] - minX[i], maxY[i] - minY[i]);
|
|
2025
|
+
}
|
|
2026
|
+
return out;
|
|
2027
|
+
}
|
|
2028
|
+
|
|
1396
2029
|
function checkOneFrame(
|
|
1397
2030
|
index: number,
|
|
1398
2031
|
file: string,
|
|
@@ -1403,8 +2036,16 @@ function checkOneFrame(
|
|
|
1403
2036
|
reference: Plate,
|
|
1404
2037
|
/** The candidate's own frame, rendered by the caller — it needs it too. */
|
|
1405
2038
|
rendered: Plate,
|
|
2039
|
+
/** Slot name → chain index, for the per-chain split. */
|
|
2040
|
+
chainOfSlot: Map<string, number>,
|
|
2041
|
+
/** Accumulated across the set by the caller — see `ChainTally`. */
|
|
2042
|
+
tally: ChainTally,
|
|
1406
2043
|
): FrameCheck {
|
|
1407
|
-
const { coverage, footprints } = frameGeometry(frame, pages, viewport);
|
|
2044
|
+
const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
|
|
2045
|
+
// Only worth the transform when something was drawn to be nearest TO.
|
|
2046
|
+
const nearest =
|
|
2047
|
+
owner !== null && owner.some((at) => at >= 0) ? nearestOwner(owner, viewport.width, viewport.height) : null;
|
|
2048
|
+
const radii = chainRadii(footprints, chainOfSlot, tally.error.length);
|
|
1408
2049
|
|
|
1409
2050
|
let union = 0;
|
|
1410
2051
|
let candidatePixels = 0;
|
|
@@ -1421,6 +2062,20 @@ function checkOneFrame(
|
|
|
1421
2062
|
const b = reference.get(x, y);
|
|
1422
2063
|
const delta = (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
|
|
1423
2064
|
sumAll += delta;
|
|
2065
|
+
if (inReference) {
|
|
2066
|
+
// The share's denominator is the reference's own drawn pixels, and the
|
|
2067
|
+
// split is over exactly those — issue #119's lesson, as a partition.
|
|
2068
|
+
tally.total += delta;
|
|
2069
|
+
const at = y * viewport.width + x;
|
|
2070
|
+
const found = nearest === null ? -1 : nearest.label[at];
|
|
2071
|
+
const chain = found >= 0 && nearest !== null && nearest.dist[at] <= radii[found] ? found : -1;
|
|
2072
|
+
if (chain >= 0) {
|
|
2073
|
+
tally.error[chain] += delta;
|
|
2074
|
+
tally.pixels[chain]++;
|
|
2075
|
+
} else {
|
|
2076
|
+
tally.unattributed += delta;
|
|
2077
|
+
}
|
|
2078
|
+
}
|
|
1424
2079
|
if (!inCandidate && !inReference) continue;
|
|
1425
2080
|
union++;
|
|
1426
2081
|
sum += delta;
|
|
@@ -1454,6 +2109,10 @@ function checkOneFrame(
|
|
|
1454
2109
|
index,
|
|
1455
2110
|
file,
|
|
1456
2111
|
mae: union === 0 ? 0 : sum / union,
|
|
2112
|
+
// The same numerator over a denominator the candidate does not control — see
|
|
2113
|
+
// `FrameCheck.maeReference`. Both figures are already in hand here, which is
|
|
2114
|
+
// why the second one costs nothing to publish.
|
|
2115
|
+
maeReference: referencePixels === 0 ? 0 : sum / referencePixels,
|
|
1457
2116
|
maeFrame: sumAll / (viewport.width * viewport.height),
|
|
1458
2117
|
unionPixels: union,
|
|
1459
2118
|
candidatePixels,
|
|
@@ -1475,6 +2134,58 @@ function checkOneFrame(
|
|
|
1475
2134
|
// the report
|
|
1476
2135
|
// ---------------------------------------------------------------------------
|
|
1477
2136
|
|
|
2137
|
+
/**
|
|
2138
|
+
* How much more than the reference a set may draw before `check` calls it
|
|
2139
|
+
* overdraw, as a ratio of drawn pixels.
|
|
2140
|
+
*
|
|
2141
|
+
* ## Why this direction needs its own warning
|
|
2142
|
+
*
|
|
2143
|
+
* `mae` divides by the pixels **either side drew** — the union — and the
|
|
2144
|
+
* candidate owns half of that denominator. A large, mostly transparent sprite
|
|
2145
|
+
* adds many cheap pixels to it and the *mean falls*, so anything optimising
|
|
2146
|
+
* against `mae` can buy a better score by drawing more, which is the opposite of
|
|
2147
|
+
* fidelity. Issue #119: spineboy-2's muzzle flare walked its own scale to 13x
|
|
2148
|
+
* doing exactly this, and cost every set in that run its framing. Reproduced
|
|
2149
|
+
* here, that candidate's `shoot` reads union MAE **39.65 against the honest
|
|
2150
|
+
* build's 47.20** — the metric calls the flare an improvement — while the same
|
|
2151
|
+
* difference over the reference's own pixels reads **73.06 against 52.54**.
|
|
2152
|
+
*
|
|
2153
|
+
* ⭐ Asymmetric on purpose. A candidate that draws LESS than the reference is
|
|
2154
|
+
* being punished by the MAE, not rewarded, and needs no warning to find out.
|
|
2155
|
+
*
|
|
2156
|
+
* ## Where 1.5 comes from
|
|
2157
|
+
*
|
|
2158
|
+
* Measured over the corpus rather than picked. Across the twelve committed
|
|
2159
|
+
* candidates in `bench/runs/` — 64 compared sets, 1 to 121 frames each — the
|
|
2160
|
+
* ratio spans **0.852 … 1.069** on 62 of them, and the two above that are both
|
|
2161
|
+
* the same shot on the same character: spineboy-1's `shoot@30fps` at 1.154 and
|
|
2162
|
+
* spineboy-2's at **1.274**, two-frame stills sets where the muzzle flare lands
|
|
2163
|
+
* a frame off. Rung 8's ball reads 1.041, rung 3's candidate 0.993.
|
|
2164
|
+
*
|
|
2165
|
+
* The 13x flare reads **1.850** on `shoot` and **3.199** on `shoot@30fps`, and
|
|
2166
|
+
* 0.94–1.01 on the fourteen sets that do not draw it — so the warning names the
|
|
2167
|
+
* shot the overdraw is in rather than colouring the whole run.
|
|
2168
|
+
*
|
|
2169
|
+
* 1.5 is the geometric middle of the gap between the widest honest reading and
|
|
2170
|
+
* the weakest defective one (1.274 · 1.850 ≈ 1.535²): half again as much ink as
|
|
2171
|
+
* the reference put down, which no honest candidate in the corpus approaches and
|
|
2172
|
+
* which the case this was built for clears on both its sets.
|
|
2173
|
+
*
|
|
2174
|
+
* ## What was measured and rejected: the content boxes
|
|
2175
|
+
*
|
|
2176
|
+
* Issue #119 suggests the two content boxes, and `check` has both. Measured, that
|
|
2177
|
+
* test is defeated by the framing it is measured through. `fitFraming` absorbs a
|
|
2178
|
+
* uniform scale on purpose, so a candidate that draws everything too big reads a
|
|
2179
|
+
* box growth of **−3.4 %** while its union MAE falls 137.6 → 36.0 — the fit
|
|
2180
|
+
* simply shrinks it back. It also fires where nothing is overdrawn: the
|
|
2181
|
+
* time-reversed fixture, whose ink is right and whose *timing* is wrong, reads
|
|
2182
|
+
* **+14.1 %** because sampling a reversed shot lands on different poses. Counting
|
|
2183
|
+
* ink is blind to both — that same reversed fixture draws **1.30–1.39x**, under
|
|
2184
|
+
* the bar, and a bloated one draws what it drew whatever the framing does with it
|
|
2185
|
+
* afterwards. C08 and C09 hold both ends of that.
|
|
2186
|
+
*/
|
|
2187
|
+
export const OVERDRAW_RATIO = 1.5;
|
|
2188
|
+
|
|
1478
2189
|
/** How many worst frames a set prints when the whole set is too long to list. */
|
|
1479
2190
|
export const WORST_FRAMES = 8;
|
|
1480
2191
|
/** Sets no longer than this print every frame. */
|
|
@@ -1487,17 +2198,15 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1487
2198
|
lines.push(` candidate ${report.candidate.skeleton}`);
|
|
1488
2199
|
lines.push(` atlas ${report.candidate.atlas}`);
|
|
1489
2200
|
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
2201
|
lines.push(
|
|
1498
|
-
`
|
|
1499
|
-
|
|
2202
|
+
` scope ${
|
|
2203
|
+
report.framingScope === 'per-shot'
|
|
2204
|
+
? "the framing decided per frame set (--framing shared measures every set in one shared framing)"
|
|
2205
|
+
: "one framing across every frame set (--framing per-shot lets a set take frames.json's own box instead)"
|
|
2206
|
+
}`,
|
|
1500
2207
|
);
|
|
2208
|
+
const v = report.viewport;
|
|
2209
|
+
if (v !== null) lines.push(...framedToLines(v, report.framing));
|
|
1501
2210
|
const r = report.referenceViewport;
|
|
1502
2211
|
if (r) {
|
|
1503
2212
|
lines.push(
|
|
@@ -1506,7 +2215,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1506
2215
|
);
|
|
1507
2216
|
lines.push(' ⤷ the two world boxes are different coordinate systems and do not compare; the pixel grid does.');
|
|
1508
2217
|
}
|
|
1509
|
-
for (const line of framingLines(report)) lines.push(line);
|
|
2218
|
+
for (const line of framingLines(report.framingFit)) lines.push(line);
|
|
2219
|
+
if (report.sharedFraming) {
|
|
2220
|
+
const shared = report.sharedFraming;
|
|
2221
|
+
const f = shared.fit;
|
|
2222
|
+
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
2223
|
+
lines.push(
|
|
2224
|
+
` shared box one box for all ${report.animations.length} set(s) leaves x${f.scale.toFixed(6)} offset ` +
|
|
2225
|
+
`${signed(f.dx)}, ${signed(f.dy)} px rms ${f.rms.toFixed(2)} px over ${f.frames * 4} edge(s) ` +
|
|
2226
|
+
`(${shared.source}; used for every set that could not take the frames' own box)`,
|
|
2227
|
+
);
|
|
2228
|
+
lines.push(
|
|
2229
|
+
" ⤷ how far one shared framing is from serving every set. A set below that took the frames' own " +
|
|
2230
|
+
'box instead is measured with no such correction at all; --framing shared measures every set here.',
|
|
2231
|
+
);
|
|
2232
|
+
}
|
|
1510
2233
|
for (const note of report.notes) lines.push(` ⚠️ ${note}`);
|
|
1511
2234
|
lines.push('');
|
|
1512
2235
|
|
|
@@ -1521,6 +2244,13 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1521
2244
|
lines.push(
|
|
1522
2245
|
` frames ${anim.referenceFrames} on disk, candidate samples ${anim.candidateFrames}, ${anim.compared} compared`,
|
|
1523
2246
|
);
|
|
2247
|
+
// Only when this set has a framing of its own: under a shared scope, or a pin,
|
|
2248
|
+
// the header already printed the one box every set was measured in, and
|
|
2249
|
+
// repeating it per set would read as though they differed.
|
|
2250
|
+
if (report.framingScope === 'per-shot' && report.viewport === null) {
|
|
2251
|
+
for (const line of framedToLines(anim.viewport, anim.framing, ' ')) lines.push(line);
|
|
2252
|
+
for (const line of framingLines(anim.framingFit, ' ')) lines.push(line);
|
|
2253
|
+
}
|
|
1524
2254
|
for (const note of anim.notes) lines.push(` ⚠️ ${note}`);
|
|
1525
2255
|
if (anim.compared === 0) {
|
|
1526
2256
|
lines.push('');
|
|
@@ -1530,6 +2260,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1530
2260
|
` MAE mean ${f2(anim.meanMae)} worst ${f2(anim.worstMae)} at f${String(anim.worstMaeFrame).padStart(4, '0')}` +
|
|
1531
2261
|
` (0..255 over the union alpha; over the whole frame, mean ${f2(anim.meanMaeFrame)})`,
|
|
1532
2262
|
);
|
|
2263
|
+
lines.push(
|
|
2264
|
+
` ⤷ over the REFERENCE's own drawn pixels, mean ${f2(anim.meanMaeReference)} — the union figure ` +
|
|
2265
|
+
'compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.',
|
|
2266
|
+
);
|
|
2267
|
+
if (anim.drawnRatio > OVERDRAW_RATIO) {
|
|
2268
|
+
const mine = Math.round(anim.frames.reduce((sum, f) => sum + f.candidatePixels, 0) / anim.frames.length);
|
|
2269
|
+
const theirs = Math.round(anim.frames.reduce((sum, f) => sum + f.referencePixels, 0) / anim.frames.length);
|
|
2270
|
+
lines.push(
|
|
2271
|
+
` ⚠️ overdraw: this shot draws ${mine.toLocaleString('en-US')} px a frame where the reference ` +
|
|
2272
|
+
`draws ${theirs.toLocaleString('en-US')} — ${anim.drawnRatio.toFixed(2)}x as much ink, past the ` +
|
|
2273
|
+
`${OVERDRAW_RATIO}x no committed candidate reaches. Most of that excess lands in the MAE's own ` +
|
|
2274
|
+
'denominator and makes the figure above cheaper without moving a pixel closer, so read the one under it. ' +
|
|
2275
|
+
'Something here is drawn that should not be, or is far too big.',
|
|
2276
|
+
);
|
|
2277
|
+
}
|
|
1533
2278
|
const blind =
|
|
1534
2279
|
anim.framesWithoutDrift === 0
|
|
1535
2280
|
? ''
|
|
@@ -1541,6 +2286,7 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1541
2286
|
`f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
|
|
1542
2287
|
);
|
|
1543
2288
|
lines.push(changeSummary(anim));
|
|
2289
|
+
for (const line of chainTable(anim)) lines.push(line);
|
|
1544
2290
|
lines.push('');
|
|
1545
2291
|
|
|
1546
2292
|
const listed = framesToList(anim, opts?.allFrames === true);
|
|
@@ -1574,9 +2320,13 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1574
2320
|
lines.push('');
|
|
1575
2321
|
}
|
|
1576
2322
|
|
|
2323
|
+
for (const line of chainFoot(report)) lines.push(line);
|
|
2324
|
+
|
|
1577
2325
|
lines.push(' MAE is the mean absolute RGB difference over the pixels either side covers, so it is');
|
|
1578
2326
|
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`.
|
|
2327
|
+
lines.push(' there is one in `diff`. The figure under it divides the same difference by the pixels the');
|
|
2328
|
+
lines.push(' REFERENCE drew — a denominator you cannot grow, which is what makes it the one to author');
|
|
2329
|
+
lines.push(' against; it is not bounded by 255. Read the framing line first: it is upstream of every number');
|
|
1580
2330
|
lines.push(' below, and a residual much wider than a pixel moves all of them at once.');
|
|
1581
2331
|
lines.push(' The slots column is how many of the drawn slots could be attributed at all. A drift');
|
|
1582
2332
|
lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
|
|
@@ -1589,6 +2339,164 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1589
2339
|
return lines;
|
|
1590
2340
|
}
|
|
1591
2341
|
|
|
2342
|
+
/** One drift, as the table says it: distance, slot, frame. */
|
|
2343
|
+
function driftPhrase(drift: number, slot: string | null, frame: number): string {
|
|
2344
|
+
if (slot === null) return 'no slot attributable';
|
|
2345
|
+
return `${drift.toFixed(1)} px ${JSON.stringify(slot)} f${String(frame).padStart(4, '0')}`;
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
/** One set, broken down by chain — see `ChainCheck`. */
|
|
2349
|
+
function chainTable(anim: AnimationCheck): string[] {
|
|
2350
|
+
if (anim.chains.length === 0) return [];
|
|
2351
|
+
const out: string[] = [];
|
|
2352
|
+
out.push(
|
|
2353
|
+
` chains ${anim.chains.length} from the candidate's own bone tree — the roster is at the foot of the report`,
|
|
2354
|
+
);
|
|
2355
|
+
out.push(
|
|
2356
|
+
` ${'chain'.padEnd(20)} ${'slots'.padStart(6)} ${'worst slot drift'.padEnd(33)} ` +
|
|
2357
|
+
`${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
|
|
2358
|
+
);
|
|
2359
|
+
// Derivation order rather than worst-first, so the same row is in the same place
|
|
2360
|
+
// in every set's table and a run can be read down a column.
|
|
2361
|
+
for (const chain of anim.chains) {
|
|
2362
|
+
const mean = chain.driftSamples === 0 ? '—' : `${chain.meanDrift.toFixed(1)} px`;
|
|
2363
|
+
out.push(
|
|
2364
|
+
` ${chain.chain.padEnd(20)} ${`${chain.drewSlots}/${chain.slots}`.padStart(6)} ` +
|
|
2365
|
+
`${driftPhrase(chain.worstDrift, chain.worstDriftSlot, chain.worstDriftFrame).padEnd(33)} ` +
|
|
2366
|
+
`${mean.padStart(8)} ${f2(chain.mae).padStart(9)} ${`${(chain.maeShare * 100).toFixed(1)}%`.padStart(6)}`,
|
|
2367
|
+
);
|
|
2368
|
+
}
|
|
2369
|
+
if (anim.unattributedError > 0 && anim.chainDenominator > 0) {
|
|
2370
|
+
const share = (anim.unattributedError / anim.chainDenominator) * 100;
|
|
2371
|
+
out.push(
|
|
2372
|
+
` ${'(unattributed)'.padEnd(20)} ${'—'.padStart(6)} ${'—'.padEnd(33)} ${'—'.padStart(8)} ` +
|
|
2373
|
+
`${'—'.padStart(9)} ${`${share.toFixed(1)}%`.padStart(6)}`,
|
|
2374
|
+
);
|
|
2375
|
+
}
|
|
2376
|
+
out.push(
|
|
2377
|
+
" ⤷ share is of this set's own difference over the REFERENCE's drawn pixels, split by nearest ink; " +
|
|
2378
|
+
'`MAE in it` is that same error per pixel. The rule, the denominator and what a 0 % row means are under ' +
|
|
2379
|
+
'"chains" at the foot of the report.',
|
|
2380
|
+
);
|
|
2381
|
+
return out;
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
/** One line per chain across every set, plus the roster the names refer to. */
|
|
2385
|
+
function chainFoot(report: CheckReport): string[] {
|
|
2386
|
+
if (report.chains.length === 0) return [];
|
|
2387
|
+
const out: string[] = [];
|
|
2388
|
+
out.push(' ── chains ──');
|
|
2389
|
+
out.push(
|
|
2390
|
+
" Cut from the CANDIDATE's own bone tree at every branch point: a chain runs from a root or a fork down to the",
|
|
2391
|
+
);
|
|
2392
|
+
out.push(
|
|
2393
|
+
' next fork, a single-bone chain that is itself a fork folds into its parent, and each is named after the first',
|
|
2394
|
+
);
|
|
2395
|
+
out.push(
|
|
2396
|
+
' bone in it that carries a slot. The reference is still nothing but pixels — this is your figure decomposed,',
|
|
2397
|
+
);
|
|
2398
|
+
out.push(' not the reference’s, which is what keeps it inside the ladder’s honesty rule.');
|
|
2399
|
+
out.push('');
|
|
2400
|
+
out.push(" MAE share divides the difference over the REFERENCE's own drawn pixels — the denominator from the MAE");
|
|
2401
|
+
out.push(' line above, which nothing you draw can grow — and splits it by giving each of those pixels to the chain');
|
|
2402
|
+
out.push(' whose ink is NEAREST it. So the shares are a partition and add to the whole, and no chain can look');
|
|
2403
|
+
out.push(' better by drawing more: growing its ink only pulls more of the reference’s pixels, and their error,');
|
|
2404
|
+
out.push(' into it. `MAE in it` is the same error per pixel it took, and it is the column that separates a chain');
|
|
2405
|
+
out.push(' that is WRONG from one that is merely large — a head and its features cover a lot of a figure and can');
|
|
2406
|
+
out.push(' carry a third of the error at a below-average figure per pixel.');
|
|
2407
|
+
out.push('');
|
|
2408
|
+
out.push(' ⚠️ Two things the split cannot do, both of which show rather than hide. Reference ink further from your');
|
|
2409
|
+
out.push(' ink than the part’s own size is left `(unattributed)` instead of blamed on a neighbour, so a part that');
|
|
2410
|
+
out.push(' has left its place stops being charged to whatever is next to it. And a chain that draws NOTHING seeds');
|
|
2411
|
+
out.push(' nothing and reads 0 % — which is why the slots column is beside the share: 0 % on 0 slots drawn is the');
|
|
2412
|
+
out.push(' loudest row here, not the quietest.');
|
|
2413
|
+
out.push(` ${'chain'.padEnd(20)} ${'bones'.padEnd(57)} slots`);
|
|
2414
|
+
for (const chain of report.chains) {
|
|
2415
|
+
out.push(
|
|
2416
|
+
` ${chain.name.padEnd(20)} ${chain.bones.join(', ').padEnd(57)} ` +
|
|
2417
|
+
`${chain.slots.length === 0 ? '(draws nothing)' : chain.slots.join(', ')}`,
|
|
2418
|
+
);
|
|
2419
|
+
}
|
|
2420
|
+
const rows = chainRollup(report);
|
|
2421
|
+
if (rows.length === 0) return out;
|
|
2422
|
+
out.push('');
|
|
2423
|
+
out.push(
|
|
2424
|
+
` ${'chain'.padEnd(20)} ${'worst slot drift across every set'.padEnd(56)} ` +
|
|
2425
|
+
`${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
|
|
2426
|
+
);
|
|
2427
|
+
for (const row of rows) {
|
|
2428
|
+
const where = row.set === null ? '' : ` in ${row.set}/f${String(row.frame).padStart(4, '0')}`;
|
|
2429
|
+
const worst =
|
|
2430
|
+
row.slot === null ? 'no slot attributable in any set' : `${row.drift.toFixed(1)} px ${JSON.stringify(row.slot)}${where}`;
|
|
2431
|
+
const mean = row.samples === 0 ? '—' : `${row.mean.toFixed(1)} px`;
|
|
2432
|
+
out.push(
|
|
2433
|
+
` ${row.chain.padEnd(20)} ${worst.padEnd(56)} ${mean.padStart(8)} ` +
|
|
2434
|
+
`${(row.pixels === 0 ? 0 : row.error / row.pixels).toFixed(2).padStart(9)} ` +
|
|
2435
|
+
`${`${(row.share * 100).toFixed(1)}%`.padStart(6)}`,
|
|
2436
|
+
);
|
|
2437
|
+
}
|
|
2438
|
+
out.push('');
|
|
2439
|
+
return out;
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
interface ChainRollup {
|
|
2443
|
+
chain: string;
|
|
2444
|
+
drift: number;
|
|
2445
|
+
slot: string | null;
|
|
2446
|
+
set: string | null;
|
|
2447
|
+
frame: number;
|
|
2448
|
+
mean: number;
|
|
2449
|
+
samples: number;
|
|
2450
|
+
error: number;
|
|
2451
|
+
pixels: number;
|
|
2452
|
+
share: number;
|
|
2453
|
+
}
|
|
2454
|
+
|
|
2455
|
+
/**
|
|
2456
|
+
* Each chain's worst reading anywhere in the run, worst share first.
|
|
2457
|
+
*
|
|
2458
|
+
* Worst-first here and derivation order in the per-set tables, deliberately: this
|
|
2459
|
+
* is the line a run’s README quotes, so it is ranked by what to fix, while a table
|
|
2460
|
+
* printed once per set is ranked so the sets line up.
|
|
2461
|
+
*/
|
|
2462
|
+
function chainRollup(report: CheckReport): ChainRollup[] {
|
|
2463
|
+
const rows = new Map<string, ChainRollup>();
|
|
2464
|
+
let denominator = 0;
|
|
2465
|
+
for (const anim of report.animations) {
|
|
2466
|
+
if (anim.compared === 0) continue;
|
|
2467
|
+
denominator += anim.chainDenominator;
|
|
2468
|
+
for (const chain of anim.chains) {
|
|
2469
|
+
const row = rows.get(chain.chain) ?? {
|
|
2470
|
+
chain: chain.chain,
|
|
2471
|
+
drift: 0,
|
|
2472
|
+
slot: null,
|
|
2473
|
+
set: null,
|
|
2474
|
+
frame: -1,
|
|
2475
|
+
mean: 0,
|
|
2476
|
+
samples: 0,
|
|
2477
|
+
error: 0,
|
|
2478
|
+
pixels: 0,
|
|
2479
|
+
share: 0,
|
|
2480
|
+
};
|
|
2481
|
+
if (chain.worstDriftSlot !== null && chain.worstDrift > row.drift) {
|
|
2482
|
+
row.drift = chain.worstDrift;
|
|
2483
|
+
row.slot = chain.worstDriftSlot;
|
|
2484
|
+
row.set = anim.dir;
|
|
2485
|
+
row.frame = chain.worstDriftFrame;
|
|
2486
|
+
}
|
|
2487
|
+
row.mean = row.mean * row.samples + chain.meanDrift * chain.driftSamples;
|
|
2488
|
+
row.samples += chain.driftSamples;
|
|
2489
|
+
row.mean = row.samples === 0 ? 0 : row.mean / row.samples;
|
|
2490
|
+
row.error += chain.error;
|
|
2491
|
+
row.pixels += chain.referencePixels;
|
|
2492
|
+
rows.set(chain.chain, row);
|
|
2493
|
+
}
|
|
2494
|
+
}
|
|
2495
|
+
const out = [...rows.values()];
|
|
2496
|
+
for (const row of out) row.share = denominator === 0 ? 0 : row.error / denominator;
|
|
2497
|
+
return out.sort((a, b) => b.share - a.share);
|
|
2498
|
+
}
|
|
2499
|
+
|
|
1592
2500
|
/**
|
|
1593
2501
|
* The frames worth printing: the worst by MAE, plus every change disagreement.
|
|
1594
2502
|
*
|
|
@@ -1659,8 +2567,21 @@ function convergence(framing: FramingReport): string {
|
|
|
1659
2567
|
}
|
|
1660
2568
|
|
|
1661
2569
|
/** The framing, as the line an author reads before anything else. */
|
|
1662
|
-
|
|
1663
|
-
|
|
2570
|
+
/** The `framed to` line: the box that was rendered into, and how it was chosen. */
|
|
2571
|
+
function framedToLines(v: Framing, how: FramingHow | null, indent = ''): string[] {
|
|
2572
|
+
const said =
|
|
2573
|
+
how === 'candidate-pixels'
|
|
2574
|
+
? "fitted to the candidate's own drawn pixels"
|
|
2575
|
+
: how === 'frames-viewport'
|
|
2576
|
+
? `${FRAMES_SIDECAR}'s own box — the candidate measured into it`
|
|
2577
|
+
: '--viewport';
|
|
2578
|
+
return [
|
|
2579
|
+
`${indent} framed to ${v.pixelWidth}x${v.pixelHeight}px ${v.scale.toFixed(6)} px/unit ` +
|
|
2580
|
+
`world x[${v.x.toFixed(1)} .. ${(v.x + v.width).toFixed(1)}] y[${v.y.toFixed(1)} .. ${(v.y + v.height).toFixed(1)}] (${said})`,
|
|
2581
|
+
];
|
|
2582
|
+
}
|
|
2583
|
+
|
|
2584
|
+
function framingLines(framing: FramingReport | null, indent = ''): string[] {
|
|
1664
2585
|
if (!framing) return [];
|
|
1665
2586
|
const { fit } = framing;
|
|
1666
2587
|
const c = fit.candidate;
|
|
@@ -1670,8 +2591,8 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1670
2591
|
`${boxWidth(b).toFixed(1)}x${boxHeight(b).toFixed(1)}px at (${b.left.toFixed(1)}, ${b.top.toFixed(1)})`;
|
|
1671
2592
|
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
1672
2593
|
const out = [
|
|
1673
|
-
|
|
1674
|
-
|
|
2594
|
+
`${indent} content candidate ${box(c)} reference ${box(r)} (union over ${fit.frames} frame(s))`,
|
|
2595
|
+
`${indent} ⤷ fit x${fit.scale.toFixed(6)} offset ${signed(fit.dx)}, ${signed(fit.dy)} px ` +
|
|
1675
2596
|
`rms ${fit.rms.toFixed(2)} px over ${fit.frames * 4} edge(s) ` +
|
|
1676
2597
|
`union residual ${signed(fit.residualWidth)} x ${signed(fit.residualHeight)} px ` +
|
|
1677
2598
|
`aspect ${percent(fit.aspectError)}` +
|
|
@@ -1683,7 +2604,7 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1683
2604
|
if (spread > 1) {
|
|
1684
2605
|
const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
|
|
1685
2606
|
out.push(
|
|
1686
|
-
|
|
2607
|
+
`${indent} ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
|
|
1687
2608
|
`${axis} and ${Math.abs(fit.residualHeight).toFixed(1)} px ` +
|
|
1688
2609
|
`${fit.residualHeight > 0 ? 'taller' : 'shorter'} than the reference's. One uniform scale cannot absorb ` +
|
|
1689
2610
|
'that: something reaches somewhere nothing in the frames does, or is a different size. Read it before ' +
|
|
@@ -1692,7 +2613,7 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1692
2613
|
}
|
|
1693
2614
|
if (fit.rms > 1) {
|
|
1694
2615
|
out.push(
|
|
1695
|
-
|
|
2616
|
+
`${indent} ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
|
|
1696
2617
|
'scale and offset puts the two shots on each other — they are different shapes, not the same shape ' +
|
|
1697
2618
|
'misframed.',
|
|
1698
2619
|
);
|
|
@@ -1700,12 +2621,12 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1700
2621
|
const units = framing.units;
|
|
1701
2622
|
if (units) {
|
|
1702
2623
|
out.push(
|
|
1703
|
-
|
|
2624
|
+
`${indent} in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
|
|
1704
2625
|
`reference ${units.reference.width.toFixed(1)} x ${units.reference.height.toFixed(1)} ` +
|
|
1705
2626
|
`x${units.ratio.toFixed(4)}`,
|
|
1706
2627
|
);
|
|
1707
2628
|
out.push(
|
|
1708
|
-
|
|
2629
|
+
`${indent} ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ` +
|
|
1709
2630
|
'purpose — a rig is authored in its own coordinates — so this is the only place one shows. It compares ' +
|
|
1710
2631
|
'only if you measured the shot in the frames’ own units.',
|
|
1711
2632
|
);
|