spine-rigc 0.3.0 → 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/src/check.ts CHANGED
@@ -77,6 +77,9 @@ import {
77
77
  PAD,
78
78
  FRAMES_SIDECAR,
79
79
  FRAMES_SPEC,
80
+ SHEET_COLUMNS,
81
+ SHEET_FILE,
82
+ SHEET_GAP,
80
83
  type Footprint,
81
84
  type Frame,
82
85
  type FramesSidecar,
@@ -94,21 +97,38 @@ import {
94
97
  fitIsSettled,
95
98
  fitSeparation,
96
99
  frameContentBox,
100
+ offsetIsWorthApplying,
101
+ OffsetScan,
102
+ shiftViewport,
97
103
  unionBoxes,
98
104
  isContent,
99
105
  BACKGROUND_TOLERANCE,
100
106
  CYCLE_PIXELS,
107
+ REFINE_MIN_GAIN,
108
+ REFINE_MIN_GAIN_MAE,
109
+ REFINE_RADIUS,
101
110
  type BoxPair,
102
111
  type ContentBox,
103
112
  type FramingFit,
113
+ type OffsetGain,
104
114
  } from './framing.ts';
105
- import { componentsOf, isAttributable, matchSlots, searchRadius, type SlotTrack } from './slots.ts';
115
+ import { componentField, isAttributable, matchSlots, searchRadius, type SlotTrack } from './slots.ts';
106
116
  import { chainsOf, type BoneChain } from './chains.ts';
107
117
  import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
108
-
109
- export { componentsOf, matchSlots, searchRadius, type Component, type MatchMethod, type SlotTrack } from './slots.ts';
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';
110
130
  export { chainsOf, chainBySlot, type BoneChain } from './chains.ts';
111
- export type { BoxPair, ContentBox, FramingFit } from './framing.ts';
131
+ export type { BoxPair, ContentBox, FramingFit, OffsetGain } from './framing.ts';
112
132
 
113
133
  // ---------------------------------------------------------------------------
114
134
  // the reference side — frames only, and mechanically so
@@ -362,6 +382,57 @@ export interface ChainCheck {
362
382
  maeShare: number;
363
383
  }
364
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
+
365
436
  export interface AnimationCheck {
366
437
  dir: string;
367
438
  /** The animation the frames show, per the sidecar. */
@@ -432,6 +503,12 @@ export interface AnimationCheck {
432
503
  framing: FramingHow;
433
504
  /** Where this set's drawn pixels ended up against the reference's. */
434
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;
435
512
  notes: string[];
436
513
  }
437
514
 
@@ -499,7 +576,16 @@ export type FramingScope = 'per-shot' | 'shared';
499
576
 
500
577
  /** What the framing pass concluded, and how sure it is of it. */
501
578
  export interface FramingReport {
502
- /** The residual fit measured at the viewport that was used. */
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
+ */
503
589
  fit: FramingFit;
504
590
  /** How many render/measure/correct passes ran. */
505
591
  passes: number;
@@ -547,6 +633,70 @@ export interface FramingReport {
547
633
  * what it does and does not mean attached.
548
634
  */
549
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;
550
700
  }
551
701
 
552
702
  /** A width and a height in world units. */
@@ -798,6 +948,9 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
798
948
  'framing line below is still measured, so it says what the pin cost.',
799
949
  );
800
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');
801
954
  const perSet = prepared.map((p, i) =>
802
955
  pairUpBoxes([p], posable.pages, pinned, background, level, slices[i]),
803
956
  );
@@ -806,7 +959,10 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
806
959
  framings.push({
807
960
  viewport: pinned,
808
961
  how: 'viewport-flag',
809
- fit: fit === null ? null : reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS }),
962
+ fit:
963
+ fit === null
964
+ ? null
965
+ : reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement }),
810
966
  notes: [],
811
967
  });
812
968
  }
