rig-c 0.0.0-stage → 2.20.4
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/framing.ts
ADDED
|
@@ -0,0 +1,819 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framing — putting the candidate's pixels onto the reference's pixels.
|
|
3
|
+
*
|
|
4
|
+
* ## Why this is its own module
|
|
5
|
+
*
|
|
6
|
+
* Everything `check` reports sits downstream of one decision: which world box the
|
|
7
|
+
* candidate is rendered into. Get it wrong and every pixel below it is shifted or
|
|
8
|
+
* rescaled, and the error arrives disguised as MAE — as motion, which is the one
|
|
9
|
+
* thing `check` exists to measure. Two independent honest authoring runs measured
|
|
10
|
+
* exactly that (issue #34): rung 4's `wave-by-hand` read 49.45 framed and 22.96
|
|
11
|
+
* with the box pinned, on identical keys, and rung 5's first correct build read
|
|
12
|
+
* 39.00 instead of 4.35 because its content box came out 0.93 % narrow.
|
|
13
|
+
*
|
|
14
|
+
* ## The rule: both sides are measured the same way, on pixels
|
|
15
|
+
*
|
|
16
|
+
* The old procedure framed the candidate by the union of its **posed quad
|
|
17
|
+
* corners**. A region attachment's quad extends past its own artwork wherever the
|
|
18
|
+
* art is transparent, so a corner can sit where no pixel is — and since the
|
|
19
|
+
* mapping used `minX`, `maxY` and the long side only, one such corner in one frame
|
|
20
|
+
* of one animation set the scale for the whole comparison.
|
|
21
|
+
*
|
|
22
|
+
* So the box is taken from **drawn pixels** instead, on both sides, with the same
|
|
23
|
+
* predicate: a pixel is content when it differs from the frames' background by
|
|
24
|
+
* more than `BACKGROUND_TOLERANCE` on any channel. The candidate's box is the
|
|
25
|
+
* union over the frames it will be compared on, the reference's is the union over
|
|
26
|
+
* the frames on disk, and a similarity transform (uniform scale + translation)
|
|
27
|
+
* carries one onto the other.
|
|
28
|
+
*
|
|
29
|
+
* ⭐ The property that buys: **when the candidate is right, the framing is exact.**
|
|
30
|
+
* A faithful candidate renders to the same pixels as the reference, so its content
|
|
31
|
+
* box IS the reference's content box, the fit is the identity, and every
|
|
32
|
+
* measurement error in this file cancels. What is left is a procedure whose noise
|
|
33
|
+
* shrinks as the candidate improves, which is the only shape of noise an authoring
|
|
34
|
+
* loop can work against.
|
|
35
|
+
*
|
|
36
|
+
* ## And one pass after that, on the MAE itself
|
|
37
|
+
*
|
|
38
|
+
* The extent fit has a floor it cannot see past, because the best fit of two
|
|
39
|
+
* extents is not the best alignment of two pictures. On a hard shot that floor is
|
|
40
|
+
* a **constant** pixel worth a tenth of the headline figure (issue #146), which a
|
|
41
|
+
* loop reads as motion. So a fitted box gets one final pass — `OffsetScan` — that
|
|
42
|
+
* searches whole-pixel translations in a ±`REFINE_RADIUS` window for the lowest
|
|
43
|
+
* *reference-denominator MAE* and moves the box when the gain clears
|
|
44
|
+
* `REFINE_MIN_GAIN`. It optimises the reported figure directly rather than a proxy
|
|
45
|
+
* for it, which is what separates it from the extent refinement measured and
|
|
46
|
+
* rejected in `frameByDeclaredBox`: that one walked off the answer because the
|
|
47
|
+
* answer was not what it was minimising.
|
|
48
|
+
*/
|
|
49
|
+
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
50
|
+
import {
|
|
51
|
+
fill,
|
|
52
|
+
pageFor,
|
|
53
|
+
projector,
|
|
54
|
+
rasterisePiece,
|
|
55
|
+
viewportOfSize,
|
|
56
|
+
type Frame,
|
|
57
|
+
type Viewport,
|
|
58
|
+
} from './render_shared.ts';
|
|
59
|
+
|
|
60
|
+
/** How far a channel must move for a pixel to count as "not background". */
|
|
61
|
+
export const BACKGROUND_TOLERANCE = 8;
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* How far a pixel is from the background, as the largest channel difference.
|
|
65
|
+
*
|
|
66
|
+
* Used two ways, and they have to be the same function or the two uses disagree
|
|
67
|
+
* about where an edge is: as a threshold (`isContent`) and as a weight (the
|
|
68
|
+
* sub-pixel edge estimate below).
|
|
69
|
+
*/
|
|
70
|
+
export function backgroundDistance(plate: Plate, x: number, y: number, background: RGBA): number {
|
|
71
|
+
const [r, g, b] = plate.get(x, y);
|
|
72
|
+
return Math.max(
|
|
73
|
+
Math.abs(r - background[0]),
|
|
74
|
+
Math.abs(g - background[1]),
|
|
75
|
+
Math.abs(b - background[2]),
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function isContent(plate: Plate, x: number, y: number, background: RGBA): boolean {
|
|
80
|
+
return backgroundDistance(plate, x, y, background) > BACKGROUND_TOLERANCE;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Where a shape stops, as opposed to where it is faintly visible.
|
|
85
|
+
*
|
|
86
|
+
* ## Why the content box does not use `BACKGROUND_TOLERANCE`
|
|
87
|
+
*
|
|
88
|
+
* `BACKGROUND_TOLERANCE` answers "is there anything here at all", at about 3 % of
|
|
89
|
+
* a channel, and that is the right question for the union alpha and for
|
|
90
|
+
* connected components. It is the wrong question for an *edge*, because the two
|
|
91
|
+
* sides of this comparison do not render edges the same way: the reference frames
|
|
92
|
+
* are drawn from the example's packed atlas, which for several rungs ships at
|
|
93
|
+
* `scale: 0.5`, while the candidate is compiled from the loose full-size PNGs.
|
|
94
|
+
* Same geometry, softer ramp — and at a 3 % threshold the softer ramp reaches
|
|
95
|
+
* further out.
|
|
96
|
+
*
|
|
97
|
+
* Measured on rung 3's mechanical transcription, where the two sides are the same
|
|
98
|
+
* skeleton and the true answer is "identical": at tolerance 8 the left edges
|
|
99
|
+
* disagreed by **0.36 px**, which is enough to double the reported MAE.
|
|
100
|
+
*
|
|
101
|
+
* A blurred step crosses **half** its own contrast at the position of the
|
|
102
|
+
* original step, whatever the blur — so the box is taken at half of a robust
|
|
103
|
+
* estimate of a solid pixel's contrast, and the same disagreement drops to
|
|
104
|
+
* **0.01 px**. The level is derived from the reference frames and used on both
|
|
105
|
+
* sides, so it is one number rather than two that can drift apart.
|
|
106
|
+
*/
|
|
107
|
+
export const EDGE_FRACTION = 0.5;
|
|
108
|
+
/** Which quantile of the content's contrast counts as "a solid pixel". */
|
|
109
|
+
export const CONTENT_QUANTILE = 0.75;
|
|
110
|
+
|
|
111
|
+
/** Pooled contrast of everything that is not background, as a 0..255 histogram. */
|
|
112
|
+
export class ContrastHistogram {
|
|
113
|
+
private readonly bins = new Uint32Array(256);
|
|
114
|
+
private counted = 0;
|
|
115
|
+
|
|
116
|
+
add(plate: Plate, background: RGBA): void {
|
|
117
|
+
for (let y = 0; y < plate.height; y++) {
|
|
118
|
+
for (let x = 0; x < plate.width; x++) {
|
|
119
|
+
const d = backgroundDistance(plate, x, y, background);
|
|
120
|
+
if (d <= BACKGROUND_TOLERANCE) continue;
|
|
121
|
+
this.bins[Math.min(255, Math.round(d))]++;
|
|
122
|
+
this.counted++;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The half-maximum level, or the bare tolerance when nothing was counted. */
|
|
128
|
+
level(): number {
|
|
129
|
+
if (this.counted === 0) return BACKGROUND_TOLERANCE;
|
|
130
|
+
const target = this.counted * CONTENT_QUANTILE;
|
|
131
|
+
let seen = 0;
|
|
132
|
+
for (let d = 0; d < 256; d++) {
|
|
133
|
+
seen += this.bins[d];
|
|
134
|
+
if (seen >= target) return Math.max(BACKGROUND_TOLERANCE, d * EDGE_FRACTION);
|
|
135
|
+
}
|
|
136
|
+
return BACKGROUND_TOLERANCE;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* A content box in frame pixels, with **real** edges rather than pixel indices.
|
|
142
|
+
*
|
|
143
|
+
* The convention is the one a rasteriser uses: pixel `i` covers `[i, i+1)`, so a
|
|
144
|
+
* box `{ left: 3, right: 7 }` is four whole pixels wide and `{ left: 3.5 }` says
|
|
145
|
+
* the edge runs down the middle of pixel 3.
|
|
146
|
+
*/
|
|
147
|
+
export interface ContentBox {
|
|
148
|
+
left: number;
|
|
149
|
+
top: number;
|
|
150
|
+
right: number;
|
|
151
|
+
bottom: number;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export const boxWidth = (b: ContentBox): number => b.right - b.left;
|
|
155
|
+
export const boxHeight = (b: ContentBox): number => b.bottom - b.top;
|
|
156
|
+
|
|
157
|
+
export function unionBoxes(a: ContentBox | null, b: ContentBox | null): ContentBox | null {
|
|
158
|
+
if (!a) return b;
|
|
159
|
+
if (!b) return a;
|
|
160
|
+
return {
|
|
161
|
+
left: Math.min(a.left, b.left),
|
|
162
|
+
top: Math.min(a.top, b.top),
|
|
163
|
+
right: Math.max(a.right, b.right),
|
|
164
|
+
bottom: Math.max(a.bottom, b.bottom),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** An integer scan window, half-open on the far edges. */
|
|
169
|
+
export interface ScanWindow {
|
|
170
|
+
minX: number;
|
|
171
|
+
minY: number;
|
|
172
|
+
maxX: number;
|
|
173
|
+
maxY: number;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Where a rendered plate's content stops, to a fraction of a pixel.
|
|
178
|
+
*
|
|
179
|
+
* ## The sub-pixel part, and why it is worth the paragraph
|
|
180
|
+
*
|
|
181
|
+
* A box read off integer pixel indices is quantised to ±0.5 px per edge, and rung
|
|
182
|
+
* 5 measured this shot's MAE moving 1.35 for a **0.065 px** viewport offset. Half
|
|
183
|
+
* a pixel of framing noise would therefore be louder than most of what an author
|
|
184
|
+
* is trying to hear.
|
|
185
|
+
*
|
|
186
|
+
* So each edge is refined by the mass in its outermost row or column. Anti-aliased
|
|
187
|
+
* coverage scales `backgroundDistance` roughly linearly, so if the first column
|
|
188
|
+
* holding content carries half the mass of the column behind it, the true edge
|
|
189
|
+
* runs about half a pixel in. That model is crude for a wedge — a shape that
|
|
190
|
+
* genuinely narrows towards its edge reads as partial coverage — but it is applied
|
|
191
|
+
* **identically to both sides**, so on similar silhouettes the bias is common-mode
|
|
192
|
+
* and on an exact candidate it cancels outright.
|
|
193
|
+
*
|
|
194
|
+
* `level` is the edge threshold — see `EDGE_FRACTION` for why it is not the bare
|
|
195
|
+
* background tolerance. `within` bounds the scan; it must contain every pixel that
|
|
196
|
+
* could be content, and the caller has that for free from the rasteriser's own
|
|
197
|
+
* destination bounds.
|
|
198
|
+
*/
|
|
199
|
+
export function contentBoxOfPlate(
|
|
200
|
+
plate: Plate,
|
|
201
|
+
background: RGBA,
|
|
202
|
+
level: number,
|
|
203
|
+
within?: ScanWindow,
|
|
204
|
+
): ContentBox | null {
|
|
205
|
+
const x0 = Math.max(0, within?.minX ?? 0);
|
|
206
|
+
const y0 = Math.max(0, within?.minY ?? 0);
|
|
207
|
+
const x1 = Math.min(plate.width, within?.maxX ?? plate.width);
|
|
208
|
+
const y1 = Math.min(plate.height, within?.maxY ?? plate.height);
|
|
209
|
+
if (x1 <= x0 || y1 <= y0) return null;
|
|
210
|
+
|
|
211
|
+
const columns = new Float64Array(plate.width);
|
|
212
|
+
const rows = new Float64Array(plate.height);
|
|
213
|
+
let minX = Infinity;
|
|
214
|
+
let minY = Infinity;
|
|
215
|
+
let maxX = -Infinity;
|
|
216
|
+
let maxY = -Infinity;
|
|
217
|
+
for (let y = y0; y < y1; y++) {
|
|
218
|
+
for (let x = x0; x < x1; x++) {
|
|
219
|
+
const d = backgroundDistance(plate, x, y, background);
|
|
220
|
+
if (d <= level) continue;
|
|
221
|
+
columns[x] += d;
|
|
222
|
+
rows[y] += d;
|
|
223
|
+
if (x < minX) minX = x;
|
|
224
|
+
if (x > maxX) maxX = x;
|
|
225
|
+
if (y < minY) minY = y;
|
|
226
|
+
if (y > maxY) maxY = y;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
if (!Number.isFinite(minX)) return null;
|
|
230
|
+
return {
|
|
231
|
+
left: minX + inset(columns, minX, 1),
|
|
232
|
+
right: maxX + 1 - inset(columns, maxX, -1),
|
|
233
|
+
top: minY + inset(rows, minY, 1),
|
|
234
|
+
bottom: maxY + 1 - inset(rows, maxY, -1),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* How far inside its own pixel an edge sits, from the mass either side of it.
|
|
240
|
+
*
|
|
241
|
+
* `towards` points into the shape. A full outer line (as much mass as the line
|
|
242
|
+
* behind it) insets nothing; an outer line with none of it insets a whole pixel,
|
|
243
|
+
* which is the limit rather than a case that happens — a line with no mass is not
|
|
244
|
+
* the edge.
|
|
245
|
+
*/
|
|
246
|
+
function inset(mass: Float64Array, edge: number, towards: 1 | -1): number {
|
|
247
|
+
const inner = mass[edge + towards];
|
|
248
|
+
if (!inner || inner <= 0) return 0;
|
|
249
|
+
return Math.max(0, Math.min(1, 1 - mass[edge] / inner));
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Every pixel a frame's pieces could touch, as an integer scan window. */
|
|
253
|
+
function pieceWindow(frame: Frame, viewport: Viewport): ScanWindow | null {
|
|
254
|
+
const project = projector(viewport);
|
|
255
|
+
let minX = Infinity;
|
|
256
|
+
let minY = Infinity;
|
|
257
|
+
let maxX = -Infinity;
|
|
258
|
+
let maxY = -Infinity;
|
|
259
|
+
for (const piece of frame.pieces) {
|
|
260
|
+
for (let i = 0; i < piece.world.length; i += 2) {
|
|
261
|
+
const [px, py] = project(piece.world[i], piece.world[i + 1]);
|
|
262
|
+
if (px < minX) minX = px;
|
|
263
|
+
if (px > maxX) maxX = px;
|
|
264
|
+
if (py < minY) minY = py;
|
|
265
|
+
if (py > maxY) maxY = py;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (!Number.isFinite(minX)) return null;
|
|
269
|
+
return {
|
|
270
|
+
minX: Math.floor(minX) - 1,
|
|
271
|
+
minY: Math.floor(minY) - 1,
|
|
272
|
+
maxX: Math.ceil(maxX) + 2,
|
|
273
|
+
maxY: Math.ceil(maxY) + 2,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The content box of one candidate frame, drawn into `viewport`.
|
|
279
|
+
*
|
|
280
|
+
* Composited rather than measured piece by piece: two nearly-transparent parts
|
|
281
|
+
* overlapping at an extreme edge are content together and neither alone, and the
|
|
282
|
+
* reference side is a composite, so this one has to be too.
|
|
283
|
+
*/
|
|
284
|
+
export function frameContentBox(
|
|
285
|
+
frame: Frame,
|
|
286
|
+
pages: Map<string, Plate>,
|
|
287
|
+
viewport: Viewport,
|
|
288
|
+
background: RGBA,
|
|
289
|
+
level: number,
|
|
290
|
+
): ContentBox | null {
|
|
291
|
+
const window = pieceWindow(frame, viewport);
|
|
292
|
+
if (!window) return null;
|
|
293
|
+
const plate = new Plate(viewport.width, viewport.height);
|
|
294
|
+
fill(plate, background);
|
|
295
|
+
const project = projector(viewport);
|
|
296
|
+
for (const piece of frame.pieces) {
|
|
297
|
+
rasterisePiece(pageFor(pages, piece), piece, project, viewport, (px, py, r, g, b, a) => {
|
|
298
|
+
plate.blend(px, py, [r, g, b, a]);
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
return contentBoxOfPlate(plate, background, level, window);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
// the fit
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
/** One frame's two content boxes: what the candidate drew, and what is on disk. */
|
|
309
|
+
export interface BoxPair {
|
|
310
|
+
candidate: ContentBox;
|
|
311
|
+
reference: ContentBox;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** What framing the candidate cost, and what it could not absorb. */
|
|
315
|
+
export interface FramingFit {
|
|
316
|
+
/** The candidate's content box, unioned over every frame compared. */
|
|
317
|
+
candidate: ContentBox;
|
|
318
|
+
/** The reference frames' content box, over the same frames. */
|
|
319
|
+
reference: ContentBox;
|
|
320
|
+
/** How many frames the fit was made from. */
|
|
321
|
+
frames: number;
|
|
322
|
+
/**
|
|
323
|
+
* Uniform scale carrying candidate pixels onto reference pixels.
|
|
324
|
+
*
|
|
325
|
+
* `> 1` means the candidate's content is SMALLER than the reference's and was
|
|
326
|
+
* scaled up to meet it; `1` means the two agree.
|
|
327
|
+
*/
|
|
328
|
+
scale: number;
|
|
329
|
+
/** Translation in frame pixels, applied after the scale. */
|
|
330
|
+
dx: number;
|
|
331
|
+
dy: number;
|
|
332
|
+
/** RMS of what the fit left over, across every edge of every frame, in pixels. */
|
|
333
|
+
rms: number;
|
|
334
|
+
/** What one uniform scale could not absorb on the union box: `scale·w − W`. */
|
|
335
|
+
residualWidth: number;
|
|
336
|
+
residualHeight: number;
|
|
337
|
+
/** Candidate aspect ÷ reference aspect − 1, on the union box. */
|
|
338
|
+
aspectError: number;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* The similarity transform mapping the candidate's content onto the reference's,
|
|
343
|
+
* by least squares over **every edge of every frame**.
|
|
344
|
+
*
|
|
345
|
+
* ## Why every frame, and not the union box
|
|
346
|
+
*
|
|
347
|
+
* The first version of this fitted one union box to the other, and that is still
|
|
348
|
+
* an extreme-value statistic — it just moved the fragility from an invisible quad
|
|
349
|
+
* corner to a visible pixel. Rung 5 demonstrates it: that candidate's runner has
|
|
350
|
+
* rigid limbs where the reference's fly off the body, so on a handful of frames it
|
|
351
|
+
* reaches about 1.5 px further left than anything in the reference does. Framing on
|
|
352
|
+
* the union let those few frames rescale all 322 of them, and the reported MAE went
|
|
353
|
+
* **4.35 → 19.7** against a viewport that is known to be right.
|
|
354
|
+
*
|
|
355
|
+
* Fitting over every frame gives each frame's four edges one vote out of `4N`, so
|
|
356
|
+
* a pose that overreaches on three frames moves the framing by about `3/N` of what
|
|
357
|
+
* it used to. What that overreach *should* do — and now does — is show up in the
|
|
358
|
+
* residual, where it reads as "your shot covers more ground than the reference's".
|
|
359
|
+
*
|
|
360
|
+
* ## The derivation
|
|
361
|
+
*
|
|
362
|
+
* With `T(p) = s·p + t` the optimal `t` always centres the two clouds, so with `x`
|
|
363
|
+
* the candidate's edge positions and `X` the reference's,
|
|
364
|
+
*
|
|
365
|
+
* s = [Σ(x−x̄)(X−X̄) + Σ(y−ȳ)(Y−Ȳ)] / [Σ(x−x̄)² + Σ(y−ȳ)²]
|
|
366
|
+
*
|
|
367
|
+
* x and y pooled into one sum because the scale is uniform, with their own means
|
|
368
|
+
* because the translation is not. The single-box case is this with `N = 1`.
|
|
369
|
+
*
|
|
370
|
+
* ## What it deliberately does NOT do: throw outliers away
|
|
371
|
+
*
|
|
372
|
+
* Four robust variants were written and measured against the five ladder
|
|
373
|
+
* candidates and the selftest fixture — sigma trimming, Tukey IRLS, a median
|
|
374
|
+
* estimator, and dropping whichever of the four edge kinds disagrees with the
|
|
375
|
+
* other three. None of them beat plain least squares across the set, and two were
|
|
376
|
+
* clearly worse on the one shot where a correct viewport is known (rung 5, whose
|
|
377
|
+
* author matched the reference's world box by hand): 8.60 trimmed and 13.4 IRLS
|
|
378
|
+
* against 12.5 plain, where the correct framing scores 4.35. The simplest
|
|
379
|
+
* estimator that no single frame can move is the one that ships.
|
|
380
|
+
*
|
|
381
|
+
* ## The floor, stated plainly
|
|
382
|
+
*
|
|
383
|
+
* This registers two shots by their **extent**, and when the candidate's
|
|
384
|
+
* silhouette genuinely differs the best fit of the extents is not the best
|
|
385
|
+
* alignment of the pictures. Measured on rung 5: at the viewport known to be
|
|
386
|
+
* right, the candidate's top edge sits 0.2 px inside the reference's, and the fit
|
|
387
|
+
* spends 0.1 % of scale and a quarter-pixel of offset absorbing it — which costs
|
|
388
|
+
* more than leaving it. That shot moves 1.35 MAE per 0.065 px of viewport, so a
|
|
389
|
+
* framing good to a third of a pixel is worth several MAE there and nothing at all
|
|
390
|
+
* on rung 4. The residual line says when this is happening (`union residual`
|
|
391
|
+
* larger than a pixel, or `rms` above one) and `--viewport` pins the box when an
|
|
392
|
+
* author knows better.
|
|
393
|
+
*
|
|
394
|
+
* ⚠️ What least squares cannot do is make two different shapes agree. If the
|
|
395
|
+
* candidate covers a different extent the fit splits the difference, and the
|
|
396
|
+
* leftover is reported as `residualWidth`/`residualHeight` and `rms` — computed
|
|
397
|
+
* over **every** edge, trimmed ones included — rather than being silently spent.
|
|
398
|
+
* That residual is the number that says "something is a different size, or is in
|
|
399
|
+
* one shot and not the other", which used to arrive disguised as MAE.
|
|
400
|
+
*/
|
|
401
|
+
export function fitFraming(pairs: BoxPair[]): FramingFit {
|
|
402
|
+
if (pairs.length === 0) throw new Error('fitFraming needs at least one frame to fit');
|
|
403
|
+
const xs: Array<[number, number]> = [];
|
|
404
|
+
const ys: Array<[number, number]> = [];
|
|
405
|
+
let candidate: ContentBox | null = null;
|
|
406
|
+
let reference: ContentBox | null = null;
|
|
407
|
+
for (const pair of pairs) {
|
|
408
|
+
xs.push([pair.candidate.left, pair.reference.left], [pair.candidate.right, pair.reference.right]);
|
|
409
|
+
ys.push([pair.candidate.top, pair.reference.top], [pair.candidate.bottom, pair.reference.bottom]);
|
|
410
|
+
candidate = unionBoxes(candidate, pair.candidate);
|
|
411
|
+
reference = unionBoxes(reference, pair.reference);
|
|
412
|
+
}
|
|
413
|
+
const mean = (v: Array<[number, number]>, i: 0 | 1): number => v.reduce((a, p) => a + p[i], 0) / v.length;
|
|
414
|
+
const xBar = mean(xs, 0);
|
|
415
|
+
const XBar = mean(xs, 1);
|
|
416
|
+
const yBar = mean(ys, 0);
|
|
417
|
+
const YBar = mean(ys, 1);
|
|
418
|
+
let numerator = 0;
|
|
419
|
+
let denominator = 0;
|
|
420
|
+
for (const [x, X] of xs) {
|
|
421
|
+
numerator += (x - xBar) * (X - XBar);
|
|
422
|
+
denominator += (x - xBar) ** 2;
|
|
423
|
+
}
|
|
424
|
+
for (const [y, Y] of ys) {
|
|
425
|
+
numerator += (y - yBar) * (Y - YBar);
|
|
426
|
+
denominator += (y - yBar) ** 2;
|
|
427
|
+
}
|
|
428
|
+
const scale = denominator > 0 ? numerator / denominator : 1;
|
|
429
|
+
const dx = XBar - scale * xBar;
|
|
430
|
+
const dy = YBar - scale * yBar;
|
|
431
|
+
let squared = 0;
|
|
432
|
+
for (const [x, X] of xs) squared += (scale * x + dx - X) ** 2;
|
|
433
|
+
for (const [y, Y] of ys) squared += (scale * y + dy - Y) ** 2;
|
|
434
|
+
|
|
435
|
+
const c = candidate as ContentBox;
|
|
436
|
+
const r = reference as ContentBox;
|
|
437
|
+
const candidateAspect = boxHeight(c) > 0 ? boxWidth(c) / boxHeight(c) : 0;
|
|
438
|
+
const referenceAspect = boxHeight(r) > 0 ? boxWidth(r) / boxHeight(r) : 0;
|
|
439
|
+
return {
|
|
440
|
+
candidate: c,
|
|
441
|
+
reference: r,
|
|
442
|
+
frames: pairs.length,
|
|
443
|
+
scale,
|
|
444
|
+
dx,
|
|
445
|
+
dy,
|
|
446
|
+
rms: Math.sqrt(squared / (xs.length + ys.length)),
|
|
447
|
+
residualWidth: scale * boxWidth(c) - boxWidth(r),
|
|
448
|
+
residualHeight: scale * boxHeight(c) - boxHeight(r),
|
|
449
|
+
aspectError: referenceAspect > 0 ? candidateAspect / referenceAspect - 1 : 0,
|
|
450
|
+
};
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
/**
|
|
454
|
+
* How far from the identity a fit may be and still be called settled, in pixels.
|
|
455
|
+
*
|
|
456
|
+
* A tenth of a pixel, because that is roughly the floor of the method: a content
|
|
457
|
+
* box read off a rendered grid is quantised, and the sub-pixel edge estimate
|
|
458
|
+
* recovers a fraction of that rather than all of it. Chasing below this is
|
|
459
|
+
* chasing the measurement, and the loop starts jittering instead of converging.
|
|
460
|
+
*/
|
|
461
|
+
export const SETTLED_PIXELS = 0.1;
|
|
462
|
+
|
|
463
|
+
/** Is this fit close enough to the identity that another pass would only add noise? */
|
|
464
|
+
export function fitIsSettled(fit: FramingFit): boolean {
|
|
465
|
+
return fitDistance(fit) < SETTLED_PIXELS;
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* How far applying this fit would move the content, in pixels: the worst of its
|
|
470
|
+
* four box corners.
|
|
471
|
+
*
|
|
472
|
+
* Not `|scale − 1|` and not `|t|` — either alone is misleading, because the
|
|
473
|
+
* translation is chosen to centre the boxes and therefore *cancels* part of the
|
|
474
|
+
* scale. What an author cares about, and what the loop should stop on, is how far
|
|
475
|
+
* anything actually moves.
|
|
476
|
+
*/
|
|
477
|
+
export function fitDistance(fit: FramingFit): number {
|
|
478
|
+
return cornerSpread(fit.candidate, (x, y) => [fit.scale * x + fit.dx - x, fit.scale * y + fit.dy - y]);
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* How far apart two passes' corrections are, in pixels — the same corner measure
|
|
483
|
+
* as `fitDistance`, applied to the difference between the two transforms.
|
|
484
|
+
*
|
|
485
|
+
* This is what tells a framing loop that it is **cycling** rather than
|
|
486
|
+
* converging. The loop's passes are not independent samples: each one is measured
|
|
487
|
+
* on the render the previous one produced, so when the fit has no fixed point the
|
|
488
|
+
* sequence falls into a repeating orbit instead of wandering. Rung 6 does exactly
|
|
489
|
+
* that with period 4 — passes 4–7 repeat as 8–11 and again as 12–15, agreeing to
|
|
490
|
+
* within 0.02 px — and a loop that cannot tell that apart from slow convergence
|
|
491
|
+
* spends every remaining pass re-measuring states it has already seen.
|
|
492
|
+
*
|
|
493
|
+
* The boxes come from `a`, because the two fits are measured against the same
|
|
494
|
+
* reference frames and it is the candidate's own extent that the correction moves.
|
|
495
|
+
*/
|
|
496
|
+
export function fitSeparation(a: FramingFit, b: FramingFit): number {
|
|
497
|
+
return cornerSpread(a.candidate, (x, y) => [
|
|
498
|
+
a.scale * x + a.dx - (b.scale * x + b.dx),
|
|
499
|
+
a.scale * y + a.dy - (b.scale * y + b.dy),
|
|
500
|
+
]);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** The worst displacement a transform applies over a box's four corners. */
|
|
504
|
+
function cornerSpread(box: ContentBox, displace: (x: number, y: number) => [number, number]): number {
|
|
505
|
+
const { left, top, right, bottom } = box;
|
|
506
|
+
let worst = 0;
|
|
507
|
+
for (const [x, y] of [
|
|
508
|
+
[left, top],
|
|
509
|
+
[right, top],
|
|
510
|
+
[left, bottom],
|
|
511
|
+
[right, bottom],
|
|
512
|
+
]) {
|
|
513
|
+
const [dx, dy] = displace(x, y);
|
|
514
|
+
worst = Math.max(worst, Math.hypot(dx, dy));
|
|
515
|
+
}
|
|
516
|
+
return worst;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
/**
|
|
520
|
+
* How far two passes' corrections may differ and still be called the same state.
|
|
521
|
+
*
|
|
522
|
+
* Half of `SETTLED_PIXELS`, and the gap it has to live in was measured rather than
|
|
523
|
+
* picked: on rung 6 two passes one period apart differ by **0.018 px** while two
|
|
524
|
+
* adjacent passes of the same orbit differ by **0.110 px**. A detector at 0.05 px
|
|
525
|
+
* separates those by a factor of three either way. Loose enough to fire late, and
|
|
526
|
+
* a late cycle report costs a pass; tight enough that it never fires on a loop
|
|
527
|
+
* that is still moving, and a false one would stop a converging fit short.
|
|
528
|
+
*/
|
|
529
|
+
export const CYCLE_PIXELS = SETTLED_PIXELS / 2;
|
|
530
|
+
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// the MAE-refined final pass
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* How far the MAE-refined pass may move a fitted box, in frame pixels.
|
|
537
|
+
*
|
|
538
|
+
* Two, because what it is there to recover is *one* pixel. The extent fit lands
|
|
539
|
+
* within a fraction of a pixel of its own optimum and its optimum is the wrong
|
|
540
|
+
* one by about that much (`fitFraming`, "the floor, stated plainly"), so the
|
|
541
|
+
* distance between "where the extents agree" and "where the pictures agree" is a
|
|
542
|
+
* pixel or two and never more — measured across the committed corpus, every
|
|
543
|
+
* offset the pass applies is `(0, ±1)`, `(±1, 0)`, `(±1, ±1)`, `(−1, 2)` or
|
|
544
|
+
* `(−2, ≤1)`, and none of the 86 sets wants a corner of the window. A wider
|
|
545
|
+
* window would start being able to absorb a real displacement, which is the one
|
|
546
|
+
* thing the framing must not do.
|
|
547
|
+
*/
|
|
548
|
+
export const REFINE_RADIUS = 2;
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* How much of the figure a shift must buy before it is allowed to move the box,
|
|
552
|
+
* as a fraction of the set's own reference-denominator MAE...
|
|
553
|
+
*
|
|
554
|
+
* ...and `REFINE_MIN_GAIN_MAE` beside it, absolutely, because a ratio alone means
|
|
555
|
+
* nothing on a shot whose MAE is already 3.
|
|
556
|
+
*
|
|
557
|
+
* ## What the corpus says, and what the threshold is therefore for
|
|
558
|
+
*
|
|
559
|
+
* Measured over the 86 compared sets of the committed runs: the best offset in
|
|
560
|
+
* ±2 px is the **exact identity** on 52 of them — every set framed by
|
|
561
|
+
* `frames.json`'s own box among them, which is the `idle`-class control issue #146
|
|
562
|
+
* asked for — and on the other 34 the gain runs **0.9 % … 30.9 %** (0.40 … 14.34
|
|
563
|
+
* MAE), clustering at 3 % and above with two lone readings at 1.0 % and 0.9 %.
|
|
564
|
+
*
|
|
565
|
+
* ⚠️ So this is **not** a threshold separating two measured populations, and it
|
|
566
|
+
* must not be quoted as one: it is a floor under a continuum. What makes a low
|
|
567
|
+
* floor the right shape here is that the pass minimises the reported figure
|
|
568
|
+
* *itself*, so the cost of applying a marginal offset is bounded by the threshold
|
|
569
|
+
* — a hundredth of the figure — while the cost of refusing one is a constant pixel
|
|
570
|
+
* left inside a number an author reads as motion. Erring towards applying is the
|
|
571
|
+
* cheap direction, and the report prints what was applied and what it was worth
|
|
572
|
+
* either way.
|
|
573
|
+
*/
|
|
574
|
+
export const REFINE_MIN_GAIN = 0.01;
|
|
575
|
+
/** ...and how much of the figure that is, in MAE points, whatever the ratio says. */
|
|
576
|
+
export const REFINE_MIN_GAIN_MAE = 0.1;
|
|
577
|
+
|
|
578
|
+
/** What the best whole-pixel offset in a window is worth, over a set's frames. */
|
|
579
|
+
export interface OffsetGain {
|
|
580
|
+
dx: number;
|
|
581
|
+
dy: number;
|
|
582
|
+
/** Mean reference-denominator MAE at the identity — the figure as it stands. */
|
|
583
|
+
identity: number;
|
|
584
|
+
/** ...and at `dx, dy`, which is the same figure with one constant taken out. */
|
|
585
|
+
best: number;
|
|
586
|
+
/** How far the search looked, and how many frames it pooled. */
|
|
587
|
+
radius: number;
|
|
588
|
+
frames: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The set's reference-denominator MAE at every whole-pixel offset in a window,
|
|
593
|
+
* accumulated one frame at a time.
|
|
594
|
+
*
|
|
595
|
+
* ## What this is for: the constant pixel a settled fit still leaves
|
|
596
|
+
*
|
|
597
|
+
* `fitFraming` registers two shots by their **extent**, and a shot whose
|
|
598
|
+
* silhouette genuinely differs has its best extent fit about a third of a pixel
|
|
599
|
+
* from its best alignment. Measured on the spineboy candidates (issue #146), that
|
|
600
|
+
* floor is not a rounding detail: a **constant** translation of one or two pixels
|
|
601
|
+
* is worth 12 % of `death`'s headline MAE and up to 30 % of a fitted set's,
|
|
602
|
+
* while the genuinely per-frame remainder is a tenth of it. A loop reading those
|
|
603
|
+
* numbers as motion is reading a framing offset.
|
|
604
|
+
*
|
|
605
|
+
* ## Why the objective is the reference denominator
|
|
606
|
+
*
|
|
607
|
+
* Because it is the figure the report tells an author to optimise against
|
|
608
|
+
* (`FrameCheck.maeReference`), and because it is the one the candidate cannot
|
|
609
|
+
* grow: minimising the union MAE would let a shift that drags more cheap pixels
|
|
610
|
+
* into the denominator win, which is issue #119 arriving by another door.
|
|
611
|
+
*
|
|
612
|
+
* ## Why whole pixels, and why a plate shift rather than a re-render
|
|
613
|
+
*
|
|
614
|
+
* The projector is `px = (wx − minX)·k`, so moving the box by exactly `dx/k`
|
|
615
|
+
* moves every sample point by exactly one pixel and samples the same texels —
|
|
616
|
+
* the render at the shifted box **is** the render shifted, but for content
|
|
617
|
+
* outside the old frame. That makes a 25-offset search cost one render per frame
|
|
618
|
+
* instead of 25, and it is why the window is whole pixels: a sub-pixel offset
|
|
619
|
+
* changes the resampling, so nothing could be searched without re-rendering it.
|
|
620
|
+
* The offset that wins is then applied to the viewport and everything the report
|
|
621
|
+
* prints is measured on a real render at a real box.
|
|
622
|
+
*/
|
|
623
|
+
export class OffsetScan {
|
|
624
|
+
readonly radius: number;
|
|
625
|
+
private readonly span: number;
|
|
626
|
+
/** Σ over frames of the per-frame reference-denominator MAE, per offset. */
|
|
627
|
+
private readonly sums: Float64Array;
|
|
628
|
+
private counted = 0;
|
|
629
|
+
|
|
630
|
+
constructor(radius: number = REFINE_RADIUS) {
|
|
631
|
+
this.radius = Math.max(0, Math.round(radius));
|
|
632
|
+
this.span = this.radius * 2 + 1;
|
|
633
|
+
this.sums = new Float64Array(this.span * this.span);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* One frame: the candidate as it was rendered, its own coverage mask, and the
|
|
638
|
+
* reference as it is on disk.
|
|
639
|
+
*
|
|
640
|
+
* ⭐ The figure accumulated here is `FrameCheck.maeReference` **exactly** —
|
|
641
|
+
* the difference summed over the pixels either side covers, over the count of
|
|
642
|
+
* the ones the *reference* covers — because the line the report prints and the
|
|
643
|
+
* line this pass minimises have to be the same line. Taking the numerator over
|
|
644
|
+
* the reference's pixels alone would be a near neighbour of it and a different
|
|
645
|
+
* number, and a "54.31 → 48.47" that did not match the MAE line under it would
|
|
646
|
+
* be two measurements wearing one name.
|
|
647
|
+
*
|
|
648
|
+
* A frame the reference drew nothing in counts as zero rather than being
|
|
649
|
+
* skipped, which is again what `checkOneFrame` does with it: an empty
|
|
650
|
+
* denominator is not a measurement, and dropping the frame instead would divide
|
|
651
|
+
* this pass's mean by a different frame count than the report's.
|
|
652
|
+
*/
|
|
653
|
+
add(candidate: Plate, coverage: Uint8Array, reference: Plate, background: RGBA): void {
|
|
654
|
+
const { width, height } = reference;
|
|
655
|
+
this.counted++;
|
|
656
|
+
// The reference's own drawn pixels, by the predicate `checkOneFrame` uses.
|
|
657
|
+
const drawn = new Uint8Array(width * height);
|
|
658
|
+
const drawnAt: number[] = [];
|
|
659
|
+
for (let y = 0; y < height; y++) {
|
|
660
|
+
for (let x = 0; x < width; x++) {
|
|
661
|
+
if (!isContent(reference, x, y, background)) continue;
|
|
662
|
+
const at = y * width + x;
|
|
663
|
+
drawn[at] = 1;
|
|
664
|
+
drawnAt.push(at);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (drawnAt.length === 0) return;
|
|
668
|
+
// ...and the candidate's, which move with the offset. Held as a list because
|
|
669
|
+
// the second sum below walks them in candidate coordinates.
|
|
670
|
+
const inkAt: number[] = [];
|
|
671
|
+
for (let at = 0; at < coverage.length; at++) if (coverage[at] === 1) inkAt.push(at);
|
|
672
|
+
|
|
673
|
+
const a = candidate.data;
|
|
674
|
+
const b = reference.data;
|
|
675
|
+
const bg = background;
|
|
676
|
+
/** |candidate at `from` − reference at `to`|, mean over RGB, either off-grid. */
|
|
677
|
+
const delta = (from: number, to: number): number => {
|
|
678
|
+
const j = to * 4;
|
|
679
|
+
const i = from * 4;
|
|
680
|
+
const ar = from < 0 ? bg[0] : a[i];
|
|
681
|
+
const ag = from < 0 ? bg[1] : a[i + 1];
|
|
682
|
+
const ab = from < 0 ? bg[2] : a[i + 2];
|
|
683
|
+
const br = to < 0 ? bg[0] : b[j];
|
|
684
|
+
const bgc = to < 0 ? bg[1] : b[j + 1];
|
|
685
|
+
const bb = to < 0 ? bg[2] : b[j + 2];
|
|
686
|
+
return (Math.abs(ar - br) + Math.abs(ag - bgc) + Math.abs(ab - bb)) / 3;
|
|
687
|
+
};
|
|
688
|
+
for (let dy = -this.radius; dy <= this.radius; dy++) {
|
|
689
|
+
for (let dx = -this.radius; dx <= this.radius; dx++) {
|
|
690
|
+
let sum = 0;
|
|
691
|
+
// Every reference-drawn pixel, against whatever the shifted candidate puts
|
|
692
|
+
// there — background where the shift pulls it off its own grid, which is
|
|
693
|
+
// what the render at the shifted box would draw.
|
|
694
|
+
for (let i = 0; i < drawnAt.length; i++) {
|
|
695
|
+
const at = drawnAt[i];
|
|
696
|
+
const x = at % width;
|
|
697
|
+
const y = (at - x) / width;
|
|
698
|
+
const sx = x - dx;
|
|
699
|
+
const sy = y - dy;
|
|
700
|
+
sum += delta(sx < 0 || sy < 0 || sx >= width || sy >= height ? -1 : sy * width + sx, at);
|
|
701
|
+
}
|
|
702
|
+
// ...and every pixel the candidate covers that the reference does not,
|
|
703
|
+
// which is the other half of the union. A pixel the shift carries out of
|
|
704
|
+
// the frame is clipped there, so it leaves the sum entirely.
|
|
705
|
+
for (let i = 0; i < inkAt.length; i++) {
|
|
706
|
+
const at = inkAt[i];
|
|
707
|
+
const x = at % width;
|
|
708
|
+
const y = (at - x) / width;
|
|
709
|
+
const tx = x + dx;
|
|
710
|
+
const ty = y + dy;
|
|
711
|
+
if (tx < 0 || ty < 0 || tx >= width || ty >= height) continue;
|
|
712
|
+
const to = ty * width + tx;
|
|
713
|
+
if (drawn[to] === 1) continue;
|
|
714
|
+
sum += delta(at, to);
|
|
715
|
+
}
|
|
716
|
+
this.sums[this.index(dx, dy)] += sum / drawnAt.length;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* The offset with the lowest figure, or `null` when no frame carried reference
|
|
723
|
+
* ink.
|
|
724
|
+
*
|
|
725
|
+
* Ties go to the smaller displacement and then to the lower `dy`, `dx`, so the
|
|
726
|
+
* answer is a function of the pixels and not of the iteration order —
|
|
727
|
+
* `A18_DETERMINISTIC_EMIT`'s discipline applied to a measurement. The identity
|
|
728
|
+
* therefore wins any tie it is in, which is what makes "no constant offset
|
|
729
|
+
* here" a reachable answer rather than an arbitrary one.
|
|
730
|
+
*/
|
|
731
|
+
best(): OffsetGain | null {
|
|
732
|
+
if (this.counted === 0) return null;
|
|
733
|
+
let bestDx = 0;
|
|
734
|
+
let bestDy = 0;
|
|
735
|
+
let bestSum = this.sums[this.index(0, 0)];
|
|
736
|
+
for (let dy = -this.radius; dy <= this.radius; dy++) {
|
|
737
|
+
for (let dx = -this.radius; dx <= this.radius; dx++) {
|
|
738
|
+
const sum = this.sums[this.index(dx, dy)];
|
|
739
|
+
if (sum > bestSum) continue;
|
|
740
|
+
if (sum === bestSum && !closerToHome(dx, dy, bestDx, bestDy)) continue;
|
|
741
|
+
bestSum = sum;
|
|
742
|
+
bestDx = dx;
|
|
743
|
+
bestDy = dy;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
dx: bestDx,
|
|
748
|
+
dy: bestDy,
|
|
749
|
+
identity: this.sums[this.index(0, 0)] / this.counted,
|
|
750
|
+
best: bestSum / this.counted,
|
|
751
|
+
radius: this.radius,
|
|
752
|
+
frames: this.counted,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private index(dx: number, dy: number): number {
|
|
757
|
+
return (dy + this.radius) * this.span + (dx + this.radius);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Is `(dx, dy)` the smaller displacement, ties broken by `dy` then `dx`? */
|
|
762
|
+
function closerToHome(dx: number, dy: number, atX: number, atY: number): boolean {
|
|
763
|
+
const mine = dx * dx + dy * dy;
|
|
764
|
+
const theirs = atX * atX + atY * atY;
|
|
765
|
+
if (mine !== theirs) return mine < theirs;
|
|
766
|
+
if (dy !== atY) return dy < atY;
|
|
767
|
+
return dx < atX;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Is this offset worth moving a box for? See `REFINE_MIN_GAIN`. */
|
|
771
|
+
export function offsetIsWorthApplying(gain: OffsetGain): boolean {
|
|
772
|
+
if (gain.dx === 0 && gain.dy === 0) return false;
|
|
773
|
+
const won = gain.identity - gain.best;
|
|
774
|
+
return won >= REFINE_MIN_GAIN_MAE && gain.identity > 0 && won / gain.identity >= REFINE_MIN_GAIN;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* The same box moved by whole frame pixels, at the same scale.
|
|
779
|
+
*
|
|
780
|
+
* `applyFit` with a scale of exactly 1, written out rather than routed through
|
|
781
|
+
* it, because the refined pass changes no scale at all and a fit-shaped argument
|
|
782
|
+
* with `scale: 1` in it would invite one.
|
|
783
|
+
*/
|
|
784
|
+
export function shiftViewport(
|
|
785
|
+
viewport: Viewport,
|
|
786
|
+
dx: number,
|
|
787
|
+
dy: number,
|
|
788
|
+
pixelWidth: number,
|
|
789
|
+
pixelHeight: number,
|
|
790
|
+
): Viewport {
|
|
791
|
+
const minX = viewport.minX - dx / viewport.scale;
|
|
792
|
+
const maxY = viewport.maxY + dy / viewport.scale;
|
|
793
|
+
const width = pixelWidth / viewport.scale;
|
|
794
|
+
const height = pixelHeight / viewport.scale;
|
|
795
|
+
return viewportOfSize(minX, maxY - height, width, height, viewport.scale, pixelWidth, pixelHeight);
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/**
|
|
799
|
+
* The viewport that renders the candidate where the fit says it belongs.
|
|
800
|
+
*
|
|
801
|
+
* `projector` is `px = (wx − minX)·k`, `py = (maxY − wy)·k`, so asking for
|
|
802
|
+
* `px' = s·px + dx` is asking for `k' = s·k` and an origin moved by `dx/k'`. The
|
|
803
|
+
* pixel size is the frames' own and is never re-derived from the box — rounding it
|
|
804
|
+
* a second time would shift every measurement by up to half a pixel, which on
|
|
805
|
+
* these shots is louder than most of what is being measured.
|
|
806
|
+
*/
|
|
807
|
+
export function applyFit(
|
|
808
|
+
viewport: Viewport,
|
|
809
|
+
fit: FramingFit,
|
|
810
|
+
pixelWidth: number,
|
|
811
|
+
pixelHeight: number,
|
|
812
|
+
): Viewport {
|
|
813
|
+
const scale = viewport.scale * fit.scale;
|
|
814
|
+
const minX = viewport.minX - fit.dx / scale;
|
|
815
|
+
const maxY = viewport.maxY + fit.dy / scale;
|
|
816
|
+
const width = pixelWidth / scale;
|
|
817
|
+
const height = pixelHeight / scale;
|
|
818
|
+
return viewportOfSize(minX, maxY - height, width, height, scale, pixelWidth, pixelHeight);
|
|
819
|
+
}
|