spine-rigc 0.2.1 → 0.4.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 +258 -9
- package/cli.ts +57 -6
- package/docs/AUTHORING.md +1006 -50
- package/docs/SPEC_COVERAGE.md +21 -14
- package/package.json +5 -2
- package/src/chains.ts +170 -0
- package/src/check.ts +1555 -98
- package/src/compile.ts +325 -6
- package/src/framing.ts +280 -0
- package/src/ladder.ts +1 -1
- package/src/render.ts +43 -2
- package/src/rig.ts +169 -6
- package/src/slots.ts +102 -4
- 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,10 @@ import {
|
|
|
77
77
|
PAD,
|
|
78
78
|
FRAMES_SIDECAR,
|
|
79
79
|
FRAMES_SPEC,
|
|
80
|
+
SHEET_COLUMNS,
|
|
81
|
+
SHEET_FILE,
|
|
82
|
+
SHEET_GAP,
|
|
83
|
+
type Footprint,
|
|
80
84
|
type Frame,
|
|
81
85
|
type FramesSidecar,
|
|
82
86
|
type FrameSet,
|
|
@@ -93,18 +97,38 @@ import {
|
|
|
93
97
|
fitIsSettled,
|
|
94
98
|
fitSeparation,
|
|
95
99
|
frameContentBox,
|
|
100
|
+
offsetIsWorthApplying,
|
|
101
|
+
OffsetScan,
|
|
102
|
+
shiftViewport,
|
|
103
|
+
unionBoxes,
|
|
96
104
|
isContent,
|
|
97
105
|
BACKGROUND_TOLERANCE,
|
|
98
106
|
CYCLE_PIXELS,
|
|
107
|
+
REFINE_MIN_GAIN,
|
|
108
|
+
REFINE_MIN_GAIN_MAE,
|
|
109
|
+
REFINE_RADIUS,
|
|
99
110
|
type BoxPair,
|
|
100
111
|
type ContentBox,
|
|
101
112
|
type FramingFit,
|
|
113
|
+
type OffsetGain,
|
|
102
114
|
} from './framing.ts';
|
|
103
|
-
import {
|
|
115
|
+
import { componentField, isAttributable, matchSlots, searchRadius, type SlotTrack } from './slots.ts';
|
|
116
|
+
import { chainsOf, type BoneChain } from './chains.ts';
|
|
104
117
|
import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
export
|
|
118
|
+
import { GLYPH_H, textWidth } from '../tools/font5x7.ts';
|
|
119
|
+
|
|
120
|
+
export {
|
|
121
|
+
componentField,
|
|
122
|
+
componentsOf,
|
|
123
|
+
matchSlots,
|
|
124
|
+
searchRadius,
|
|
125
|
+
type Component,
|
|
126
|
+
type ComponentField,
|
|
127
|
+
type MatchMethod,
|
|
128
|
+
type SlotTrack,
|
|
129
|
+
} from './slots.ts';
|
|
130
|
+
export { chainsOf, chainBySlot, type BoneChain } from './chains.ts';
|
|
131
|
+
export type { BoxPair, ContentBox, FramingFit, OffsetGain } from './framing.ts';
|
|
108
132
|
|
|
109
133
|
// ---------------------------------------------------------------------------
|
|
110
134
|
// the reference side — frames only, and mechanically so
|
|
@@ -259,6 +283,25 @@ export interface FrameCheck {
|
|
|
259
283
|
file: string;
|
|
260
284
|
/** Mean absolute RGB difference over the union alpha, 0..255. */
|
|
261
285
|
mae: number;
|
|
286
|
+
/**
|
|
287
|
+
* The same total difference over the REFERENCE's own drawn pixels alone.
|
|
288
|
+
*
|
|
289
|
+
* ⭐ The figure to optimise against, and the reason is the denominator. `mae`
|
|
290
|
+
* divides by the pixels either side drew, and the candidate owns half of that:
|
|
291
|
+
* drawing something large and mostly transparent adds many cheap pixels to the
|
|
292
|
+
* union and the *mean falls*, so an optimiser can buy a better score by growing
|
|
293
|
+
* (issue #119 — a muzzle flare walked its own scale to 13x doing exactly this).
|
|
294
|
+
* This denominator is the reference's, which nothing the candidate does can
|
|
295
|
+
* move, so the only way down is to draw the reference's picture.
|
|
296
|
+
*
|
|
297
|
+
* ⚠️ Not bounded by 255, and deliberately: a candidate that draws far more than
|
|
298
|
+
* the reference has more absolute error than the reference has pixels to carry
|
|
299
|
+
* it, and the figure says so instead of saturating.
|
|
300
|
+
*
|
|
301
|
+
* `mae` is still the right figure for comparing two builds of the same rig,
|
|
302
|
+
* where the union is near enough the same on both sides.
|
|
303
|
+
*/
|
|
304
|
+
maeReference: number;
|
|
262
305
|
/**
|
|
263
306
|
* The same difference averaged over the WHOLE frame, background included.
|
|
264
307
|
*
|
|
@@ -285,6 +328,111 @@ export interface FrameCheck {
|
|
|
285
328
|
change: FrameChange | null;
|
|
286
329
|
}
|
|
287
330
|
|
|
331
|
+
/**
|
|
332
|
+
* One bone chain's slice of a set — the row an author reads before deciding what
|
|
333
|
+
* to re-key.
|
|
334
|
+
*
|
|
335
|
+
* The chains come from the CANDIDATE's bone tree (`src/chains.ts` owns the rule
|
|
336
|
+
* and the reasoning); the reference stays pixels, so this is a decomposition of
|
|
337
|
+
* your own figure and never a reading of the answer.
|
|
338
|
+
*/
|
|
339
|
+
export interface ChainCheck {
|
|
340
|
+
/** The chain, named as `src/chains.ts` names it. */
|
|
341
|
+
chain: string;
|
|
342
|
+
/** How many slots it owns. */
|
|
343
|
+
slots: number;
|
|
344
|
+
/** How many of those drew anything in at least one compared frame. */
|
|
345
|
+
drewSlots: number;
|
|
346
|
+
/** The worst attributable slot drift anywhere in it, in frame pixels. */
|
|
347
|
+
worstDrift: number;
|
|
348
|
+
/** Which slot that was, and in which frame — `null`/`-1` when none was attributable. */
|
|
349
|
+
worstDriftSlot: string | null;
|
|
350
|
+
worstDriftFrame: number;
|
|
351
|
+
/** The mean of every attributable slot drift in it, over `driftSamples` of them. */
|
|
352
|
+
meanDrift: number;
|
|
353
|
+
driftSamples: number;
|
|
354
|
+
/** How many frames contributed at least one of those samples. */
|
|
355
|
+
driftFrames: number;
|
|
356
|
+
/**
|
|
357
|
+
* The absolute RGB difference attributed to this chain, summed over the
|
|
358
|
+
* REFERENCE's own drawn pixels — never over the union.
|
|
359
|
+
*
|
|
360
|
+
* ⭐ The denominator lesson from issue #119, applied to a share. A reference
|
|
361
|
+
* pixel goes to the chain whose ink is nearest to it, so the chains partition
|
|
362
|
+
* the reference's drawn pixels and the shares add up to the whole. What the
|
|
363
|
+
* candidate controls is only *which* chain a pixel lands in, and growing a
|
|
364
|
+
* chain's ink pulls MORE of the reference's pixels — and their error — into it.
|
|
365
|
+
* There is no move here that makes a chain look better by drawing more, which is
|
|
366
|
+
* exactly what the union MAE could not say.
|
|
367
|
+
*/
|
|
368
|
+
error: number;
|
|
369
|
+
/** How many reference-drawn pixels it took, summed over frames. */
|
|
370
|
+
referencePixels: number;
|
|
371
|
+
/**
|
|
372
|
+
* `error` per pixel it took — the MAE *inside* this chain, 0..255.
|
|
373
|
+
*
|
|
374
|
+
* Printed beside the share because the share alone confounds being wrong with
|
|
375
|
+
* being big: spineboy's head, goggles, eye and mouth are one chain covering a
|
|
376
|
+
* lot of the figure, so it can carry a third of the error at a per-pixel figure
|
|
377
|
+
* below the run's own mean. The share says where the error IS; this says whether
|
|
378
|
+
* the chain is actually worse than the rest of the figure.
|
|
379
|
+
*/
|
|
380
|
+
mae: number;
|
|
381
|
+
/** `error` over the set's own total, 0..1 — see `AnimationCheck.chainDenominator`. */
|
|
382
|
+
maeShare: number;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
/**
|
|
386
|
+
* The whole shot against the contact sheet beside it — the frames `check` has no
|
|
387
|
+
* file for.
|
|
388
|
+
*
|
|
389
|
+
* ## Why a sheet is a frame set and not a picture of one
|
|
390
|
+
*
|
|
391
|
+
* A long shot does not commit 311 near-duplicate PNGs. It commits a couple of
|
|
392
|
+
* stills and folds every sampled frame into one `contact.png`, and `check` used to
|
|
393
|
+
* read the two stills, say `2 compared`, and mean it — an honest number with a hole
|
|
394
|
+
* behind it: nothing whatever was measured about the other 309 frames, so a clean
|
|
395
|
+
* table said nothing about the shot (issue #36). A sheet is the same thing a frame
|
|
396
|
+
* is, at a smaller scale and with a frame number burned into the corner, and
|
|
397
|
+
* reading it reads no reference skeleton.
|
|
398
|
+
*
|
|
399
|
+
* So the candidate is sampled at the set's own rate, rendered into the same world
|
|
400
|
+
* box the set was framed in at the sheet's own scale, and each frame is compared
|
|
401
|
+
* against its own tile. The prototype this replaces was written in-run by the
|
|
402
|
+
* second rung-2 attempt, which found with it what the two-still table could not:
|
|
403
|
+
* flat MAE 4.85–4.95 over all 1,244 frames of four shots, no spikes — evidence
|
|
404
|
+
* that trajectories, ring rates and attachment swaps land where and when they
|
|
405
|
+
* should.
|
|
406
|
+
*
|
|
407
|
+
* ## What it deliberately does not measure: the per-frame change
|
|
408
|
+
*
|
|
409
|
+
* The tiles are adjacent, so `FrameChange` looks reachable here, and it is not:
|
|
410
|
+
* `CHANGE_EXCESS` is two dozen pixels at frame scale, and a quarter-scale tile has
|
|
411
|
+
* a sixteenth of the pixels to move. The thresholds would have to be re-derived
|
|
412
|
+
* against sheets before that column could mean anything, and a figure printed at
|
|
413
|
+
* the wrong scale is worse than one not printed. MAE only, and the report says so.
|
|
414
|
+
*/
|
|
415
|
+
export interface SheetCheck {
|
|
416
|
+
/** The sheet itself, so a worst-tile line is openable. */
|
|
417
|
+
file: string;
|
|
418
|
+
/** The grid, measured off the sheet — see `sheetGeometry`. */
|
|
419
|
+
columns: number;
|
|
420
|
+
tileWidth: number;
|
|
421
|
+
tileHeight: number;
|
|
422
|
+
/** Frame pixels per tile pixel: how much smaller a tile is than a frame. */
|
|
423
|
+
tileScale: number;
|
|
424
|
+
/** How many tiles the sheet holds, and how many the candidate could be compared on. */
|
|
425
|
+
tiles: number;
|
|
426
|
+
compared: number;
|
|
427
|
+
/** Mean and worst over the tiles, in the same two denominators the frames use. */
|
|
428
|
+
meanMae: number;
|
|
429
|
+
meanMaeReference: number;
|
|
430
|
+
worstMae: number;
|
|
431
|
+
worstTile: number;
|
|
432
|
+
/** The worst tiles by MAE, worst first — at most `WORST_FRAMES` of them. */
|
|
433
|
+
worst: Array<{ index: number; mae: number }>;
|
|
434
|
+
}
|
|
435
|
+
|
|
288
436
|
export interface AnimationCheck {
|
|
289
437
|
dir: string;
|
|
290
438
|
/** The animation the frames show, per the sidecar. */
|
|
@@ -296,6 +444,18 @@ export interface AnimationCheck {
|
|
|
296
444
|
candidateFrames: number;
|
|
297
445
|
compared: number;
|
|
298
446
|
meanMae: number;
|
|
447
|
+
/** Mean of the per-frame reference-denominator MAE — see `FrameCheck.maeReference`. */
|
|
448
|
+
meanMaeReference: number;
|
|
449
|
+
/**
|
|
450
|
+
* How much this set draws, against how much the reference draws: the mean over
|
|
451
|
+
* its frames of `candidatePixels / referencePixels`.
|
|
452
|
+
*
|
|
453
|
+
* 1 means the two shots put ink on the same amount of the frame. Above 1 the
|
|
454
|
+
* candidate is drawing more than the reference does, which is the move that
|
|
455
|
+
* makes the union MAE cheaper — see `OVERDRAW_RATIO`, which is where the
|
|
456
|
+
* threshold and the corpus it came from are written down.
|
|
457
|
+
*/
|
|
458
|
+
drawnRatio: number;
|
|
299
459
|
/** Mean of the per-frame whole-frame MAE — see `FrameCheck.maeFrame`. */
|
|
300
460
|
meanMaeFrame: number;
|
|
301
461
|
worstMae: number;
|
|
@@ -311,7 +471,44 @@ export interface AnimationCheck {
|
|
|
311
471
|
changeDisagreements: number;
|
|
312
472
|
/** The widest of those disagreements, and `-1` when there is none. */
|
|
313
473
|
worstChangeFrame: number;
|
|
474
|
+
/**
|
|
475
|
+
* This set, broken down by the candidate's own bone chains — see `ChainCheck`.
|
|
476
|
+
*
|
|
477
|
+
* Chains that own no slot at all are left out: they have nothing to attribute.
|
|
478
|
+
* They are still in `CheckReport.chains`, so the roster stays a complete account
|
|
479
|
+
* of where every bone went.
|
|
480
|
+
*/
|
|
481
|
+
chains: ChainCheck[];
|
|
482
|
+
/**
|
|
483
|
+
* The set's whole difference over the reference's own drawn pixels — the
|
|
484
|
+
* denominator every `ChainCheck.maeShare` divides by.
|
|
485
|
+
*
|
|
486
|
+
* The same numerator `meanMaeReference` averages, kept as a total because a
|
|
487
|
+
* share needs the total and a mean has already divided it away.
|
|
488
|
+
*/
|
|
489
|
+
chainDenominator: number;
|
|
490
|
+
/** The part of it no chain could take, because the candidate drew nothing at all. */
|
|
491
|
+
unattributedError: number;
|
|
314
492
|
frames: FrameCheck[];
|
|
493
|
+
/**
|
|
494
|
+
* The box THIS set's candidate frames were rendered into.
|
|
495
|
+
*
|
|
496
|
+
* Under the default per-shot scope every set carries its own, and they are
|
|
497
|
+
* different boxes; under `--framing shared` they are all the same one. Either
|
|
498
|
+
* way it is here rather than only at the top of the report, because it is
|
|
499
|
+
* upstream of every number in this row.
|
|
500
|
+
*/
|
|
501
|
+
viewport: Framing;
|
|
502
|
+
/** How this set's box was chosen — see `FramingSource`. */
|
|
503
|
+
framing: FramingHow;
|
|
504
|
+
/** Where this set's drawn pixels ended up against the reference's. */
|
|
505
|
+
framingFit: FramingReport | null;
|
|
506
|
+
/**
|
|
507
|
+
* The whole shot against the contact sheet, when the set ships one and does not
|
|
508
|
+
* ship every frame — see `SheetCheck`. `null` when there is nothing to add: no
|
|
509
|
+
* sheet, or every sampled frame already on disk as a frame of its own.
|
|
510
|
+
*/
|
|
511
|
+
sheet: SheetCheck | null;
|
|
315
512
|
notes: string[];
|
|
316
513
|
}
|
|
317
514
|
|
|
@@ -339,9 +536,56 @@ export interface Framing {
|
|
|
339
536
|
*/
|
|
340
537
|
export type FramingSource = 'derived' | 'declared' | 'pinned';
|
|
341
538
|
|
|
539
|
+
/** The same three, named for the report line rather than for the code path. */
|
|
540
|
+
export type FramingHow = 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
|
|
541
|
+
|
|
542
|
+
/**
|
|
543
|
+
* Whether the framing is decided per frame set, or once across every set.
|
|
544
|
+
*
|
|
545
|
+
* ## What `per-shot` actually scopes, and why only that
|
|
546
|
+
*
|
|
547
|
+
* A framing is decided over the frames it is measured on, so pointing `check` at a
|
|
548
|
+
* skeleton root used to decide ONE for every set under it — and one badly-framed
|
|
549
|
+
* shot was then paid for by all the others. Measured on the spineboy rung, 8 shots
|
|
550
|
+
* and 147 frames: `idle` read **41.59** MAE at the root against the **18.77** it
|
|
551
|
+
* reads on its own frames, with not one key different (issue #100).
|
|
552
|
+
*
|
|
553
|
+
* `per-shot` moves exactly one decision into the set: **whether the box
|
|
554
|
+
* `frames.json` records is this set's box too.** That decision is a measurement
|
|
555
|
+
* with no floor — either the set's own drawn pixels land in the declared box or
|
|
556
|
+
* they do not — and over the union one shot that does not can put the pooled
|
|
557
|
+
* correction over `COINCIDENT_PIXELS` and take every other shot down with it. Per
|
|
558
|
+
* set, the ones that qualify read exactly what pinning by hand reads.
|
|
559
|
+
*
|
|
560
|
+
* ⚠️ It does NOT fit a separate chain per set, and that is a measured decision
|
|
561
|
+
* rather than a simplification. Per-set FITTING is worse: `fitFraming` registers
|
|
562
|
+
* extent, extent is not alignment, and one shot's frames do not constrain that
|
|
563
|
+
* enough — spineboy's `hit` reads 92.36 fitted on its own against 60.59 in the
|
|
564
|
+
* shared fit, and its two-frame `shoot@30fps` set reads 101.94 against 42.98. So a
|
|
565
|
+
* set that cannot take the declared box is measured in the shared framing, where
|
|
566
|
+
* every frame in the run constrains the answer.
|
|
567
|
+
*
|
|
568
|
+
* `shared` is the old behaviour, and it answers one question well: *does a single
|
|
569
|
+
* box serve every set?* The report prints that fit either way — see
|
|
570
|
+
* `CheckReport.sharedFraming`.
|
|
571
|
+
*
|
|
572
|
+
* ⚠️ The two are different measurements and their absolute numbers are not
|
|
573
|
+
* comparable across builds. The report says which one it did.
|
|
574
|
+
*/
|
|
575
|
+
export type FramingScope = 'per-shot' | 'shared';
|
|
576
|
+
|
|
342
577
|
/** What the framing pass concluded, and how sure it is of it. */
|
|
343
578
|
export interface FramingReport {
|
|
344
|
-
/**
|
|
579
|
+
/**
|
|
580
|
+
* The residual fit measured at the box the framing chain chose.
|
|
581
|
+
*
|
|
582
|
+
* ⚠️ **Before** the MAE-refined pass below it, when that pass moved the box —
|
|
583
|
+
* and deliberately, because the two answer different questions and both are
|
|
584
|
+
* worth printing: this says how far the two *extents* were from registering,
|
|
585
|
+
* `refinement` says how far the *pictures* were from each other after that. Its
|
|
586
|
+
* `settled` / `agrees` / `cycled` words describe the chain that reached the box,
|
|
587
|
+
* which is a fact about the chain and does not change afterwards.
|
|
588
|
+
*/
|
|
345
589
|
fit: FramingFit;
|
|
346
590
|
/** How many render/measure/correct passes ran. */
|
|
347
591
|
passes: number;
|
|
@@ -389,6 +633,70 @@ export interface FramingReport {
|
|
|
389
633
|
* what it does and does not mean attached.
|
|
390
634
|
*/
|
|
391
635
|
units: { candidate: Extent; reference: Extent; ratio: number } | null;
|
|
636
|
+
/**
|
|
637
|
+
* What the MAE-refined final pass found, and whether it moved the box.
|
|
638
|
+
*
|
|
639
|
+
* `null` when nothing could be searched (no frame with reference ink). See
|
|
640
|
+
* `FramingRefinement`.
|
|
641
|
+
*/
|
|
642
|
+
refinement: FramingRefinement | null;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* The final framing pass, which asks a different question from every pass above
|
|
647
|
+
* it: not *do the two extents register?* but *is a constant pixel of this set's
|
|
648
|
+
* MAE a framing offset?*
|
|
649
|
+
*
|
|
650
|
+
* ## Why it exists, and why it is the last thing that happens
|
|
651
|
+
*
|
|
652
|
+
* Issue #146 measured the answer on the spineboy candidates: a **constant**
|
|
653
|
+
* translation of one or two pixels is worth 12 % of `death`'s headline
|
|
654
|
+
* reference-denominator MAE and up to 30 % of a fitted set's, while what is left
|
|
655
|
+
* after it is taken out is per-frame and small. That is `fitFraming`'s documented
|
|
656
|
+
* "extent is not alignment" floor arriving as a tenth of the number an author is
|
|
657
|
+
* reading as motion. `OffsetScan` searches whole-pixel offsets against the MAE
|
|
658
|
+
* itself, so it cannot walk off the answer the way the extent refinement measured
|
|
659
|
+
* and rejected in `frameByDeclaredBox` did — the figure it minimises is the figure
|
|
660
|
+
* the report prints.
|
|
661
|
+
*
|
|
662
|
+
* ## ⭐ Where it is allowed to move the box, and where it only reports
|
|
663
|
+
*
|
|
664
|
+
* **A fitted box is an estimate and gets corrected. An exact box does not.**
|
|
665
|
+
*
|
|
666
|
+
* - `derived` — the box came from a fit of extents, so a constant pixel in it is
|
|
667
|
+
* the estimator's own floor. The offset is applied.
|
|
668
|
+
* - `declared` — `frames.json`'s box is not an estimate of where the frames were
|
|
669
|
+
* drawn, it is where they were drawn (`frameByDeclaredBox`). A constant pixel
|
|
670
|
+
* *there* is the candidate's own figure sitting a pixel off inside the right
|
|
671
|
+
* box, which is a finding an author can act on and the framing must not absorb.
|
|
672
|
+
* Searched, reported, never applied — and measured: over the committed corpus
|
|
673
|
+
* every declared-box set's best offset is the exact identity, so this branch
|
|
674
|
+
* has cost nothing so far and would only ever fire on a real offset.
|
|
675
|
+
* - `pinned` — `--viewport` is the author's claim about their own coordinates and
|
|
676
|
+
* nothing here overrides it, exactly as the fit above is measured and not
|
|
677
|
+
* applied.
|
|
678
|
+
*/
|
|
679
|
+
export interface FramingRefinement {
|
|
680
|
+
/** The best whole-pixel offset found, in frame pixels. `0, 0` means none was. */
|
|
681
|
+
dx: number;
|
|
682
|
+
dy: number;
|
|
683
|
+
/** Was it applied to the box the set was measured in? */
|
|
684
|
+
applied: boolean;
|
|
685
|
+
/** The set's mean reference-denominator MAE at the box the framing chose... */
|
|
686
|
+
before: number;
|
|
687
|
+
/** ...and at `dx, dy`, which is the same figure with the constant taken out. */
|
|
688
|
+
after: number;
|
|
689
|
+
/** How far the search looked, and how many frames it pooled. */
|
|
690
|
+
radius: number;
|
|
691
|
+
frames: number;
|
|
692
|
+
/**
|
|
693
|
+
* Why the offset was not applied — `null` when it was.
|
|
694
|
+
*
|
|
695
|
+
* `identity` is the answer this pass gives on a set whose framing is already
|
|
696
|
+
* where the picture is, and it is a measurement rather than a default: the
|
|
697
|
+
* search ran over the whole window and the identity won it.
|
|
698
|
+
*/
|
|
699
|
+
declined: 'identity' | 'below-threshold' | 'box-is-exact' | 'pinned' | null;
|
|
392
700
|
}
|
|
393
701
|
|
|
394
702
|
/** A width and a height in world units. */
|
|
@@ -401,12 +709,35 @@ export interface CheckReport {
|
|
|
401
709
|
candidate: { skeleton: string; atlas: string };
|
|
402
710
|
framesDir: string;
|
|
403
711
|
framesRoot: string;
|
|
404
|
-
/**
|
|
405
|
-
|
|
712
|
+
/** One framing per set, or one across every set — see `FramingScope`. */
|
|
713
|
+
framingScope: FramingScope;
|
|
714
|
+
/**
|
|
715
|
+
* How the candidate's own world box was chosen, when ONE box covers the run.
|
|
716
|
+
*
|
|
717
|
+
* `null` under a per-shot scope with more than one set compared: there is no
|
|
718
|
+
* single answer then, and each `AnimationCheck` carries its own. A run that
|
|
719
|
+
* compared exactly one set fills these in whatever the scope, because for one
|
|
720
|
+
* set the two scopes are the same measurement.
|
|
721
|
+
*/
|
|
722
|
+
framing: FramingHow | null;
|
|
406
723
|
/** The box the CANDIDATE was rendered into, at the reference's pixel size. */
|
|
407
|
-
viewport: Framing;
|
|
724
|
+
viewport: Framing | null;
|
|
408
725
|
/** Where the candidate's drawn pixels ended up against the reference's. */
|
|
409
726
|
framingFit: FramingReport | null;
|
|
727
|
+
/**
|
|
728
|
+
* The framing ONE shared box gives across every set compared.
|
|
729
|
+
*
|
|
730
|
+
* Under `per-shot` it is both reported and used: every set that cannot take the
|
|
731
|
+
* frames' own declared box is measured in it. It is also the figure that says
|
|
732
|
+
* *why* a whole-root run is a different measurement — a set that reads well in
|
|
733
|
+
* the declared box and badly here is a set the old whole-root run was measuring
|
|
734
|
+
* through somebody else's silhouette, which is what `idle` reading 41.59 against
|
|
735
|
+
* 18.77 was (issue #100).
|
|
736
|
+
*
|
|
737
|
+
* `null` when the scope is already shared — `framingFit` is that number then —
|
|
738
|
+
* and when only one set was compared, where the two scopes are the same thing.
|
|
739
|
+
*/
|
|
740
|
+
sharedFraming: FramingReport | null;
|
|
410
741
|
/**
|
|
411
742
|
* The box the REFERENCE was rendered into, when the sidecar records one.
|
|
412
743
|
*
|
|
@@ -416,6 +747,14 @@ export interface CheckReport {
|
|
|
416
747
|
*/
|
|
417
748
|
referenceViewport: Framing | null;
|
|
418
749
|
background: RGBA;
|
|
750
|
+
/**
|
|
751
|
+
* The candidate's bone tree, cut into chains — the roster the report prints.
|
|
752
|
+
*
|
|
753
|
+
* Printed rather than assumed, because a decomposition an author has to guess at
|
|
754
|
+
* is one they will read wrong: the table says `front-thigh` and the roster says
|
|
755
|
+
* which bones and which slots that name covers. `src/chains.ts` owns the rule.
|
|
756
|
+
*/
|
|
757
|
+
chains: BoneChain[];
|
|
419
758
|
animations: AnimationCheck[];
|
|
420
759
|
notes: string[];
|
|
421
760
|
}
|
|
@@ -443,6 +782,15 @@ export interface CheckOptions {
|
|
|
443
782
|
viewport?: { x: number; y: number; width: number; height: number };
|
|
444
783
|
/** Play this candidate animation against the frames, when the names differ. */
|
|
445
784
|
as?: string;
|
|
785
|
+
/**
|
|
786
|
+
* Fit one framing per frame set, or one across every set compared.
|
|
787
|
+
*
|
|
788
|
+
* Defaults to `per-shot`. See `FramingScope` for what the choice costs and why
|
|
789
|
+
* this is the default; it has no effect when only one set is compared, and none
|
|
790
|
+
* when `viewport` pins the box (a pin is a claim about the candidate's own
|
|
791
|
+
* coordinates, and those do not change between shots).
|
|
792
|
+
*/
|
|
793
|
+
framing?: FramingScope;
|
|
446
794
|
}
|
|
447
795
|
|
|
448
796
|
// ---------------------------------------------------------------------------
|
|
@@ -572,34 +920,62 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
572
920
|
const referenceBoxes =
|
|
573
921
|
pairs.length === 0 ? [] : referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight);
|
|
574
922
|
|
|
575
|
-
|
|
576
|
-
|
|
923
|
+
const scope: FramingScope = options.framing ?? 'per-shot';
|
|
924
|
+
const slices = sliceBySet(prepared, referenceBoxes);
|
|
925
|
+
/** One per prepared set, in `prepared` order. */
|
|
926
|
+
const framings: SetFraming[] = [];
|
|
927
|
+
let topViewport: Viewport | null = null;
|
|
928
|
+
let topHow: FramingHow | null = null;
|
|
929
|
+
let topFit: FramingReport | null = null;
|
|
930
|
+
let sharedFraming: FramingReport | null = null;
|
|
931
|
+
|
|
932
|
+
const reportFor = (fit: FramingFit, at: Viewport, over: Omit<FramingReport, 'units' | 'fit'>): FramingReport => ({
|
|
933
|
+
...over,
|
|
934
|
+
fit,
|
|
935
|
+
units: extentsOf(fit, at.scale, referenceViewport),
|
|
936
|
+
});
|
|
937
|
+
|
|
577
938
|
if (options.viewport) {
|
|
939
|
+
// A pin is a claim about the CANDIDATE's own coordinates, and those do not
|
|
940
|
+
// change between shots — so one box covers the run whatever the scope. The
|
|
941
|
+
// per-set fits below are free: every frame is measured in that one box once,
|
|
942
|
+
// and splitting the result per set costs nothing.
|
|
578
943
|
const v = options.viewport;
|
|
579
|
-
|
|
944
|
+
const pinned = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
|
|
580
945
|
notes.push(
|
|
581
946
|
`the candidate's world box was pinned by --viewport ${v.x},${v.y},${v.width},${v.height} rather than derived ` +
|
|
582
947
|
"from its own pixels — that is a claim about the candidate's coordinates, and nothing here checks it. The " +
|
|
583
948
|
'framing line below is still measured, so it says what the pin cost.',
|
|
584
949
|
);
|
|
585
|
-
const
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
950
|
+
const pinnedShape = { passes: 1, settled: false, source: 'pinned' as const, cycled: false, applied: false };
|
|
951
|
+
// A pin is one claim for the whole run, so the refined pass is measured over
|
|
952
|
+
// the run as a whole — and never applied, for the same reason the fit is not.
|
|
953
|
+
const refinement = refinementOf(scanOffsets(located.root, prepared, posable.pages, pinned, background), 'pinned');
|
|
954
|
+
const perSet = prepared.map((p, i) =>
|
|
955
|
+
pairUpBoxes([p], posable.pages, pinned, background, level, slices[i]),
|
|
956
|
+
);
|
|
957
|
+
for (const boxes of perSet) {
|
|
958
|
+
const fit = boxes.length === 0 ? null : fitFraming(boxes);
|
|
959
|
+
framings.push({
|
|
960
|
+
viewport: pinned,
|
|
961
|
+
how: 'viewport-flag',
|
|
962
|
+
fit:
|
|
963
|
+
fit === null
|
|
964
|
+
? null
|
|
965
|
+
: reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement }),
|
|
966
|
+
notes: [],
|
|
967
|
+
});
|
|
598
968
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
969
|
+
const all = perSet.flat();
|
|
970
|
+
topViewport = pinned;
|
|
971
|
+
topHow = 'viewport-flag';
|
|
972
|
+
if (all.length > 0) {
|
|
973
|
+
const fit = fitFraming(all);
|
|
974
|
+
topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement });
|
|
602
975
|
}
|
|
976
|
+
} else if (referenceBoxes.every((b) => b === null)) {
|
|
977
|
+
throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
|
|
978
|
+
} else if (scope === 'shared' || prepared.length === 1) {
|
|
603
979
|
const framed = frameCandidate(
|
|
604
980
|
prepared,
|
|
605
981
|
posable.pages,
|
|
@@ -610,14 +986,136 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
610
986
|
pixelHeight,
|
|
611
987
|
referenceViewport,
|
|
612
988
|
);
|
|
613
|
-
|
|
614
|
-
|
|
989
|
+
// One box for the run, so one refined pass over every frame in it: a single
|
|
990
|
+
// shared framing that each set nudged its own way would not be a shared one.
|
|
991
|
+
const refinement = refinementOf(
|
|
992
|
+
scanOffsets(located.root, prepared, posable.pages, framed.viewport, background),
|
|
993
|
+
framed.report.source,
|
|
994
|
+
);
|
|
995
|
+
const viewport =
|
|
996
|
+
refinement !== null && refinement.applied
|
|
997
|
+
? shiftViewport(framed.viewport, refinement.dx, refinement.dy, pixelWidth, pixelHeight)
|
|
998
|
+
: framed.viewport;
|
|
999
|
+
const fit = {
|
|
1000
|
+
...framed.report,
|
|
1001
|
+
units: extentsOf(framed.report.fit, framed.viewport.scale, referenceViewport),
|
|
1002
|
+
refinement,
|
|
1003
|
+
};
|
|
1004
|
+
const how = HOW_BY_SOURCE[framed.report.source];
|
|
1005
|
+
for (let i = 0; i < prepared.length; i++) framings.push({ viewport, how, fit, notes: [] });
|
|
1006
|
+
topViewport = viewport;
|
|
1007
|
+
topHow = how;
|
|
1008
|
+
topFit = fit;
|
|
615
1009
|
notes.push(...framingNotes(framed.report));
|
|
1010
|
+
if (prepared.length > 1) {
|
|
1011
|
+
notes.push(
|
|
1012
|
+
`one framing was fitted across all ${prepared.length} frame set(s) (--framing shared). Its absolute numbers ` +
|
|
1013
|
+
'are not comparable with a per-shot run, and one badly-fitted set moves every other set in it.',
|
|
1014
|
+
);
|
|
1015
|
+
}
|
|
1016
|
+
} else {
|
|
1017
|
+
// Per shot: the DECLARED BOX is decided per set, and every set that does not
|
|
1018
|
+
// qualify for it is measured in the one shared framing.
|
|
1019
|
+
//
|
|
1020
|
+
// ## Why the split falls exactly there, and not "fit each set on its own"
|
|
1021
|
+
//
|
|
1022
|
+
// The obvious reading of issue #100 is that each set should get its own fitted
|
|
1023
|
+
// framing. It was written that way and measured, and it is worse — on the
|
|
1024
|
+
// spineboy rung, per-set fitting reads `hit` **92.36** against the shared
|
|
1025
|
+
// fit's 60.59 and `shoot@30fps` **101.94** against 42.98 (a two-frame set,
|
|
1026
|
+
// framed 24 % off). The reason is `fitFraming`'s own: it registers **extent**,
|
|
1027
|
+
// and extent is not alignment, so on a shot whose silhouette genuinely differs
|
|
1028
|
+
// the chain has a local minimum of the correction that is not a minimum of the
|
|
1029
|
+
// difference. More frames constrain that; one shot's worth does not.
|
|
1030
|
+
//
|
|
1031
|
+
// What actually produced the good column in that run is the other half — the
|
|
1032
|
+
// box `frames.json` records, which is not an estimate of anything and has no
|
|
1033
|
+
// floor. Over the union it was refused, because ONE badly-fitted shot put the
|
|
1034
|
+
// pooled correction over `COINCIDENT_PIXELS` and the whole root fell back to a
|
|
1035
|
+
// fit. Per set, the four sets that ARE in the frames' coordinates take it and
|
|
1036
|
+
// read exactly what pinning by hand reads: `idle` **18.77** against 41.59,
|
|
1037
|
+
// `walk` 32.00 against 45.33.
|
|
1038
|
+
//
|
|
1039
|
+
// So a set is framed by the frames' own box when its OWN pixels land there,
|
|
1040
|
+
// and by the shared fit otherwise. Every set is then at least as well framed
|
|
1041
|
+
// as a whole-root run framed it, and four of spineboy's sixteen much better.
|
|
1042
|
+
const shared = frameCandidate(
|
|
1043
|
+
prepared,
|
|
1044
|
+
posable.pages,
|
|
1045
|
+
referenceBoxes,
|
|
1046
|
+
background,
|
|
1047
|
+
level,
|
|
1048
|
+
pixelWidth,
|
|
1049
|
+
pixelHeight,
|
|
1050
|
+
referenceViewport,
|
|
1051
|
+
);
|
|
1052
|
+
const sharedShape: SetFraming = {
|
|
1053
|
+
viewport: shared.viewport,
|
|
1054
|
+
how: HOW_BY_SOURCE[shared.report.source],
|
|
1055
|
+
fit: { ...shared.report, units: extentsOf(shared.report.fit, shared.viewport.scale, referenceViewport) },
|
|
1056
|
+
notes: [],
|
|
1057
|
+
};
|
|
1058
|
+
sharedFraming = sharedShape.fit;
|
|
1059
|
+
let own = 0;
|
|
1060
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
1061
|
+
const p = prepared[i];
|
|
1062
|
+
const declared =
|
|
1063
|
+
p.pairs.length === 0
|
|
1064
|
+
? null
|
|
1065
|
+
: frameByDeclaredBox(
|
|
1066
|
+
[p],
|
|
1067
|
+
posable.pages,
|
|
1068
|
+
slices[i],
|
|
1069
|
+
background,
|
|
1070
|
+
level,
|
|
1071
|
+
pixelWidth,
|
|
1072
|
+
pixelHeight,
|
|
1073
|
+
referenceViewport,
|
|
1074
|
+
);
|
|
1075
|
+
const chosen: SetFraming = declared
|
|
1076
|
+
? {
|
|
1077
|
+
viewport: declared.viewport,
|
|
1078
|
+
how: HOW_BY_SOURCE[declared.report.source],
|
|
1079
|
+
fit: {
|
|
1080
|
+
...declared.report,
|
|
1081
|
+
units: extentsOf(declared.report.fit, declared.viewport.scale, referenceViewport),
|
|
1082
|
+
},
|
|
1083
|
+
notes: framingNotes(declared.report),
|
|
1084
|
+
}
|
|
1085
|
+
: { ...sharedShape, notes: framingNotes(shared.report) };
|
|
1086
|
+
if (declared) own++;
|
|
1087
|
+
// Per set, because the constant this pass removes is per set: issue #146
|
|
1088
|
+
// measured spineboy's `death` wanting (−1, +1) and its `jump` (0, −1) in the
|
|
1089
|
+
// same run and the same shared box. That is not the per-set *fitting* this
|
|
1090
|
+
// scope rejects — the offset is measured against the MAE itself, where one
|
|
1091
|
+
// shot's frames constrain the answer completely.
|
|
1092
|
+
framings.push(refined(located.root, [p], posable.pages, chosen, background, pixelWidth, pixelHeight));
|
|
1093
|
+
}
|
|
1094
|
+
notes.push(
|
|
1095
|
+
`the framing was decided per frame set: ${own} of ${prepared.length} set(s) were measured in ` +
|
|
1096
|
+
`${FRAMES_SIDECAR}'s own box because their own pixels land there, and the rest in the one shared framing on ` +
|
|
1097
|
+
'the "shared box" line. A set framed by the frames\' own box cannot be moved by any other set. --framing ' +
|
|
1098
|
+
'shared measures every set in the shared framing instead, which is a different measurement and not ' +
|
|
1099
|
+
'comparable with this one.',
|
|
1100
|
+
);
|
|
616
1101
|
}
|
|
617
1102
|
|
|
1103
|
+
// The candidate's own decomposition, derived once and used by every set — see
|
|
1104
|
+
// `src/chains.ts`. Reading the CANDIDATE's tree is what keeps this on the right
|
|
1105
|
+
// side of the honesty rule: the reference is still nothing but pixels.
|
|
1106
|
+
const chains = chainsOf(
|
|
1107
|
+
posable.data.bones.map((bone) => ({ name: bone.name, parent: bone.parent === null ? null : bone.parent.name })),
|
|
1108
|
+
posable.data.slots.map((slot) => ({ name: slot.name, bone: slot.boneData.name })),
|
|
1109
|
+
);
|
|
1110
|
+
const chainOfSlot = new Map<string, number>();
|
|
1111
|
+
chains.forEach((chain, index) => {
|
|
1112
|
+
for (const slot of chain.slots) chainOfSlot.set(slot, index);
|
|
1113
|
+
});
|
|
1114
|
+
|
|
618
1115
|
const animations: AnimationCheck[] = [];
|
|
619
|
-
for (
|
|
620
|
-
|
|
1116
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
1117
|
+
const f = framings[i];
|
|
1118
|
+
animations.push(checkOneSet(located.root, prepared[i], posable, f, background, chains, chainOfSlot));
|
|
621
1119
|
}
|
|
622
1120
|
|
|
623
1121
|
return {
|
|
@@ -627,28 +1125,64 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
|
627
1125
|
},
|
|
628
1126
|
framesDir: resolve(options.framesDir),
|
|
629
1127
|
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,
|
|
1128
|
+
framingScope: scope,
|
|
1129
|
+
framing: topHow,
|
|
1130
|
+
viewport: topViewport === null ? null : framingOfViewport(topViewport),
|
|
1131
|
+
framingFit: topFit,
|
|
1132
|
+
sharedFraming,
|
|
645
1133
|
referenceViewport,
|
|
646
1134
|
background,
|
|
1135
|
+
chains,
|
|
647
1136
|
animations,
|
|
648
1137
|
notes,
|
|
649
1138
|
};
|
|
650
1139
|
}
|
|
651
1140
|
|
|
1141
|
+
/** The framing one prepared set was measured in. */
|
|
1142
|
+
interface SetFraming {
|
|
1143
|
+
viewport: Viewport;
|
|
1144
|
+
how: FramingHow;
|
|
1145
|
+
fit: FramingReport | null;
|
|
1146
|
+
notes: string[];
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
/** `FramingSource` said in the report's own words. */
|
|
1150
|
+
const HOW_BY_SOURCE: Record<FramingSource, FramingHow> = {
|
|
1151
|
+
derived: 'candidate-pixels',
|
|
1152
|
+
declared: 'frames-viewport',
|
|
1153
|
+
pinned: 'viewport-flag',
|
|
1154
|
+
};
|
|
1155
|
+
|
|
1156
|
+
/**
|
|
1157
|
+
* `referenceBoxes` cut into one array per prepared set, in `prepared` order.
|
|
1158
|
+
*
|
|
1159
|
+
* The array is built by `prepared.flatMap((p) => p.pairs)`, so this is the inverse
|
|
1160
|
+
* of that flatten and nothing else. It exists because a per-shot framing measures
|
|
1161
|
+
* one set at a time and `frameCandidate` indexes its boxes the flat way.
|
|
1162
|
+
*/
|
|
1163
|
+
function sliceBySet(prepared: PreparedSet[], referenceBoxes: Array<ContentBox | null>): Array<Array<ContentBox | null>> {
|
|
1164
|
+
const out: Array<Array<ContentBox | null>> = [];
|
|
1165
|
+
let at = 0;
|
|
1166
|
+
for (const p of prepared) {
|
|
1167
|
+
out.push(referenceBoxes.slice(at, at + p.pairs.length));
|
|
1168
|
+
at += p.pairs.length;
|
|
1169
|
+
}
|
|
1170
|
+
return out;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
/** A rendering viewport as the report states it. */
|
|
1174
|
+
function framingOfViewport(v: Viewport): Framing {
|
|
1175
|
+
return {
|
|
1176
|
+
x: v.minX,
|
|
1177
|
+
y: v.minY,
|
|
1178
|
+
width: v.maxX - v.minX,
|
|
1179
|
+
height: v.maxY - v.minY,
|
|
1180
|
+
scale: v.scale,
|
|
1181
|
+
pixelWidth: v.width,
|
|
1182
|
+
pixelHeight: v.height,
|
|
1183
|
+
};
|
|
1184
|
+
}
|
|
1185
|
+
|
|
652
1186
|
function readPlateFrom(root: string, file: string): Plate {
|
|
653
1187
|
readFrameFile(root, file); // the guard; readPlate does the decoding
|
|
654
1188
|
return readPlate(file);
|
|
@@ -793,6 +1327,12 @@ interface FramingPass {
|
|
|
793
1327
|
distance: number;
|
|
794
1328
|
}
|
|
795
1329
|
|
|
1330
|
+
/** A framing for one run of sets: the box, and what it still leaves over. */
|
|
1331
|
+
interface FramedSets {
|
|
1332
|
+
viewport: Viewport;
|
|
1333
|
+
report: Omit<FramingReport, 'units'>;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
796
1336
|
/** A chain of passes and why it stopped. */
|
|
797
1337
|
interface FramingChain {
|
|
798
1338
|
passes: FramingPass[];
|
|
@@ -898,7 +1438,7 @@ function frameCandidate(
|
|
|
898
1438
|
pixelWidth: number,
|
|
899
1439
|
pixelHeight: number,
|
|
900
1440
|
referenceViewport: Framing | null,
|
|
901
|
-
):
|
|
1441
|
+
): FramedSets {
|
|
902
1442
|
const declared = frameByDeclaredBox(
|
|
903
1443
|
prepared,
|
|
904
1444
|
pages,
|
|
@@ -909,34 +1449,14 @@ function frameCandidate(
|
|
|
909
1449
|
pixelHeight,
|
|
910
1450
|
referenceViewport,
|
|
911
1451
|
);
|
|
1452
|
+
// The declared-box probe measures every frame in `frames.json`'s own box
|
|
1453
|
+
// whether or not it ends up being used, and that measurement is the only one
|
|
1454
|
+
// taken in a box every set shares. Handing it back is what lets a per-shot run
|
|
1455
|
+
// report `sharedFit` without a second render — see `CheckReport.sharedFit`.
|
|
912
1456
|
if (declared) return declared;
|
|
913
1457
|
|
|
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
1458
|
const chain = runFramingChain(
|
|
939
|
-
|
|
1459
|
+
seedFromGeometry(prepared, pages, referenceBoxes, pixelWidth, pixelHeight),
|
|
940
1460
|
prepared,
|
|
941
1461
|
pages,
|
|
942
1462
|
referenceBoxes,
|
|
@@ -960,10 +1480,94 @@ function frameCandidate(
|
|
|
960
1480
|
cycled: chain.cycled,
|
|
961
1481
|
agrees: chosen.distance <= COINCIDENT_PIXELS,
|
|
962
1482
|
applied: true,
|
|
1483
|
+
// Filled in by the refined pass, which runs once the box is decided.
|
|
1484
|
+
refinement: null,
|
|
963
1485
|
},
|
|
964
1486
|
};
|
|
965
1487
|
}
|
|
966
1488
|
|
|
1489
|
+
/**
|
|
1490
|
+
* The starting viewport, from the candidate's own posed geometry laid onto the
|
|
1491
|
+
* reference's own drawn extent.
|
|
1492
|
+
*
|
|
1493
|
+
* ## Why the reference's extent and not the frame
|
|
1494
|
+
*
|
|
1495
|
+
* The seed used to scale the candidate's trimmed quads to **fill the frame**, and
|
|
1496
|
+
* that is an assumption about the reference: that the shot its frames show was
|
|
1497
|
+
* framed around itself. Over a whole skeleton root it holds well enough, because
|
|
1498
|
+
* the sidecar's one box was chosen to hold every set. Over one SHORT set it can be
|
|
1499
|
+
* badly wrong — rung 3's `light` covers about half of the box its frames were
|
|
1500
|
+
* rendered in, so filling the frame starts it near 2x too large, and the chain
|
|
1501
|
+
* walks that back by only a few per cent a pass: `--frames <root>/light` on a
|
|
1502
|
+
* candidate in its own coordinates read **MAE 141** with a framing 65 % off, after
|
|
1503
|
+
* spending its whole pass budget (issue #100).
|
|
1504
|
+
*
|
|
1505
|
+
* The reference's own content box is already measured, on the same frames, with
|
|
1506
|
+
* the same predicate — it is what the fit is trying to reach. Starting there costs
|
|
1507
|
+
* nothing and starts the chain where it used to end up: the same shot now settles
|
|
1508
|
+
* on the first or second pass.
|
|
1509
|
+
*
|
|
1510
|
+
* The scale matches the two boxes by **area** rather than by either side, because
|
|
1511
|
+
* a candidate whose silhouette differs has two different side ratios and picking
|
|
1512
|
+
* one of them would seed the chain with that difference as a scale error.
|
|
1513
|
+
*
|
|
1514
|
+
* ⚠️ Falls back to filling the frame when there is no reference box to aim at —
|
|
1515
|
+
* every frame unreadable, or a set with nothing on disk.
|
|
1516
|
+
*/
|
|
1517
|
+
function seedFromGeometry(
|
|
1518
|
+
prepared: PreparedSet[],
|
|
1519
|
+
pages: Map<string, Plate>,
|
|
1520
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
1521
|
+
pixelWidth: number,
|
|
1522
|
+
pixelHeight: number,
|
|
1523
|
+
): Viewport {
|
|
1524
|
+
const quads = trimmedUnionBounds(
|
|
1525
|
+
prepared.map((p) => p.pairs.map((pair) => pair.frame)),
|
|
1526
|
+
pages,
|
|
1527
|
+
);
|
|
1528
|
+
if (!Number.isFinite(quads.minX)) {
|
|
1529
|
+
throw new CheckError('the candidate posed no drawable attachment in any frame that was compared');
|
|
1530
|
+
}
|
|
1531
|
+
const pad = Math.max(quads.maxX - quads.minX, quads.maxY - quads.minY) * PAD;
|
|
1532
|
+
const world = {
|
|
1533
|
+
minX: quads.minX - pad,
|
|
1534
|
+
minY: quads.minY - pad,
|
|
1535
|
+
maxX: quads.maxX + pad,
|
|
1536
|
+
maxY: quads.maxY + pad,
|
|
1537
|
+
};
|
|
1538
|
+
const worldWidth = world.maxX - world.minX;
|
|
1539
|
+
const worldHeight = world.maxY - world.minY;
|
|
1540
|
+
|
|
1541
|
+
let reference: ContentBox | null = null;
|
|
1542
|
+
for (const box of referenceBoxes) reference = unionBoxes(reference, box);
|
|
1543
|
+
if (reference !== null && boxWidth(reference) > 0 && boxHeight(reference) > 0) {
|
|
1544
|
+
// The reference's box is the trimmed content, so pad it the same way the
|
|
1545
|
+
// candidate's is before the two are matched — otherwise the pad is a scale
|
|
1546
|
+
// error the chain then has to undo.
|
|
1547
|
+
const refWidth = boxWidth(reference) * (1 + 2 * PAD);
|
|
1548
|
+
const refHeight = boxHeight(reference) * (1 + 2 * PAD);
|
|
1549
|
+
const scale = Math.sqrt((refWidth * refHeight) / (worldWidth * worldHeight));
|
|
1550
|
+
const left = reference.left - boxWidth(reference) * PAD;
|
|
1551
|
+
const top = reference.top - boxHeight(reference) * PAD;
|
|
1552
|
+
// `projector` is px = (wx - minX)·k and py = (maxY - wy)·k, so putting the
|
|
1553
|
+
// candidate's padded box on the reference's is one subtraction per axis.
|
|
1554
|
+
const minX = world.minX - left / scale;
|
|
1555
|
+
const maxY = world.maxY + top / scale;
|
|
1556
|
+
return viewportOfSize(minX, maxY - pixelHeight / scale, pixelWidth / scale, pixelHeight / scale, scale, pixelWidth, pixelHeight);
|
|
1557
|
+
}
|
|
1558
|
+
|
|
1559
|
+
const maxSide = Math.max(pixelWidth, pixelHeight);
|
|
1560
|
+
return viewportOfSize(
|
|
1561
|
+
world.minX,
|
|
1562
|
+
world.minY,
|
|
1563
|
+
worldWidth,
|
|
1564
|
+
worldHeight,
|
|
1565
|
+
maxSide / Math.max(worldWidth, worldHeight),
|
|
1566
|
+
pixelWidth,
|
|
1567
|
+
pixelHeight,
|
|
1568
|
+
);
|
|
1569
|
+
}
|
|
1570
|
+
|
|
967
1571
|
/**
|
|
968
1572
|
* The box `frames.json` records, used as the candidate's own — when, and only
|
|
969
1573
|
* when, the candidate's pixels are measured to land in it.
|
|
@@ -1056,10 +1660,91 @@ function frameByDeclaredBox(
|
|
|
1056
1660
|
cycled: false,
|
|
1057
1661
|
agrees: true,
|
|
1058
1662
|
applied: true,
|
|
1663
|
+
refinement: null,
|
|
1059
1664
|
},
|
|
1060
1665
|
};
|
|
1061
1666
|
}
|
|
1062
1667
|
|
|
1668
|
+
// ---------------------------------------------------------------------------
|
|
1669
|
+
// the MAE-refined final pass — see `FramingRefinement`
|
|
1670
|
+
// ---------------------------------------------------------------------------
|
|
1671
|
+
|
|
1672
|
+
/**
|
|
1673
|
+
* The reference-denominator MAE of these sets at every whole-pixel offset in a
|
|
1674
|
+
* ±`REFINE_RADIUS` window, in the box they were framed in.
|
|
1675
|
+
*
|
|
1676
|
+
* One render per frame, whatever the window's size: `OffsetScan` owns why that is
|
|
1677
|
+
* exact for whole pixels. The reference plates are read again here and again by
|
|
1678
|
+
* `checkOneSet`; decoding a PNG twice is cheaper than holding a set's frames in
|
|
1679
|
+
* memory, which on the ladder's largest set is a quarter of a gigabyte.
|
|
1680
|
+
*/
|
|
1681
|
+
function scanOffsets(
|
|
1682
|
+
root: string,
|
|
1683
|
+
prepared: PreparedSet[],
|
|
1684
|
+
pages: Map<string, Plate>,
|
|
1685
|
+
viewport: Viewport,
|
|
1686
|
+
background: RGBA,
|
|
1687
|
+
): OffsetGain | null {
|
|
1688
|
+
const scan = new OffsetScan(REFINE_RADIUS);
|
|
1689
|
+
for (const p of prepared) {
|
|
1690
|
+
for (const pair of p.pairs) {
|
|
1691
|
+
scan.add(
|
|
1692
|
+
renderFrame(pair.frame, pages, viewport, background),
|
|
1693
|
+
frameGeometry(pair.frame, pages, viewport).coverage,
|
|
1694
|
+
readPlateFrom(root, pair.file),
|
|
1695
|
+
background,
|
|
1696
|
+
);
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
return scan.best();
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
/**
|
|
1703
|
+
* What to do with the offset a scan found, given how the box was chosen.
|
|
1704
|
+
*
|
|
1705
|
+
* The whole judgement of `FramingRefinement` in one function, so that "a fitted
|
|
1706
|
+
* box is corrected and an exact one is only reported" is a single readable rule
|
|
1707
|
+
* rather than a condition spread over three call sites.
|
|
1708
|
+
*/
|
|
1709
|
+
function refinementOf(gain: OffsetGain | null, source: FramingSource): FramingRefinement | null {
|
|
1710
|
+
if (gain === null) return null;
|
|
1711
|
+
const shape = {
|
|
1712
|
+
dx: gain.dx,
|
|
1713
|
+
dy: gain.dy,
|
|
1714
|
+
before: gain.identity,
|
|
1715
|
+
after: gain.best,
|
|
1716
|
+
radius: gain.radius,
|
|
1717
|
+
frames: gain.frames,
|
|
1718
|
+
};
|
|
1719
|
+
if (gain.dx === 0 && gain.dy === 0) return { ...shape, applied: false, declined: 'identity' };
|
|
1720
|
+
if (!offsetIsWorthApplying(gain)) return { ...shape, applied: false, declined: 'below-threshold' };
|
|
1721
|
+
if (source === 'pinned') return { ...shape, applied: false, declined: 'pinned' };
|
|
1722
|
+
if (source === 'declared') return { ...shape, applied: false, declined: 'box-is-exact' };
|
|
1723
|
+
return { ...shape, applied: true, declined: null };
|
|
1724
|
+
}
|
|
1725
|
+
|
|
1726
|
+
/** One set's framing with the refined pass run over it, and applied if it may be. */
|
|
1727
|
+
function refined(
|
|
1728
|
+
root: string,
|
|
1729
|
+
prepared: PreparedSet[],
|
|
1730
|
+
pages: Map<string, Plate>,
|
|
1731
|
+
framing: SetFraming,
|
|
1732
|
+
background: RGBA,
|
|
1733
|
+
pixelWidth: number,
|
|
1734
|
+
pixelHeight: number,
|
|
1735
|
+
): SetFraming {
|
|
1736
|
+
if (framing.fit === null) return framing;
|
|
1737
|
+
const refinement = refinementOf(
|
|
1738
|
+
scanOffsets(root, prepared, pages, framing.viewport, background),
|
|
1739
|
+
framing.fit.source,
|
|
1740
|
+
);
|
|
1741
|
+
const viewport =
|
|
1742
|
+
refinement !== null && refinement.applied
|
|
1743
|
+
? shiftViewport(framing.viewport, refinement.dx, refinement.dy, pixelWidth, pixelHeight)
|
|
1744
|
+
: framing.viewport;
|
|
1745
|
+
return { ...framing, viewport, fit: { ...framing.fit, refinement } };
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1063
1748
|
/**
|
|
1064
1749
|
* What the framing pass concluded, in the words that tell the three cases apart.
|
|
1065
1750
|
*
|
|
@@ -1114,6 +1799,16 @@ interface PreparedSet {
|
|
|
1114
1799
|
candidateFrames: number;
|
|
1115
1800
|
referenceFrames: number;
|
|
1116
1801
|
pairs: FramePair[];
|
|
1802
|
+
/**
|
|
1803
|
+
* Every frame the candidate sampled, in index order — not only the ones a file
|
|
1804
|
+
* on disk pairs with.
|
|
1805
|
+
*
|
|
1806
|
+
* The contact sheet holds a tile for every SAMPLED frame, so a set that commits
|
|
1807
|
+
* two stills out of 311 needs all 311 poses to be compared against it
|
|
1808
|
+
* (`SheetCheck`). Kept as poses rather than plates: a `Frame` is geometry, and
|
|
1809
|
+
* holding 311 rendered plates of a busy shot is a quarter of a gigabyte.
|
|
1810
|
+
*/
|
|
1811
|
+
frames: Frame[];
|
|
1117
1812
|
notes: string[];
|
|
1118
1813
|
/** Set when nothing could be compared at all, saying why. */
|
|
1119
1814
|
missing: string | null;
|
|
@@ -1137,6 +1832,7 @@ function prepareSet(
|
|
|
1137
1832
|
candidateFrames: 0,
|
|
1138
1833
|
referenceFrames: disk.length,
|
|
1139
1834
|
pairs: [],
|
|
1835
|
+
frames: [],
|
|
1140
1836
|
notes: [],
|
|
1141
1837
|
missing:
|
|
1142
1838
|
`the candidate has no animation called ${JSON.stringify(wanted)} — it has [${have.join(', ') || 'none'}]. ` +
|
|
@@ -1184,6 +1880,7 @@ function prepareSet(
|
|
|
1184
1880
|
candidateFrames: candidateFrames.length,
|
|
1185
1881
|
referenceFrames: disk.length,
|
|
1186
1882
|
pairs,
|
|
1883
|
+
frames: candidateFrames,
|
|
1187
1884
|
notes,
|
|
1188
1885
|
missing: null,
|
|
1189
1886
|
};
|
|
@@ -1193,10 +1890,14 @@ function checkOneSet(
|
|
|
1193
1890
|
root: string,
|
|
1194
1891
|
prepared: PreparedSet,
|
|
1195
1892
|
posable: ReturnType<typeof posableFromText>,
|
|
1196
|
-
|
|
1893
|
+
framing: SetFraming,
|
|
1197
1894
|
background: RGBA,
|
|
1895
|
+
chains: BoneChain[],
|
|
1896
|
+
/** Slot name → its index in `chains`. */
|
|
1897
|
+
chainOfSlot: Map<string, number>,
|
|
1198
1898
|
): AnimationCheck {
|
|
1199
1899
|
const { set } = prepared;
|
|
1900
|
+
const viewport = framing.viewport;
|
|
1200
1901
|
const blank: AnimationCheck = {
|
|
1201
1902
|
dir: set.dir,
|
|
1202
1903
|
animation: set.animation,
|
|
@@ -1206,6 +1907,8 @@ function checkOneSet(
|
|
|
1206
1907
|
candidateFrames: prepared.candidateFrames,
|
|
1207
1908
|
compared: 0,
|
|
1208
1909
|
meanMae: 0,
|
|
1910
|
+
meanMaeReference: 0,
|
|
1911
|
+
drawnRatio: 1,
|
|
1209
1912
|
meanMaeFrame: 0,
|
|
1210
1913
|
worstMae: 0,
|
|
1211
1914
|
worstMaeFrame: -1,
|
|
@@ -1216,13 +1919,22 @@ function checkOneSet(
|
|
|
1216
1919
|
changePairs: 0,
|
|
1217
1920
|
changeDisagreements: 0,
|
|
1218
1921
|
worstChangeFrame: -1,
|
|
1922
|
+
chains: [],
|
|
1923
|
+
chainDenominator: 0,
|
|
1924
|
+
unattributedError: 0,
|
|
1219
1925
|
frames: [],
|
|
1220
|
-
|
|
1926
|
+
viewport: framingOfViewport(viewport),
|
|
1927
|
+
framing: framing.how,
|
|
1928
|
+
framingFit: framing.fit,
|
|
1929
|
+
sheet: null,
|
|
1930
|
+
notes: prepared.missing ? [prepared.missing] : [...framing.notes, ...prepared.notes],
|
|
1221
1931
|
};
|
|
1222
1932
|
if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
|
|
1223
1933
|
|
|
1224
1934
|
const frames: FrameCheck[] = [];
|
|
1225
1935
|
let maeSum = 0;
|
|
1936
|
+
let maeReferenceSum = 0;
|
|
1937
|
+
let drawnRatioSum = 0;
|
|
1226
1938
|
let maeFrameSum = 0;
|
|
1227
1939
|
let worstMae = 0;
|
|
1228
1940
|
let worstMaeFrame = -1;
|
|
@@ -1238,15 +1950,34 @@ function checkOneSet(
|
|
|
1238
1950
|
// ITSELF a frame earlier. Both are already rendered or read for this frame, so
|
|
1239
1951
|
// holding one frame of each costs one extra plate and no extra work.
|
|
1240
1952
|
let previous: { index: number; candidate: Plate; reference: Plate } | null = null;
|
|
1953
|
+
const tally: ChainTally = {
|
|
1954
|
+
error: new Array<number>(chains.length).fill(0),
|
|
1955
|
+
pixels: new Array<number>(chains.length).fill(0),
|
|
1956
|
+
unattributed: 0,
|
|
1957
|
+
total: 0,
|
|
1958
|
+
};
|
|
1241
1959
|
|
|
1242
1960
|
for (const { index, file, frame } of prepared.pairs) {
|
|
1243
1961
|
const reference = readPlateFrom(root, file);
|
|
1244
1962
|
const rendered = renderFrame(frame, posable.pages, viewport, background);
|
|
1245
|
-
const check = checkOneFrame(
|
|
1963
|
+
const check = checkOneFrame(
|
|
1964
|
+
index,
|
|
1965
|
+
file,
|
|
1966
|
+
frame,
|
|
1967
|
+
posable.pages,
|
|
1968
|
+
viewport,
|
|
1969
|
+
background,
|
|
1970
|
+
reference,
|
|
1971
|
+
rendered,
|
|
1972
|
+
chainOfSlot,
|
|
1973
|
+
tally,
|
|
1974
|
+
);
|
|
1246
1975
|
check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
|
|
1247
1976
|
previous = { index, candidate: rendered, reference };
|
|
1248
1977
|
frames.push(check);
|
|
1249
1978
|
maeSum += check.mae;
|
|
1979
|
+
maeReferenceSum += check.maeReference;
|
|
1980
|
+
drawnRatioSum += check.referencePixels === 0 ? 1 : check.candidatePixels / check.referencePixels;
|
|
1250
1981
|
maeFrameSum += check.maeFrame;
|
|
1251
1982
|
if (check.attributed === 0) framesWithoutDrift++;
|
|
1252
1983
|
if (check.change) {
|
|
@@ -1271,10 +2002,21 @@ function checkOneSet(
|
|
|
1271
2002
|
}
|
|
1272
2003
|
}
|
|
1273
2004
|
|
|
2005
|
+
// The frames the set does not commit as files, against the sheet that holds
|
|
2006
|
+
// them — see `SheetCheck`. After the frame loop, because it is measured in the
|
|
2007
|
+
// box that loop was measured in.
|
|
2008
|
+
const sheet = checkAgainstSheet(root, prepared, posable, viewport, background);
|
|
2009
|
+
|
|
1274
2010
|
return {
|
|
1275
2011
|
...blank,
|
|
1276
2012
|
compared: frames.length,
|
|
2013
|
+
sheet: sheet.sheet,
|
|
2014
|
+
chains: chainChecks(chains, frames, tally),
|
|
2015
|
+
chainDenominator: tally.total,
|
|
2016
|
+
unattributedError: tally.unattributed,
|
|
1277
2017
|
meanMae: maeSum / frames.length,
|
|
2018
|
+
meanMaeReference: maeReferenceSum / frames.length,
|
|
2019
|
+
drawnRatio: drawnRatioSum / frames.length,
|
|
1278
2020
|
meanMaeFrame: maeFrameSum / frames.length,
|
|
1279
2021
|
worstMae,
|
|
1280
2022
|
worstMaeFrame,
|
|
@@ -1286,7 +2028,7 @@ function checkOneSet(
|
|
|
1286
2028
|
changeDisagreements,
|
|
1287
2029
|
worstChangeFrame,
|
|
1288
2030
|
frames,
|
|
1289
|
-
notes: prepared.notes,
|
|
2031
|
+
notes: [...framing.notes, ...prepared.notes, ...sheet.notes],
|
|
1290
2032
|
};
|
|
1291
2033
|
}
|
|
1292
2034
|
|
|
@@ -1393,6 +2135,176 @@ function plateDelta(before: Plate, after: Plate): { pixels: number; mae: number
|
|
|
1393
2135
|
return { pixels, mae: sum / 3 / count };
|
|
1394
2136
|
}
|
|
1395
2137
|
|
|
2138
|
+
/**
|
|
2139
|
+
* Roll a set's frames up into one row per chain — the dashboard's rows.
|
|
2140
|
+
*
|
|
2141
|
+
* A chain that owns no slot is left out: it has nothing to attribute, and a row of
|
|
2142
|
+
* dashes in every set is noise in a table read sixteen times. The roster at the
|
|
2143
|
+
* foot of the report still lists it, so the account of where every bone went stays
|
|
2144
|
+
* complete.
|
|
2145
|
+
*/
|
|
2146
|
+
function chainChecks(chains: BoneChain[], frames: FrameCheck[], tally: ChainTally): ChainCheck[] {
|
|
2147
|
+
const out: ChainCheck[] = [];
|
|
2148
|
+
chains.forEach((chain, index) => {
|
|
2149
|
+
if (chain.slots.length === 0) return;
|
|
2150
|
+
const own = new Set(chain.slots);
|
|
2151
|
+
const drew = new Set<string>();
|
|
2152
|
+
let worstDrift = 0;
|
|
2153
|
+
let worstDriftSlot: string | null = null;
|
|
2154
|
+
let worstDriftFrame = -1;
|
|
2155
|
+
let driftSum = 0;
|
|
2156
|
+
let driftSamples = 0;
|
|
2157
|
+
let driftFrames = 0;
|
|
2158
|
+
for (const frame of frames) {
|
|
2159
|
+
let sampled = false;
|
|
2160
|
+
for (const track of frame.slots) {
|
|
2161
|
+
if (!own.has(track.slot)) continue;
|
|
2162
|
+
if (track.candidate !== null) drew.add(track.slot);
|
|
2163
|
+
if (!isAttributable(track)) continue;
|
|
2164
|
+
const drift = track.drift as number;
|
|
2165
|
+
driftSum += drift;
|
|
2166
|
+
driftSamples++;
|
|
2167
|
+
sampled = true;
|
|
2168
|
+
if (drift > worstDrift) {
|
|
2169
|
+
worstDrift = drift;
|
|
2170
|
+
worstDriftSlot = track.slot;
|
|
2171
|
+
worstDriftFrame = frame.index;
|
|
2172
|
+
}
|
|
2173
|
+
}
|
|
2174
|
+
if (sampled) driftFrames++;
|
|
2175
|
+
}
|
|
2176
|
+
out.push({
|
|
2177
|
+
chain: chain.name,
|
|
2178
|
+
slots: chain.slots.length,
|
|
2179
|
+
drewSlots: drew.size,
|
|
2180
|
+
worstDrift,
|
|
2181
|
+
worstDriftSlot,
|
|
2182
|
+
worstDriftFrame,
|
|
2183
|
+
meanDrift: driftSamples === 0 ? 0 : driftSum / driftSamples,
|
|
2184
|
+
driftSamples,
|
|
2185
|
+
driftFrames,
|
|
2186
|
+
error: tally.error[index],
|
|
2187
|
+
referencePixels: tally.pixels[index],
|
|
2188
|
+
mae: tally.pixels[index] === 0 ? 0 : tally.error[index] / tally.pixels[index],
|
|
2189
|
+
maeShare: tally.total === 0 ? 0 : tally.error[index] / tally.total,
|
|
2190
|
+
});
|
|
2191
|
+
});
|
|
2192
|
+
return out;
|
|
2193
|
+
}
|
|
2194
|
+
|
|
2195
|
+
/**
|
|
2196
|
+
* A set's error, being split between the candidate's chains as its frames are read.
|
|
2197
|
+
*
|
|
2198
|
+
* Carried across frames rather than parked on each `FrameCheck` because a share is
|
|
2199
|
+
* a fact about the SET — and because a per-frame array of it would land in every
|
|
2200
|
+
* `--json` report and every `bench.json` for a number nobody reads per frame.
|
|
2201
|
+
*/
|
|
2202
|
+
interface ChainTally {
|
|
2203
|
+
/** Absolute difference over reference-drawn pixels attributed to each chain. */
|
|
2204
|
+
error: number[];
|
|
2205
|
+
/** How many such pixels each chain took. */
|
|
2206
|
+
pixels: number[];
|
|
2207
|
+
/** The same, over reference pixels no chain could take — the candidate drew nothing. */
|
|
2208
|
+
unattributed: number;
|
|
2209
|
+
/** Every reference-drawn pixel's difference, chain or not: the share's denominator. */
|
|
2210
|
+
total: number;
|
|
2211
|
+
}
|
|
2212
|
+
|
|
2213
|
+
/** How much a diagonal step costs the chamfer pass below. */
|
|
2214
|
+
const DIAGONAL_STEP = Math.SQRT2;
|
|
2215
|
+
|
|
2216
|
+
/**
|
|
2217
|
+
* Give every pixel of the frame the chain whose ink is nearest to it.
|
|
2218
|
+
*
|
|
2219
|
+
* Two chamfer passes over the owner mask — forward then backward, propagating
|
|
2220
|
+
* (distance, label) together. It is an approximate Euclidean transform and that is
|
|
2221
|
+
* enough: what it decides is which of a handful of well-separated regions a pixel
|
|
2222
|
+
* belongs to, not a distance anybody reads.
|
|
2223
|
+
*
|
|
2224
|
+
* ⚠️ Nearest **ink the candidate drew**, so a chain that draws nothing seeds
|
|
2225
|
+
* nothing and is handed no pixels at all — its share reads 0 % while its slots are
|
|
2226
|
+
* missing entirely. That is why the table prints `drewSlots` beside the share: 0 %
|
|
2227
|
+
* on `0/3 slots` is the loudest row here, not the quietest one.
|
|
2228
|
+
*
|
|
2229
|
+
* The distance comes back with the label because the caller bounds it — see
|
|
2230
|
+
* `chainRadii`.
|
|
2231
|
+
*/
|
|
2232
|
+
function nearestOwner(owner: Int32Array, width: number, height: number): { label: Int32Array; dist: Float32Array } {
|
|
2233
|
+
const label = Int32Array.from(owner);
|
|
2234
|
+
const dist = new Float32Array(width * height);
|
|
2235
|
+
for (let i = 0; i < label.length; i++) dist[i] = label[i] >= 0 ? 0 : Infinity;
|
|
2236
|
+
const relax = (at: number, from: number, step: number): void => {
|
|
2237
|
+
const reach = dist[from] + step;
|
|
2238
|
+
if (reach >= dist[at]) return;
|
|
2239
|
+
dist[at] = reach;
|
|
2240
|
+
label[at] = label[from];
|
|
2241
|
+
};
|
|
2242
|
+
for (let y = 0; y < height; y++) {
|
|
2243
|
+
for (let x = 0; x < width; x++) {
|
|
2244
|
+
const at = y * width + x;
|
|
2245
|
+
if (x > 0) relax(at, at - 1, 1);
|
|
2246
|
+
if (y > 0) {
|
|
2247
|
+
relax(at, at - width, 1);
|
|
2248
|
+
if (x > 0) relax(at, at - width - 1, DIAGONAL_STEP);
|
|
2249
|
+
if (x + 1 < width) relax(at, at - width + 1, DIAGONAL_STEP);
|
|
2250
|
+
}
|
|
2251
|
+
}
|
|
2252
|
+
}
|
|
2253
|
+
for (let y = height - 1; y >= 0; y--) {
|
|
2254
|
+
for (let x = width - 1; x >= 0; x--) {
|
|
2255
|
+
const at = y * width + x;
|
|
2256
|
+
if (x + 1 < width) relax(at, at + 1, 1);
|
|
2257
|
+
if (y + 1 < height) {
|
|
2258
|
+
relax(at, at + width, 1);
|
|
2259
|
+
if (x + 1 < width) relax(at, at + width + 1, DIAGONAL_STEP);
|
|
2260
|
+
if (x > 0) relax(at, at + width - 1, DIAGONAL_STEP);
|
|
2261
|
+
}
|
|
2262
|
+
}
|
|
2263
|
+
}
|
|
2264
|
+
return { label, dist };
|
|
2265
|
+
}
|
|
2266
|
+
|
|
2267
|
+
/**
|
|
2268
|
+
* How far each chain's attribution may reach, in frame pixels.
|
|
2269
|
+
*
|
|
2270
|
+
* The same judgement `src/slots.ts` makes about a slot — *past about its own long
|
|
2271
|
+
* side a part no longer overlaps where it was, and something out there is another
|
|
2272
|
+
* object rather than this one moved* — applied to the chain's own drawn box. Past
|
|
2273
|
+
* it, reference ink is left **unattributed** instead of being handed to whichever
|
|
2274
|
+
* chain happens to be nearest.
|
|
2275
|
+
*
|
|
2276
|
+
* ⚠️ This is the bound that keeps the dashboard honest about its own limits, and
|
|
2277
|
+
* it is a bound rather than a fix. Nothing candidate-side can know which part of
|
|
2278
|
+
* the REFERENCE a pixel belonged to; nearest-ink is a good guess while the figure
|
|
2279
|
+
* is roughly in place and a bad one once a part has left. So a part displaced past
|
|
2280
|
+
* its own size stops being blamed on its neighbour and starts showing up in the
|
|
2281
|
+
* `(unattributed)` row, next to the `reference component(s) no slot reaches` count
|
|
2282
|
+
* that says the same thing a different way.
|
|
2283
|
+
*/
|
|
2284
|
+
function chainRadii(
|
|
2285
|
+
footprints: Map<string, Footprint>,
|
|
2286
|
+
chainOfSlot: Map<string, number>,
|
|
2287
|
+
chains: number,
|
|
2288
|
+
): Float64Array {
|
|
2289
|
+
const minX = new Float64Array(chains).fill(Infinity);
|
|
2290
|
+
const minY = new Float64Array(chains).fill(Infinity);
|
|
2291
|
+
const maxX = new Float64Array(chains).fill(-Infinity);
|
|
2292
|
+
const maxY = new Float64Array(chains).fill(-Infinity);
|
|
2293
|
+
for (const [slot, foot] of footprints) {
|
|
2294
|
+
const chain = chainOfSlot.get(slot);
|
|
2295
|
+
if (chain === undefined || foot.pixels === 0) continue;
|
|
2296
|
+
if (foot.minX < minX[chain]) minX[chain] = foot.minX;
|
|
2297
|
+
if (foot.minY < minY[chain]) minY[chain] = foot.minY;
|
|
2298
|
+
if (foot.maxX > maxX[chain]) maxX[chain] = foot.maxX;
|
|
2299
|
+
if (foot.maxY > maxY[chain]) maxY[chain] = foot.maxY;
|
|
2300
|
+
}
|
|
2301
|
+
const out = new Float64Array(chains);
|
|
2302
|
+
for (let i = 0; i < chains; i++) {
|
|
2303
|
+
out[i] = maxX[i] < minX[i] ? -1 : searchRadius(maxX[i] - minX[i], maxY[i] - minY[i]);
|
|
2304
|
+
}
|
|
2305
|
+
return out;
|
|
2306
|
+
}
|
|
2307
|
+
|
|
1396
2308
|
function checkOneFrame(
|
|
1397
2309
|
index: number,
|
|
1398
2310
|
file: string,
|
|
@@ -1403,8 +2315,16 @@ function checkOneFrame(
|
|
|
1403
2315
|
reference: Plate,
|
|
1404
2316
|
/** The candidate's own frame, rendered by the caller — it needs it too. */
|
|
1405
2317
|
rendered: Plate,
|
|
2318
|
+
/** Slot name → chain index, for the per-chain split. */
|
|
2319
|
+
chainOfSlot: Map<string, number>,
|
|
2320
|
+
/** Accumulated across the set by the caller — see `ChainTally`. */
|
|
2321
|
+
tally: ChainTally,
|
|
1406
2322
|
): FrameCheck {
|
|
1407
|
-
const { coverage, footprints } = frameGeometry(frame, pages, viewport);
|
|
2323
|
+
const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
|
|
2324
|
+
// Only worth the transform when something was drawn to be nearest TO.
|
|
2325
|
+
const nearest =
|
|
2326
|
+
owner !== null && owner.some((at) => at >= 0) ? nearestOwner(owner, viewport.width, viewport.height) : null;
|
|
2327
|
+
const radii = chainRadii(footprints, chainOfSlot, tally.error.length);
|
|
1408
2328
|
|
|
1409
2329
|
let union = 0;
|
|
1410
2330
|
let candidatePixels = 0;
|
|
@@ -1421,14 +2341,29 @@ function checkOneFrame(
|
|
|
1421
2341
|
const b = reference.get(x, y);
|
|
1422
2342
|
const delta = (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
|
|
1423
2343
|
sumAll += delta;
|
|
2344
|
+
if (inReference) {
|
|
2345
|
+
// The share's denominator is the reference's own drawn pixels, and the
|
|
2346
|
+
// split is over exactly those — issue #119's lesson, as a partition.
|
|
2347
|
+
tally.total += delta;
|
|
2348
|
+
const at = y * viewport.width + x;
|
|
2349
|
+
const found = nearest === null ? -1 : nearest.label[at];
|
|
2350
|
+
const chain = found >= 0 && nearest !== null && nearest.dist[at] <= radii[found] ? found : -1;
|
|
2351
|
+
if (chain >= 0) {
|
|
2352
|
+
tally.error[chain] += delta;
|
|
2353
|
+
tally.pixels[chain]++;
|
|
2354
|
+
} else {
|
|
2355
|
+
tally.unattributed += delta;
|
|
2356
|
+
}
|
|
2357
|
+
}
|
|
1424
2358
|
if (!inCandidate && !inReference) continue;
|
|
1425
2359
|
union++;
|
|
1426
2360
|
sum += delta;
|
|
1427
2361
|
}
|
|
1428
2362
|
}
|
|
1429
2363
|
|
|
1430
|
-
const
|
|
1431
|
-
const
|
|
2364
|
+
const field = componentField(reference, background);
|
|
2365
|
+
const components = field.components;
|
|
2366
|
+
const { tracks, matchedComponents } = matchSlots(footprints, field, {
|
|
1432
2367
|
frame,
|
|
1433
2368
|
pages,
|
|
1434
2369
|
viewport,
|
|
@@ -1454,6 +2389,10 @@ function checkOneFrame(
|
|
|
1454
2389
|
index,
|
|
1455
2390
|
file,
|
|
1456
2391
|
mae: union === 0 ? 0 : sum / union,
|
|
2392
|
+
// The same numerator over a denominator the candidate does not control — see
|
|
2393
|
+
// `FrameCheck.maeReference`. Both figures are already in hand here, which is
|
|
2394
|
+
// why the second one costs nothing to publish.
|
|
2395
|
+
maeReference: referencePixels === 0 ? 0 : sum / referencePixels,
|
|
1457
2396
|
maeFrame: sumAll / (viewport.width * viewport.height),
|
|
1458
2397
|
unionPixels: union,
|
|
1459
2398
|
candidatePixels,
|
|
@@ -1471,10 +2410,261 @@ function checkOneFrame(
|
|
|
1471
2410
|
};
|
|
1472
2411
|
}
|
|
1473
2412
|
|
|
2413
|
+
// ---------------------------------------------------------------------------
|
|
2414
|
+
// the contact sheet — see `SheetCheck`
|
|
2415
|
+
// ---------------------------------------------------------------------------
|
|
2416
|
+
|
|
2417
|
+
/** A sheet's grid, in the terms the tiles are cut out with. */
|
|
2418
|
+
interface SheetGeometry {
|
|
2419
|
+
columns: number;
|
|
2420
|
+
rows: number;
|
|
2421
|
+
tileWidth: number;
|
|
2422
|
+
tileHeight: number;
|
|
2423
|
+
/** Tile pixels per frame pixel. */
|
|
2424
|
+
tileScale: number;
|
|
2425
|
+
}
|
|
2426
|
+
|
|
2427
|
+
/**
|
|
2428
|
+
* A sheet's grid, **measured off the sheet** rather than taken on trust.
|
|
2429
|
+
*
|
|
2430
|
+
* `frames.json` records the frame count and the world box; it does not record the
|
|
2431
|
+
* tile size, because `--tile` is a per-run choice and the sheet's own dimensions
|
|
2432
|
+
* state the answer exactly. With `n` tiles in `c` columns the sheet is
|
|
2433
|
+
* `c·(w+1)+1` by `ceil(n/c)·(h+1)+1`, so a column count either divides both
|
|
2434
|
+
* dimensions exactly or is wrong — and the surviving candidate has to agree with
|
|
2435
|
+
* the frames' own aspect ratio as well, since both tile sides came from one scale.
|
|
2436
|
+
*
|
|
2437
|
+
* `SHEET_COLUMNS` is tried first because it is the contract
|
|
2438
|
+
* `bench/render_reference.ts` writes; the search behind it is what keeps a sheet
|
|
2439
|
+
* rendered by something else readable, and what makes a mismatch a **named
|
|
2440
|
+
* refusal** rather than a silent misread of somebody's grid.
|
|
2441
|
+
*/
|
|
2442
|
+
export function sheetGeometry(
|
|
2443
|
+
sheet: Plate,
|
|
2444
|
+
tiles: number,
|
|
2445
|
+
pixelWidth: number,
|
|
2446
|
+
pixelHeight: number,
|
|
2447
|
+
): SheetGeometry | null {
|
|
2448
|
+
if (tiles <= 0 || pixelWidth <= 0 || pixelHeight <= 0) return null;
|
|
2449
|
+
const candidates = [SHEET_COLUMNS, ...Array.from({ length: Math.min(tiles, 64) }, (_, i) => i + 1)];
|
|
2450
|
+
const slack = 1 / Math.max(pixelWidth, pixelHeight);
|
|
2451
|
+
let best: { geometry: SheetGeometry; error: number } | null = null;
|
|
2452
|
+
for (const columns of candidates) {
|
|
2453
|
+
if (columns > tiles) continue;
|
|
2454
|
+
const across = sheet.width - SHEET_GAP;
|
|
2455
|
+
const rows = Math.ceil(tiles / columns);
|
|
2456
|
+
const down = sheet.height - SHEET_GAP;
|
|
2457
|
+
if (across % columns !== 0 || down % rows !== 0) continue;
|
|
2458
|
+
const tileWidth = across / columns - SHEET_GAP;
|
|
2459
|
+
const tileHeight = down / rows - SHEET_GAP;
|
|
2460
|
+
if (tileWidth < 1 || tileHeight < 1) continue;
|
|
2461
|
+
// Both tile sides are one scale, rounded — so the two ratios agree to within
|
|
2462
|
+
// the rounding, and a grid that does not is a different grid.
|
|
2463
|
+
const error = Math.abs(tileWidth / pixelWidth - tileHeight / pixelHeight);
|
|
2464
|
+
if (error > slack) continue;
|
|
2465
|
+
const geometry = { columns, rows, tileWidth, tileHeight, tileScale: tileWidth / pixelWidth };
|
|
2466
|
+
if (best === null || error < best.error) best = { geometry, error };
|
|
2467
|
+
if (columns === SHEET_COLUMNS) break;
|
|
2468
|
+
}
|
|
2469
|
+
return best === null ? null : best.geometry;
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* The label burned into a tile's corner, as a box to leave out of the comparison.
|
|
2474
|
+
*
|
|
2475
|
+
* `bench/render_reference.ts` paints the frame's index at `(2, 2)` in the tile at
|
|
2476
|
+
* scale 1, and the candidate does not draw it. Left in, it would add the same
|
|
2477
|
+
* constant to every tile and a bigger one to four-digit frames than to one-digit
|
|
2478
|
+
* ones — a difference that is a fact about the labeller. The box is derived from
|
|
2479
|
+
* the font's own metrics, with a pixel of margin, rather than measured once and
|
|
2480
|
+
* written down.
|
|
2481
|
+
*/
|
|
2482
|
+
function labelBox(index: number): { width: number; height: number } {
|
|
2483
|
+
return { width: 2 + textWidth(String(index), 1) + 1, height: 2 + GLYPH_H + 1 };
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* One frame set against its contact sheet, tile by tile.
|
|
2488
|
+
*
|
|
2489
|
+
* The candidate is rendered into the SAME world box the set was framed in, at the
|
|
2490
|
+
* sheet's own scale — so a set framed by `frames.json`'s own box is compared here
|
|
2491
|
+
* with no correction at all, and a set framed by a fit carries that fit (which,
|
|
2492
|
+
* for a stills-plus-sheet set, was measured on the stills). The report says which.
|
|
2493
|
+
*/
|
|
2494
|
+
function checkAgainstSheet(
|
|
2495
|
+
root: string,
|
|
2496
|
+
prepared: PreparedSet,
|
|
2497
|
+
posable: ReturnType<typeof posableFromText>,
|
|
2498
|
+
viewport: Viewport,
|
|
2499
|
+
background: RGBA,
|
|
2500
|
+
): { sheet: SheetCheck | null; notes: string[] } {
|
|
2501
|
+
const { set } = prepared;
|
|
2502
|
+
const file = join(root, set.dir, SHEET_FILE);
|
|
2503
|
+
if (!existsSync(file)) return { sheet: null, notes: [] };
|
|
2504
|
+
const onDisk = framesOnDisk(root, set.dir).length;
|
|
2505
|
+
// Every sampled frame already has a file of its own: the sheet is the same
|
|
2506
|
+
// pictures again, smaller, and measuring them twice would just report the
|
|
2507
|
+
// resampling.
|
|
2508
|
+
if (onDisk >= set.sampled) return { sheet: null, notes: [] };
|
|
2509
|
+
if (prepared.frames.length === 0) return { sheet: null, notes: [] };
|
|
2510
|
+
|
|
2511
|
+
const plate = readPlateFrom(root, file);
|
|
2512
|
+
const geometry = sheetGeometry(plate, set.sampled, viewport.width, viewport.height);
|
|
2513
|
+
if (geometry === null) {
|
|
2514
|
+
return {
|
|
2515
|
+
sheet: null,
|
|
2516
|
+
notes: [
|
|
2517
|
+
`${file} is ${plate.width}x${plate.height} px, which is not a grid of ${set.sampled} tile(s) at the ` +
|
|
2518
|
+
`${viewport.width}x${viewport.height} aspect of these frames — so the whole shot was NOT compared, only ` +
|
|
2519
|
+
`the ${onDisk} still(s) on disk. Re-render the set with bench/render_reference.ts if the sheet is stale.`,
|
|
2520
|
+
],
|
|
2521
|
+
};
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
const { columns, tileWidth, tileHeight, tileScale } = geometry;
|
|
2525
|
+
const tileViewport = viewportOfSize(
|
|
2526
|
+
viewport.minX,
|
|
2527
|
+
viewport.minY,
|
|
2528
|
+
viewport.maxX - viewport.minX,
|
|
2529
|
+
viewport.maxY - viewport.minY,
|
|
2530
|
+
viewport.scale * tileScale,
|
|
2531
|
+
tileWidth,
|
|
2532
|
+
tileHeight,
|
|
2533
|
+
);
|
|
2534
|
+
const compared = Math.min(set.sampled, prepared.frames.length);
|
|
2535
|
+
let maeSum = 0;
|
|
2536
|
+
let maeReferenceSum = 0;
|
|
2537
|
+
let worstMae = 0;
|
|
2538
|
+
let worstTile = -1;
|
|
2539
|
+
const per: Array<{ index: number; mae: number }> = [];
|
|
2540
|
+
for (let index = 0; index < compared; index++) {
|
|
2541
|
+
const frame = prepared.frames[index];
|
|
2542
|
+
const rendered = renderFrame(frame, posable.pages, tileViewport, background);
|
|
2543
|
+
const ox = SHEET_GAP + (index % columns) * (tileWidth + SHEET_GAP);
|
|
2544
|
+
const oy = SHEET_GAP + Math.floor(index / columns) * (tileHeight + SHEET_GAP);
|
|
2545
|
+
const label = labelBox(index);
|
|
2546
|
+
let union = 0;
|
|
2547
|
+
let referencePixels = 0;
|
|
2548
|
+
let sum = 0;
|
|
2549
|
+
for (let y = 0; y < tileHeight; y++) {
|
|
2550
|
+
for (let x = 0; x < tileWidth; x++) {
|
|
2551
|
+
if (y < label.height && x < label.width) continue;
|
|
2552
|
+
const sx = ox + x;
|
|
2553
|
+
const sy = oy + y;
|
|
2554
|
+
if (sx >= plate.width || sy >= plate.height) continue;
|
|
2555
|
+
const inReference = isContent(plate, sx, sy, background);
|
|
2556
|
+
const inCandidate = isContent(rendered, x, y, background);
|
|
2557
|
+
if (inReference) referencePixels++;
|
|
2558
|
+
if (!inReference && !inCandidate) continue;
|
|
2559
|
+
const a = rendered.get(x, y);
|
|
2560
|
+
const b = plate.get(sx, sy);
|
|
2561
|
+
union++;
|
|
2562
|
+
sum += (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
|
|
2563
|
+
}
|
|
2564
|
+
}
|
|
2565
|
+
const mae = union === 0 ? 0 : sum / union;
|
|
2566
|
+
maeSum += mae;
|
|
2567
|
+
maeReferenceSum += referencePixels === 0 ? 0 : sum / referencePixels;
|
|
2568
|
+
per.push({ index, mae });
|
|
2569
|
+
if (mae > worstMae) {
|
|
2570
|
+
worstMae = mae;
|
|
2571
|
+
worstTile = index;
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
per.sort((a, b) => b.mae - a.mae);
|
|
2575
|
+
return {
|
|
2576
|
+
sheet: {
|
|
2577
|
+
file,
|
|
2578
|
+
columns,
|
|
2579
|
+
tileWidth,
|
|
2580
|
+
tileHeight,
|
|
2581
|
+
tileScale,
|
|
2582
|
+
tiles: set.sampled,
|
|
2583
|
+
compared,
|
|
2584
|
+
meanMae: compared === 0 ? 0 : maeSum / compared,
|
|
2585
|
+
meanMaeReference: compared === 0 ? 0 : maeReferenceSum / compared,
|
|
2586
|
+
worstMae,
|
|
2587
|
+
worstTile,
|
|
2588
|
+
worst: per.slice(0, WORST_FRAMES),
|
|
2589
|
+
},
|
|
2590
|
+
notes: [],
|
|
2591
|
+
};
|
|
2592
|
+
}
|
|
2593
|
+
|
|
2594
|
+
/** The sheet block, as the lines an author reads after the frame table. */
|
|
2595
|
+
function sheetLines(sheet: SheetCheck | null): string[] {
|
|
2596
|
+
if (sheet === null) return [];
|
|
2597
|
+
const worst = sheet.worst
|
|
2598
|
+
.map((tile) => `f${String(tile.index).padStart(4, '0')}=${tile.mae.toFixed(1)}`)
|
|
2599
|
+
.join(' ');
|
|
2600
|
+
return [
|
|
2601
|
+
` sheet ${sheet.compared} of ${sheet.tiles} tile(s) of ${basename(sheet.file)} at ` +
|
|
2602
|
+
`${sheet.tileWidth}x${sheet.tileHeight}px in ${sheet.columns} column(s) MAE mean ${f2(sheet.meanMae)} ` +
|
|
2603
|
+
`worst ${f2(sheet.worstMae)} at f${String(sheet.worstTile).padStart(4, '0')} ` +
|
|
2604
|
+
`(over the reference's own pixels, mean ${f2(sheet.meanMaeReference)})`,
|
|
2605
|
+
' ⤷ the frames this set does not commit as files. The candidate is sampled at the set\'s own ' +
|
|
2606
|
+
"rate and rendered into the same box the frames above were, at the sheet's scale. Read the " +
|
|
2607
|
+
'series, not the mean: flat is framing or art, a spike is timing at that moment.',
|
|
2608
|
+
` ⤷ worst ${sheet.worst.length}: ${worst}`,
|
|
2609
|
+
];
|
|
2610
|
+
}
|
|
2611
|
+
|
|
1474
2612
|
// ---------------------------------------------------------------------------
|
|
1475
2613
|
// the report
|
|
1476
2614
|
// ---------------------------------------------------------------------------
|
|
1477
2615
|
|
|
2616
|
+
/**
|
|
2617
|
+
* How much more than the reference a set may draw before `check` calls it
|
|
2618
|
+
* overdraw, as a ratio of drawn pixels.
|
|
2619
|
+
*
|
|
2620
|
+
* ## Why this direction needs its own warning
|
|
2621
|
+
*
|
|
2622
|
+
* `mae` divides by the pixels **either side drew** — the union — and the
|
|
2623
|
+
* candidate owns half of that denominator. A large, mostly transparent sprite
|
|
2624
|
+
* adds many cheap pixels to it and the *mean falls*, so anything optimising
|
|
2625
|
+
* against `mae` can buy a better score by drawing more, which is the opposite of
|
|
2626
|
+
* fidelity. Issue #119: spineboy-2's muzzle flare walked its own scale to 13x
|
|
2627
|
+
* doing exactly this, and cost every set in that run its framing. Reproduced
|
|
2628
|
+
* here, that candidate's `shoot` reads union MAE **39.65 against the honest
|
|
2629
|
+
* build's 47.20** — the metric calls the flare an improvement — while the same
|
|
2630
|
+
* difference over the reference's own pixels reads **73.06 against 52.54**.
|
|
2631
|
+
*
|
|
2632
|
+
* ⭐ Asymmetric on purpose. A candidate that draws LESS than the reference is
|
|
2633
|
+
* being punished by the MAE, not rewarded, and needs no warning to find out.
|
|
2634
|
+
*
|
|
2635
|
+
* ## Where 1.5 comes from
|
|
2636
|
+
*
|
|
2637
|
+
* Measured over the corpus rather than picked. Across the twelve committed
|
|
2638
|
+
* candidates in `bench/runs/` — 64 compared sets, 1 to 121 frames each — the
|
|
2639
|
+
* ratio spans **0.852 … 1.069** on 62 of them, and the two above that are both
|
|
2640
|
+
* the same shot on the same character: spineboy-1's `shoot@30fps` at 1.154 and
|
|
2641
|
+
* spineboy-2's at **1.274**, two-frame stills sets where the muzzle flare lands
|
|
2642
|
+
* a frame off. Rung 8's ball reads 1.041, rung 3's candidate 0.993.
|
|
2643
|
+
*
|
|
2644
|
+
* The 13x flare reads **1.850** on `shoot` and **3.199** on `shoot@30fps`, and
|
|
2645
|
+
* 0.94–1.01 on the fourteen sets that do not draw it — so the warning names the
|
|
2646
|
+
* shot the overdraw is in rather than colouring the whole run.
|
|
2647
|
+
*
|
|
2648
|
+
* 1.5 is the geometric middle of the gap between the widest honest reading and
|
|
2649
|
+
* the weakest defective one (1.274 · 1.850 ≈ 1.535²): half again as much ink as
|
|
2650
|
+
* the reference put down, which no honest candidate in the corpus approaches and
|
|
2651
|
+
* which the case this was built for clears on both its sets.
|
|
2652
|
+
*
|
|
2653
|
+
* ## What was measured and rejected: the content boxes
|
|
2654
|
+
*
|
|
2655
|
+
* Issue #119 suggests the two content boxes, and `check` has both. Measured, that
|
|
2656
|
+
* test is defeated by the framing it is measured through. `fitFraming` absorbs a
|
|
2657
|
+
* uniform scale on purpose, so a candidate that draws everything too big reads a
|
|
2658
|
+
* box growth of **−3.4 %** while its union MAE falls 137.6 → 36.0 — the fit
|
|
2659
|
+
* simply shrinks it back. It also fires where nothing is overdrawn: the
|
|
2660
|
+
* time-reversed fixture, whose ink is right and whose *timing* is wrong, reads
|
|
2661
|
+
* **+14.1 %** because sampling a reversed shot lands on different poses. Counting
|
|
2662
|
+
* ink is blind to both — that same reversed fixture draws **1.30–1.39x**, under
|
|
2663
|
+
* the bar, and a bloated one draws what it drew whatever the framing does with it
|
|
2664
|
+
* afterwards. C08 and C09 hold both ends of that.
|
|
2665
|
+
*/
|
|
2666
|
+
export const OVERDRAW_RATIO = 1.5;
|
|
2667
|
+
|
|
1478
2668
|
/** How many worst frames a set prints when the whole set is too long to list. */
|
|
1479
2669
|
export const WORST_FRAMES = 8;
|
|
1480
2670
|
/** Sets no longer than this print every frame. */
|
|
@@ -1487,17 +2677,15 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1487
2677
|
lines.push(` candidate ${report.candidate.skeleton}`);
|
|
1488
2678
|
lines.push(` atlas ${report.candidate.atlas}`);
|
|
1489
2679
|
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
2680
|
lines.push(
|
|
1498
|
-
`
|
|
1499
|
-
|
|
2681
|
+
` scope ${
|
|
2682
|
+
report.framingScope === 'per-shot'
|
|
2683
|
+
? "the framing decided per frame set (--framing shared measures every set in one shared framing)"
|
|
2684
|
+
: "one framing across every frame set (--framing per-shot lets a set take frames.json's own box instead)"
|
|
2685
|
+
}`,
|
|
1500
2686
|
);
|
|
2687
|
+
const v = report.viewport;
|
|
2688
|
+
if (v !== null) lines.push(...framedToLines(v, report.framing));
|
|
1501
2689
|
const r = report.referenceViewport;
|
|
1502
2690
|
if (r) {
|
|
1503
2691
|
lines.push(
|
|
@@ -1506,7 +2694,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1506
2694
|
);
|
|
1507
2695
|
lines.push(' ⤷ the two world boxes are different coordinate systems and do not compare; the pixel grid does.');
|
|
1508
2696
|
}
|
|
1509
|
-
for (const line of framingLines(report)) lines.push(line);
|
|
2697
|
+
for (const line of framingLines(report.framingFit)) lines.push(line);
|
|
2698
|
+
if (report.sharedFraming) {
|
|
2699
|
+
const shared = report.sharedFraming;
|
|
2700
|
+
const f = shared.fit;
|
|
2701
|
+
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
2702
|
+
lines.push(
|
|
2703
|
+
` shared box one box for all ${report.animations.length} set(s) leaves x${f.scale.toFixed(6)} offset ` +
|
|
2704
|
+
`${signed(f.dx)}, ${signed(f.dy)} px rms ${f.rms.toFixed(2)} px over ${f.frames * 4} edge(s) ` +
|
|
2705
|
+
`(${shared.source}; used for every set that could not take the frames' own box)`,
|
|
2706
|
+
);
|
|
2707
|
+
lines.push(
|
|
2708
|
+
" ⤷ how far one shared framing is from serving every set. A set below that took the frames' own " +
|
|
2709
|
+
'box instead is measured with no such correction at all; --framing shared measures every set here.',
|
|
2710
|
+
);
|
|
2711
|
+
}
|
|
1510
2712
|
for (const note of report.notes) lines.push(` ⚠️ ${note}`);
|
|
1511
2713
|
lines.push('');
|
|
1512
2714
|
|
|
@@ -1521,6 +2723,13 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1521
2723
|
lines.push(
|
|
1522
2724
|
` frames ${anim.referenceFrames} on disk, candidate samples ${anim.candidateFrames}, ${anim.compared} compared`,
|
|
1523
2725
|
);
|
|
2726
|
+
// Only when this set has a framing of its own: under a shared scope, or a pin,
|
|
2727
|
+
// the header already printed the one box every set was measured in, and
|
|
2728
|
+
// repeating it per set would read as though they differed.
|
|
2729
|
+
if (report.framingScope === 'per-shot' && report.viewport === null) {
|
|
2730
|
+
for (const line of framedToLines(anim.viewport, anim.framing, ' ')) lines.push(line);
|
|
2731
|
+
for (const line of framingLines(anim.framingFit, ' ')) lines.push(line);
|
|
2732
|
+
}
|
|
1524
2733
|
for (const note of anim.notes) lines.push(` ⚠️ ${note}`);
|
|
1525
2734
|
if (anim.compared === 0) {
|
|
1526
2735
|
lines.push('');
|
|
@@ -1530,6 +2739,21 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1530
2739
|
` MAE mean ${f2(anim.meanMae)} worst ${f2(anim.worstMae)} at f${String(anim.worstMaeFrame).padStart(4, '0')}` +
|
|
1531
2740
|
` (0..255 over the union alpha; over the whole frame, mean ${f2(anim.meanMaeFrame)})`,
|
|
1532
2741
|
);
|
|
2742
|
+
lines.push(
|
|
2743
|
+
` ⤷ over the REFERENCE's own drawn pixels, mean ${f2(anim.meanMaeReference)} — the union figure ` +
|
|
2744
|
+
'compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.',
|
|
2745
|
+
);
|
|
2746
|
+
if (anim.drawnRatio > OVERDRAW_RATIO) {
|
|
2747
|
+
const mine = Math.round(anim.frames.reduce((sum, f) => sum + f.candidatePixels, 0) / anim.frames.length);
|
|
2748
|
+
const theirs = Math.round(anim.frames.reduce((sum, f) => sum + f.referencePixels, 0) / anim.frames.length);
|
|
2749
|
+
lines.push(
|
|
2750
|
+
` ⚠️ overdraw: this shot draws ${mine.toLocaleString('en-US')} px a frame where the reference ` +
|
|
2751
|
+
`draws ${theirs.toLocaleString('en-US')} — ${anim.drawnRatio.toFixed(2)}x as much ink, past the ` +
|
|
2752
|
+
`${OVERDRAW_RATIO}x no committed candidate reaches. Most of that excess lands in the MAE's own ` +
|
|
2753
|
+
'denominator and makes the figure above cheaper without moving a pixel closer, so read the one under it. ' +
|
|
2754
|
+
'Something here is drawn that should not be, or is far too big.',
|
|
2755
|
+
);
|
|
2756
|
+
}
|
|
1533
2757
|
const blind =
|
|
1534
2758
|
anim.framesWithoutDrift === 0
|
|
1535
2759
|
? ''
|
|
@@ -1541,6 +2765,8 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1541
2765
|
`f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
|
|
1542
2766
|
);
|
|
1543
2767
|
lines.push(changeSummary(anim));
|
|
2768
|
+
for (const line of sheetLines(anim.sheet)) lines.push(line);
|
|
2769
|
+
for (const line of chainTable(anim)) lines.push(line);
|
|
1544
2770
|
lines.push('');
|
|
1545
2771
|
|
|
1546
2772
|
const listed = framesToList(anim, opts?.allFrames === true);
|
|
@@ -1574,14 +2800,22 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1574
2800
|
lines.push('');
|
|
1575
2801
|
}
|
|
1576
2802
|
|
|
2803
|
+
for (const line of chainFoot(report)) lines.push(line);
|
|
2804
|
+
|
|
1577
2805
|
lines.push(' MAE is the mean absolute RGB difference over the pixels either side covers, so it is');
|
|
1578
2806
|
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`.
|
|
2807
|
+
lines.push(' there is one in `diff`. The figure under it divides the same difference by the pixels the');
|
|
2808
|
+
lines.push(' REFERENCE drew — a denominator you cannot grow, which is what makes it the one to author');
|
|
2809
|
+
lines.push(' against; it is not bounded by 255. Read the framing line first: it is upstream of every number');
|
|
1580
2810
|
lines.push(' below, and a residual much wider than a pixel moves all of them at once.');
|
|
1581
2811
|
lines.push(' The slots column is how many of the drawn slots could be attributed at all. A drift');
|
|
1582
2812
|
lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
|
|
1583
2813
|
lines.push(' merged it into a neighbour; the number beside it is how much better that match was');
|
|
1584
2814
|
lines.push(' than its best rival, and a slot that matched nothing at all is left out of the count.');
|
|
2815
|
+
lines.push(' A `sheet` line is the frames a set does not commit as files: the candidate sampled at the');
|
|
2816
|
+
lines.push(" set's own rate against the tiles of its contact.png, in the same box the frame table used.");
|
|
2817
|
+
lines.push(' Read it as a series — flat is framing or art, a spike is timing at that moment — and note');
|
|
2818
|
+
lines.push(' that it is MAE only: the change columns below are pixel counts at frame scale.');
|
|
1585
2819
|
lines.push(' `Δpx` and `ref Δ` are how many pixels each side moved since ITS OWN previous frame —');
|
|
1586
2820
|
lines.push(' not against each other. They are the only columns that can see a held pose that is');
|
|
1587
2821
|
lines.push(' not held, or a one-frame event that never fired: both are small in every frame and');
|
|
@@ -1589,6 +2823,164 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
|
|
|
1589
2823
|
return lines;
|
|
1590
2824
|
}
|
|
1591
2825
|
|
|
2826
|
+
/** One drift, as the table says it: distance, slot, frame. */
|
|
2827
|
+
function driftPhrase(drift: number, slot: string | null, frame: number): string {
|
|
2828
|
+
if (slot === null) return 'no slot attributable';
|
|
2829
|
+
return `${drift.toFixed(1)} px ${JSON.stringify(slot)} f${String(frame).padStart(4, '0')}`;
|
|
2830
|
+
}
|
|
2831
|
+
|
|
2832
|
+
/** One set, broken down by chain — see `ChainCheck`. */
|
|
2833
|
+
function chainTable(anim: AnimationCheck): string[] {
|
|
2834
|
+
if (anim.chains.length === 0) return [];
|
|
2835
|
+
const out: string[] = [];
|
|
2836
|
+
out.push(
|
|
2837
|
+
` chains ${anim.chains.length} from the candidate's own bone tree — the roster is at the foot of the report`,
|
|
2838
|
+
);
|
|
2839
|
+
out.push(
|
|
2840
|
+
` ${'chain'.padEnd(20)} ${'slots'.padStart(6)} ${'worst slot drift'.padEnd(33)} ` +
|
|
2841
|
+
`${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
|
|
2842
|
+
);
|
|
2843
|
+
// Derivation order rather than worst-first, so the same row is in the same place
|
|
2844
|
+
// in every set's table and a run can be read down a column.
|
|
2845
|
+
for (const chain of anim.chains) {
|
|
2846
|
+
const mean = chain.driftSamples === 0 ? '—' : `${chain.meanDrift.toFixed(1)} px`;
|
|
2847
|
+
out.push(
|
|
2848
|
+
` ${chain.chain.padEnd(20)} ${`${chain.drewSlots}/${chain.slots}`.padStart(6)} ` +
|
|
2849
|
+
`${driftPhrase(chain.worstDrift, chain.worstDriftSlot, chain.worstDriftFrame).padEnd(33)} ` +
|
|
2850
|
+
`${mean.padStart(8)} ${f2(chain.mae).padStart(9)} ${`${(chain.maeShare * 100).toFixed(1)}%`.padStart(6)}`,
|
|
2851
|
+
);
|
|
2852
|
+
}
|
|
2853
|
+
if (anim.unattributedError > 0 && anim.chainDenominator > 0) {
|
|
2854
|
+
const share = (anim.unattributedError / anim.chainDenominator) * 100;
|
|
2855
|
+
out.push(
|
|
2856
|
+
` ${'(unattributed)'.padEnd(20)} ${'—'.padStart(6)} ${'—'.padEnd(33)} ${'—'.padStart(8)} ` +
|
|
2857
|
+
`${'—'.padStart(9)} ${`${share.toFixed(1)}%`.padStart(6)}`,
|
|
2858
|
+
);
|
|
2859
|
+
}
|
|
2860
|
+
out.push(
|
|
2861
|
+
" ⤷ share is of this set's own difference over the REFERENCE's drawn pixels, split by nearest ink; " +
|
|
2862
|
+
'`MAE in it` is that same error per pixel. The rule, the denominator and what a 0 % row means are under ' +
|
|
2863
|
+
'"chains" at the foot of the report.',
|
|
2864
|
+
);
|
|
2865
|
+
return out;
|
|
2866
|
+
}
|
|
2867
|
+
|
|
2868
|
+
/** One line per chain across every set, plus the roster the names refer to. */
|
|
2869
|
+
function chainFoot(report: CheckReport): string[] {
|
|
2870
|
+
if (report.chains.length === 0) return [];
|
|
2871
|
+
const out: string[] = [];
|
|
2872
|
+
out.push(' ── chains ──');
|
|
2873
|
+
out.push(
|
|
2874
|
+
" Cut from the CANDIDATE's own bone tree at every branch point: a chain runs from a root or a fork down to the",
|
|
2875
|
+
);
|
|
2876
|
+
out.push(
|
|
2877
|
+
' next fork, a single-bone chain that is itself a fork folds into its parent, and each is named after the first',
|
|
2878
|
+
);
|
|
2879
|
+
out.push(
|
|
2880
|
+
' bone in it that carries a slot. The reference is still nothing but pixels — this is your figure decomposed,',
|
|
2881
|
+
);
|
|
2882
|
+
out.push(' not the reference’s, which is what keeps it inside the ladder’s honesty rule.');
|
|
2883
|
+
out.push('');
|
|
2884
|
+
out.push(" MAE share divides the difference over the REFERENCE's own drawn pixels — the denominator from the MAE");
|
|
2885
|
+
out.push(' line above, which nothing you draw can grow — and splits it by giving each of those pixels to the chain');
|
|
2886
|
+
out.push(' whose ink is NEAREST it. So the shares are a partition and add to the whole, and no chain can look');
|
|
2887
|
+
out.push(' better by drawing more: growing its ink only pulls more of the reference’s pixels, and their error,');
|
|
2888
|
+
out.push(' into it. `MAE in it` is the same error per pixel it took, and it is the column that separates a chain');
|
|
2889
|
+
out.push(' that is WRONG from one that is merely large — a head and its features cover a lot of a figure and can');
|
|
2890
|
+
out.push(' carry a third of the error at a below-average figure per pixel.');
|
|
2891
|
+
out.push('');
|
|
2892
|
+
out.push(' ⚠️ Two things the split cannot do, both of which show rather than hide. Reference ink further from your');
|
|
2893
|
+
out.push(' ink than the part’s own size is left `(unattributed)` instead of blamed on a neighbour, so a part that');
|
|
2894
|
+
out.push(' has left its place stops being charged to whatever is next to it. And a chain that draws NOTHING seeds');
|
|
2895
|
+
out.push(' nothing and reads 0 % — which is why the slots column is beside the share: 0 % on 0 slots drawn is the');
|
|
2896
|
+
out.push(' loudest row here, not the quietest.');
|
|
2897
|
+
out.push(` ${'chain'.padEnd(20)} ${'bones'.padEnd(57)} slots`);
|
|
2898
|
+
for (const chain of report.chains) {
|
|
2899
|
+
out.push(
|
|
2900
|
+
` ${chain.name.padEnd(20)} ${chain.bones.join(', ').padEnd(57)} ` +
|
|
2901
|
+
`${chain.slots.length === 0 ? '(draws nothing)' : chain.slots.join(', ')}`,
|
|
2902
|
+
);
|
|
2903
|
+
}
|
|
2904
|
+
const rows = chainRollup(report);
|
|
2905
|
+
if (rows.length === 0) return out;
|
|
2906
|
+
out.push('');
|
|
2907
|
+
out.push(
|
|
2908
|
+
` ${'chain'.padEnd(20)} ${'worst slot drift across every set'.padEnd(56)} ` +
|
|
2909
|
+
`${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
|
|
2910
|
+
);
|
|
2911
|
+
for (const row of rows) {
|
|
2912
|
+
const where = row.set === null ? '' : ` in ${row.set}/f${String(row.frame).padStart(4, '0')}`;
|
|
2913
|
+
const worst =
|
|
2914
|
+
row.slot === null ? 'no slot attributable in any set' : `${row.drift.toFixed(1)} px ${JSON.stringify(row.slot)}${where}`;
|
|
2915
|
+
const mean = row.samples === 0 ? '—' : `${row.mean.toFixed(1)} px`;
|
|
2916
|
+
out.push(
|
|
2917
|
+
` ${row.chain.padEnd(20)} ${worst.padEnd(56)} ${mean.padStart(8)} ` +
|
|
2918
|
+
`${(row.pixels === 0 ? 0 : row.error / row.pixels).toFixed(2).padStart(9)} ` +
|
|
2919
|
+
`${`${(row.share * 100).toFixed(1)}%`.padStart(6)}`,
|
|
2920
|
+
);
|
|
2921
|
+
}
|
|
2922
|
+
out.push('');
|
|
2923
|
+
return out;
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2926
|
+
interface ChainRollup {
|
|
2927
|
+
chain: string;
|
|
2928
|
+
drift: number;
|
|
2929
|
+
slot: string | null;
|
|
2930
|
+
set: string | null;
|
|
2931
|
+
frame: number;
|
|
2932
|
+
mean: number;
|
|
2933
|
+
samples: number;
|
|
2934
|
+
error: number;
|
|
2935
|
+
pixels: number;
|
|
2936
|
+
share: number;
|
|
2937
|
+
}
|
|
2938
|
+
|
|
2939
|
+
/**
|
|
2940
|
+
* Each chain's worst reading anywhere in the run, worst share first.
|
|
2941
|
+
*
|
|
2942
|
+
* Worst-first here and derivation order in the per-set tables, deliberately: this
|
|
2943
|
+
* is the line a run’s README quotes, so it is ranked by what to fix, while a table
|
|
2944
|
+
* printed once per set is ranked so the sets line up.
|
|
2945
|
+
*/
|
|
2946
|
+
function chainRollup(report: CheckReport): ChainRollup[] {
|
|
2947
|
+
const rows = new Map<string, ChainRollup>();
|
|
2948
|
+
let denominator = 0;
|
|
2949
|
+
for (const anim of report.animations) {
|
|
2950
|
+
if (anim.compared === 0) continue;
|
|
2951
|
+
denominator += anim.chainDenominator;
|
|
2952
|
+
for (const chain of anim.chains) {
|
|
2953
|
+
const row = rows.get(chain.chain) ?? {
|
|
2954
|
+
chain: chain.chain,
|
|
2955
|
+
drift: 0,
|
|
2956
|
+
slot: null,
|
|
2957
|
+
set: null,
|
|
2958
|
+
frame: -1,
|
|
2959
|
+
mean: 0,
|
|
2960
|
+
samples: 0,
|
|
2961
|
+
error: 0,
|
|
2962
|
+
pixels: 0,
|
|
2963
|
+
share: 0,
|
|
2964
|
+
};
|
|
2965
|
+
if (chain.worstDriftSlot !== null && chain.worstDrift > row.drift) {
|
|
2966
|
+
row.drift = chain.worstDrift;
|
|
2967
|
+
row.slot = chain.worstDriftSlot;
|
|
2968
|
+
row.set = anim.dir;
|
|
2969
|
+
row.frame = chain.worstDriftFrame;
|
|
2970
|
+
}
|
|
2971
|
+
row.mean = row.mean * row.samples + chain.meanDrift * chain.driftSamples;
|
|
2972
|
+
row.samples += chain.driftSamples;
|
|
2973
|
+
row.mean = row.samples === 0 ? 0 : row.mean / row.samples;
|
|
2974
|
+
row.error += chain.error;
|
|
2975
|
+
row.pixels += chain.referencePixels;
|
|
2976
|
+
rows.set(chain.chain, row);
|
|
2977
|
+
}
|
|
2978
|
+
}
|
|
2979
|
+
const out = [...rows.values()];
|
|
2980
|
+
for (const row of out) row.share = denominator === 0 ? 0 : row.error / denominator;
|
|
2981
|
+
return out.sort((a, b) => b.share - a.share);
|
|
2982
|
+
}
|
|
2983
|
+
|
|
1592
2984
|
/**
|
|
1593
2985
|
* The frames worth printing: the worst by MAE, plus every change disagreement.
|
|
1594
2986
|
*
|
|
@@ -1659,8 +3051,72 @@ function convergence(framing: FramingReport): string {
|
|
|
1659
3051
|
}
|
|
1660
3052
|
|
|
1661
3053
|
/** The framing, as the line an author reads before anything else. */
|
|
1662
|
-
|
|
1663
|
-
|
|
3054
|
+
/** The `framed to` line: the box that was rendered into, and how it was chosen. */
|
|
3055
|
+
function framedToLines(v: Framing, how: FramingHow | null, indent = ''): string[] {
|
|
3056
|
+
const said =
|
|
3057
|
+
how === 'candidate-pixels'
|
|
3058
|
+
? "fitted to the candidate's own drawn pixels"
|
|
3059
|
+
: how === 'frames-viewport'
|
|
3060
|
+
? `${FRAMES_SIDECAR}'s own box — the candidate measured into it`
|
|
3061
|
+
: '--viewport';
|
|
3062
|
+
return [
|
|
3063
|
+
`${indent} framed to ${v.pixelWidth}x${v.pixelHeight}px ${v.scale.toFixed(6)} px/unit ` +
|
|
3064
|
+
`world x[${v.x.toFixed(1)} .. ${(v.x + v.width).toFixed(1)}] y[${v.y.toFixed(1)} .. ${(v.y + v.height).toFixed(1)}] (${said})`,
|
|
3065
|
+
];
|
|
3066
|
+
}
|
|
3067
|
+
|
|
3068
|
+
/**
|
|
3069
|
+
* What the MAE-refined pass found, in the words that separate its four answers.
|
|
3070
|
+
*
|
|
3071
|
+
* All four are printed, the identity included, because "the framing is already
|
|
3072
|
+
* where the picture is" is a measurement this pass makes and not a default it
|
|
3073
|
+
* falls back to — the same reason an assertion with nothing to measure reports
|
|
3074
|
+
* SKIP here rather than a pass. The two that decline a real offset are the loud
|
|
3075
|
+
* ones: they say the constant pixel is in the candidate rather than in the box.
|
|
3076
|
+
*/
|
|
3077
|
+
function refinementLines(refinement: FramingRefinement | null, indent = ''): string[] {
|
|
3078
|
+
if (refinement === null) return [];
|
|
3079
|
+
const { dx, dy, before, after, radius, frames } = refinement;
|
|
3080
|
+
const at = `${dx >= 0 ? '+' : ''}${dx}, ${dy >= 0 ? '+' : ''}${dy} px`;
|
|
3081
|
+
const worth =
|
|
3082
|
+
`${f2(before)} → ${f2(after)} over the reference's own pixels` +
|
|
3083
|
+
(before > 0 ? ` (${(((before - after) / before) * 100).toFixed(1)}% of the figure)` : '');
|
|
3084
|
+
const searched = `±${radius} px over ${frames} frame(s)`;
|
|
3085
|
+
if (refinement.declined === 'identity') {
|
|
3086
|
+
return [
|
|
3087
|
+
`${indent} ⤷ MAE-refined pass: searched ${searched} and the identity won, so no part of this set's ` +
|
|
3088
|
+
'figure is a constant offset.',
|
|
3089
|
+
];
|
|
3090
|
+
}
|
|
3091
|
+
if (refinement.declined === 'below-threshold') {
|
|
3092
|
+
return [
|
|
3093
|
+
`${indent} ⤷ MAE-refined pass: the best offset in ${searched} was ${at}, worth ${worth} — under the ` +
|
|
3094
|
+
`${(REFINE_MIN_GAIN * 100).toFixed(0)}% / ${REFINE_MIN_GAIN_MAE.toFixed(2)} MAE this pass moves a box for, so ` +
|
|
3095
|
+
'the box was left alone.',
|
|
3096
|
+
];
|
|
3097
|
+
}
|
|
3098
|
+
if (refinement.declined === 'box-is-exact') {
|
|
3099
|
+
return [
|
|
3100
|
+
`${indent} ⚠️ a constant ${at} would take this set ${worth} — and it was NOT applied, because this ` +
|
|
3101
|
+
`box is ${FRAMES_SIDECAR}'s own and is not an estimate of anything. A constant pixel inside the box the ` +
|
|
3102
|
+
'frames were drawn at is your own figure sitting a pixel off, which is a thing to fix rather than to frame ' +
|
|
3103
|
+
'away. Read it beside the drift below.',
|
|
3104
|
+
];
|
|
3105
|
+
}
|
|
3106
|
+
if (refinement.declined === 'pinned') {
|
|
3107
|
+
return [
|
|
3108
|
+
`${indent} ⤷ a constant ${at} would take this set ${worth} — measured, NOT applied, because ` +
|
|
3109
|
+
'--viewport pinned the box.',
|
|
3110
|
+
];
|
|
3111
|
+
}
|
|
3112
|
+
return [
|
|
3113
|
+
`${indent} ⭐ MAE-refined by ${at}: ${worth}. The fit above registers the two extents, and the best ` +
|
|
3114
|
+
'fit of two extents is not the best alignment of two pictures — this pass takes that difference out, so the ' +
|
|
3115
|
+
'figures below are what is left after it rather than a constant offset read as motion.',
|
|
3116
|
+
];
|
|
3117
|
+
}
|
|
3118
|
+
|
|
3119
|
+
function framingLines(framing: FramingReport | null, indent = ''): string[] {
|
|
1664
3120
|
if (!framing) return [];
|
|
1665
3121
|
const { fit } = framing;
|
|
1666
3122
|
const c = fit.candidate;
|
|
@@ -1670,8 +3126,8 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1670
3126
|
`${boxWidth(b).toFixed(1)}x${boxHeight(b).toFixed(1)}px at (${b.left.toFixed(1)}, ${b.top.toFixed(1)})`;
|
|
1671
3127
|
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
1672
3128
|
const out = [
|
|
1673
|
-
|
|
1674
|
-
|
|
3129
|
+
`${indent} content candidate ${box(c)} reference ${box(r)} (union over ${fit.frames} frame(s))`,
|
|
3130
|
+
`${indent} ⤷ fit x${fit.scale.toFixed(6)} offset ${signed(fit.dx)}, ${signed(fit.dy)} px ` +
|
|
1675
3131
|
`rms ${fit.rms.toFixed(2)} px over ${fit.frames * 4} edge(s) ` +
|
|
1676
3132
|
`union residual ${signed(fit.residualWidth)} x ${signed(fit.residualHeight)} px ` +
|
|
1677
3133
|
`aspect ${percent(fit.aspectError)}` +
|
|
@@ -1679,11 +3135,12 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1679
3135
|
? ` (${framing.source}, ${framing.passes} pass(es), ${convergence(framing)})`
|
|
1680
3136
|
: ' (measured, NOT applied — --viewport pinned)'),
|
|
1681
3137
|
];
|
|
3138
|
+
out.push(...refinementLines(framing.refinement, indent));
|
|
1682
3139
|
const spread = Math.max(Math.abs(fit.residualWidth), Math.abs(fit.residualHeight));
|
|
1683
3140
|
if (spread > 1) {
|
|
1684
3141
|
const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
|
|
1685
3142
|
out.push(
|
|
1686
|
-
|
|
3143
|
+
`${indent} ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
|
|
1687
3144
|
`${axis} and ${Math.abs(fit.residualHeight).toFixed(1)} px ` +
|
|
1688
3145
|
`${fit.residualHeight > 0 ? 'taller' : 'shorter'} than the reference's. One uniform scale cannot absorb ` +
|
|
1689
3146
|
'that: something reaches somewhere nothing in the frames does, or is a different size. Read it before ' +
|
|
@@ -1692,7 +3149,7 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1692
3149
|
}
|
|
1693
3150
|
if (fit.rms > 1) {
|
|
1694
3151
|
out.push(
|
|
1695
|
-
|
|
3152
|
+
`${indent} ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
|
|
1696
3153
|
'scale and offset puts the two shots on each other — they are different shapes, not the same shape ' +
|
|
1697
3154
|
'misframed.',
|
|
1698
3155
|
);
|
|
@@ -1700,12 +3157,12 @@ function framingLines(report: CheckReport): string[] {
|
|
|
1700
3157
|
const units = framing.units;
|
|
1701
3158
|
if (units) {
|
|
1702
3159
|
out.push(
|
|
1703
|
-
|
|
3160
|
+
`${indent} in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
|
|
1704
3161
|
`reference ${units.reference.width.toFixed(1)} x ${units.reference.height.toFixed(1)} ` +
|
|
1705
3162
|
`x${units.ratio.toFixed(4)}`,
|
|
1706
3163
|
);
|
|
1707
3164
|
out.push(
|
|
1708
|
-
|
|
3165
|
+
`${indent} ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ` +
|
|
1709
3166
|
'purpose — a rig is authored in its own coordinates — so this is the only place one shows. It compares ' +
|
|
1710
3167
|
'only if you measured the shot in the frames’ own units.',
|
|
1711
3168
|
);
|