@@ -815,7 +971,7 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
815
971
  topHow = 'viewport-flag';
816
972
  if (all.length > 0) {
817
973
  const fit = fitFraming(all);
818
- topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS });
974
+ topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement });
819
975
  }
820
976
  } else if (referenceBoxes.every((b) => b === null)) {
821
977
  throw new CheckError('no reference frame could be compared, so there is nothing to frame against');
@@ -830,10 +986,24 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
830
986
  pixelHeight,
831
987
  referenceViewport,
832
988
  );
833
- const fit = { ...framed.report, units: extentsOf(framed.report.fit, framed.viewport.scale, referenceViewport) };
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
+ };
834
1004
  const how = HOW_BY_SOURCE[framed.report.source];
835
- for (let i = 0; i < prepared.length; i++) framings.push({ viewport: framed.viewport, how, fit, notes: [] });
836
- topViewport = framed.viewport;
1005
+ for (let i = 0; i < prepared.length; i++) framings.push({ viewport, how, fit, notes: [] });
1006
+ topViewport = viewport;
837
1007
  topHow = how;
838
1008
  topFit = fit;
839
1009
  notes.push(...framingNotes(framed.report));
@@ -902,17 +1072,24 @@ export function checkAgainstFrames(options: CheckOptions): CheckReport {
902
1072
  pixelHeight,
903
1073
  referenceViewport,
904
1074
  );
905
- if (!declared) {
906
- framings.push({ ...sharedShape, notes: framingNotes(shared.report) });
907
- continue;
908
- }
909
- own++;
910
- framings.push({
911
- viewport: declared.viewport,
912
- how: HOW_BY_SOURCE[declared.report.source],
913
- fit: { ...declared.report, units: extentsOf(declared.report.fit, declared.viewport.scale, referenceViewport) },
914
- notes: framingNotes(declared.report),
915
- });
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));
916
1093
  }
917
1094
  notes.push(
918
1095
  `the framing was decided per frame set: ${own} of ${prepared.length} set(s) were measured in ` +
@@ -1303,6 +1480,8 @@ function frameCandidate(
1303
1480
  cycled: chain.cycled,
1304
1481
  agrees: chosen.distance <= COINCIDENT_PIXELS,
1305
1482
  applied: true,
1483
+ // Filled in by the refined pass, which runs once the box is decided.
1484
+ refinement: null,
1306
1485
  },
1307
1486
  };
1308
1487
  }
@@ -1481,10 +1660,91 @@ function frameByDeclaredBox(
1481
1660
  cycled: false,
1482
1661
  agrees: true,
1483
1662
  applied: true,
1663
+ refinement: null,
1484
1664
  },
1485
1665
  };
1486
1666
  }
1487
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
+
1488
1748
  /**
1489
1749
  * What the framing pass concluded, in the words that tell the three cases apart.
1490
1750
  *
@@ -1539,6 +1799,16 @@ interface PreparedSet {
1539
1799
  candidateFrames: number;
1540
1800
  referenceFrames: number;
1541
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[];
1542
1812
  notes: string[];
1543
1813
  /** Set when nothing could be compared at all, saying why. */
1544
1814
  missing: string | null;
@@ -1562,6 +1832,7 @@ function prepareSet(
1562
1832
  candidateFrames: 0,
1563
1833
  referenceFrames: disk.length,
1564
1834
  pairs: [],
1835
+ frames: [],
1565
1836
  notes: [],
1566
1837
  missing:
1567
1838
  `the candidate has no animation called ${JSON.stringify(wanted)} — it has [${have.join(', ') || 'none'}]. ` +
@@ -1609,6 +1880,7 @@ function prepareSet(
1609
1880
  candidateFrames: candidateFrames.length,
1610
1881
  referenceFrames: disk.length,
1611
1882
  pairs,
1883
+ frames: candidateFrames,
1612
1884
  notes,
1613
1885
  missing: null,
1614
1886
  };
@@ -1654,6 +1926,7 @@ function checkOneSet(
1654
1926
  viewport: framingOfViewport(viewport),
1655
1927
  framing: framing.how,
1656
1928
  framingFit: framing.fit,
1929
+ sheet: null,
1657
1930
  notes: prepared.missing ? [prepared.missing] : [...framing.notes, ...prepared.notes],
1658
1931
  };
1659
1932
  if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
@@ -1729,9 +2002,15 @@ function checkOneSet(
1729
2002
  }
1730
2003
  }
1731
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
+
1732
2010
  return {
1733
2011
  ...blank,
1734
2012
  compared: frames.length,
2013
+ sheet: sheet.sheet,
1735
2014
  chains: chainChecks(chains, frames, tally),
1736
2015
  chainDenominator: tally.total,
1737
2016
  unattributedError: tally.unattributed,
@@ -1749,7 +2028,7 @@ function checkOneSet(
1749
2028
  changeDisagreements,
1750
2029
  worstChangeFrame,
1751
2030
  frames,
1752
- notes: [...framing.notes, ...prepared.notes],
2031
+ notes: [...framing.notes, ...prepared.notes, ...sheet.notes],
1753
2032
  };
1754
2033
  }
1755
2034
 
@@ -2082,8 +2361,9 @@ function checkOneFrame(
2082
2361
  }
2083
2362
  }
2084
2363
 
2085
- const components = componentsOf(reference, background);
2086
- const { tracks, matchedComponents } = matchSlots(footprints, components, {
2364
+ const field = componentField(reference, background);
2365
+ const components = field.components;
2366
+ const { tracks, matchedComponents } = matchSlots(footprints, field, {
2087
2367
  frame,
2088
2368
  pages,
2089
2369
  viewport,
@@ -2130,6 +2410,205 @@ function checkOneFrame(
2130
2410
  };
2131
2411
  }
2132
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
+
2133
2612
  // ---------------------------------------------------------------------------
2134
2613
  // the report
2135
2614
  // ---------------------------------------------------------------------------
@@ -2286,6 +2765,7 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
2286
2765
  `f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
2287
2766
  );
2288
2767
  lines.push(changeSummary(anim));
2768
+ for (const line of sheetLines(anim.sheet)) lines.push(line);
2289
2769
  for (const line of chainTable(anim)) lines.push(line);
2290
2770
  lines.push('');
2291
2771
 
@@ -2332,6 +2812,10 @@ export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }):
2332
2812
  lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
2333
2813
  lines.push(' merged it into a neighbour; the number beside it is how much better that match was');
2334
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.');
2335
2819
  lines.push(' `Δpx` and `ref Δ` are how many pixels each side moved since ITS OWN previous frame —');
2336
2820
  lines.push(' not against each other. They are the only columns that can see a held pose that is');
2337
2821
  lines.push(' not held, or a one-frame event that never fired: both are small in every frame and');
@@ -2581,6 +3065,57 @@ function framedToLines(v: Framing, how: FramingHow | null, indent = ''): string[
2581
3065
  ];
2582
3066
  }
2583
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
+
2584
3119
  function framingLines(framing: FramingReport | null, indent = ''): string[] {
2585
3120
  if (!framing) return [];
2586
3121
  const { fit } = framing;
@@ -2600,6 +3135,7 @@ function framingLines(framing: FramingReport | null, indent = ''): string[] {
2600
3135
  ? ` (${framing.source}, ${framing.passes} pass(es), ${convergence(framing)})`
2601
3136
  : ' (measured, NOT applied — --viewport pinned)'),
2602
3137
  ];
3138
+ out.push(...refinementLines(framing.refinement, indent));
2603
3139
  const spread = Math.max(Math.abs(fit.residualWidth), Math.abs(fit.residualHeight));
2604
3140
  if (spread > 1) {
2605
3141
  const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';