rig-c 0.0.0-stage → 2.21.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/.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 +1191 -0
- package/src/meshquality.ts +2051 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1444 -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/check.ts
ADDED
|
@@ -0,0 +1,4303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rigc check — measure a candidate against reference **frames**, never against
|
|
3
|
+
* the reference file.
|
|
4
|
+
*
|
|
5
|
+
* ## The hole this closes
|
|
6
|
+
*
|
|
7
|
+
* The gate is a *validity* gate. It parses the skeleton, steps every animation,
|
|
8
|
+
* and refuses anything degenerate — and it has no opinion whatever about whether
|
|
9
|
+
* the animation is the one in the frames. Three honest ladder runs produced zero
|
|
10
|
+
* validator FAILs between them, and one of those runs shipped a build in which
|
|
11
|
+
* **every easing in the file was reversed** and came back green. Both authors
|
|
12
|
+
* closed the loop the same way, with a script they wrote themselves: pose the
|
|
13
|
+
* candidate with `spine-core`, and compare it against what they had measured off
|
|
14
|
+
* the pictures. This is that script, promoted, so the loop is
|
|
15
|
+
*
|
|
16
|
+
* build → validate → check against frames → fix
|
|
17
|
+
*
|
|
18
|
+
* and the last step is a command rather than something each author reinvents.
|
|
19
|
+
*
|
|
20
|
+
* ## 🔒 The invariant: this never reads the answer
|
|
21
|
+
*
|
|
22
|
+
* `check` opens exactly two things — **the candidate** (its skeleton, its atlas
|
|
23
|
+
* and the atlas pages) and **PNG frames** under `--frames`, plus the
|
|
24
|
+
* `frames.json` sidecar beside them. It has no code path that names
|
|
25
|
+
* `examples/`, an `export/` directory, a rung or a reference skeleton, and every
|
|
26
|
+
* read on the reference side goes through `readFrameFile`, which refuses a path
|
|
27
|
+
* that escapes `--frames` or that is neither a `.png` nor the sidecar.
|
|
28
|
+
*
|
|
29
|
+
* That is not fastidiousness. `docs/LADDER.md`'s honesty rule is the only thing
|
|
30
|
+
* that makes a rung's number mean anything, and a fidelity tool that quietly
|
|
31
|
+
* loaded the reference JSON would convert every future run from authoring into
|
|
32
|
+
* transcription without anybody noticing — the exact failure that is hardest to
|
|
33
|
+
* detect after the fact.
|
|
34
|
+
*
|
|
35
|
+
* ## What it measures
|
|
36
|
+
*
|
|
37
|
+
* Per animation, per frame:
|
|
38
|
+
*
|
|
39
|
+
* - **The framing** — where the candidate's drawn pixels sit against the
|
|
40
|
+
* reference's drawn pixels, as a scale ratio and a residual. It is reported
|
|
41
|
+
* first because it is upstream of everything else: get it wrong and every
|
|
42
|
+
* number below carries the error, disguised as motion (issue #34).
|
|
43
|
+
* - **MAE over the union alpha** — the mean absolute RGB difference between the
|
|
44
|
+
* candidate composited over the frames' background and the reference frame,
|
|
45
|
+
* averaged over the pixels either side covers. Over the union rather than the
|
|
46
|
+
* whole frame because most of a frame is background on both sides, and
|
|
47
|
+
* averaging that in makes every number small and every difference between
|
|
48
|
+
* numbers smaller.
|
|
49
|
+
* - **Per-frame change** — how much each side moved since **its own** previous
|
|
50
|
+
* frame, and whether those two agree. The only measure here that looks at a
|
|
51
|
+
* relation between two frames rather than at one, and the only one that can see
|
|
52
|
+
* a held pose that is not held or a one-frame event that never fired — see
|
|
53
|
+
* `FrameChange`.
|
|
54
|
+
* - **Per-slot tracking** — where each of the candidate's own slots landed
|
|
55
|
+
* against the reference frame. This is the part an author acts on: MAE says
|
|
56
|
+
* *how wrong*, a slot's drift says *which part, which way, how far*.
|
|
57
|
+
*
|
|
58
|
+
* ⚠️ Both of the last two are bounded by what a picture can attribute, and
|
|
59
|
+
* `src/slots.ts` owns that judgement: a slot the reference merged into a
|
|
60
|
+
* neighbour is template-matched against its own pixels rather than guessed at,
|
|
61
|
+
* and a slot nothing in its search radius matches comes back as **no match**
|
|
62
|
+
* rather than as a number. A drift printed beside the wrong part is worse than a
|
|
63
|
+
* blank, because it is actionable and wrong.
|
|
64
|
+
*/
|
|
65
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
66
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
67
|
+
import {
|
|
68
|
+
BACKGROUND,
|
|
69
|
+
frameGeometry,
|
|
70
|
+
PROTOCOL_FPS,
|
|
71
|
+
loadCandidate,
|
|
72
|
+
renderFrame,
|
|
73
|
+
sampleAnimation,
|
|
74
|
+
sampleSetupPose,
|
|
75
|
+
substituteTexture,
|
|
76
|
+
textureSubstitutionFromText,
|
|
77
|
+
trimmedUnionBounds,
|
|
78
|
+
viewportOfSize,
|
|
79
|
+
PAD,
|
|
80
|
+
FRAMES_SIDECAR,
|
|
81
|
+
FRAMES_SPEC,
|
|
82
|
+
nonFinitePoseOf,
|
|
83
|
+
unframeableSentence,
|
|
84
|
+
UnframeablePoseError,
|
|
85
|
+
refuseUnchosen,
|
|
86
|
+
throughPoser,
|
|
87
|
+
SHEET_COLUMNS,
|
|
88
|
+
SHEET_FILE,
|
|
89
|
+
SHEET_GAP,
|
|
90
|
+
type Footprint,
|
|
91
|
+
type Frame,
|
|
92
|
+
type FramesSidecar,
|
|
93
|
+
type FrameSet,
|
|
94
|
+
type MakeCorePoser,
|
|
95
|
+
type PoseOptions,
|
|
96
|
+
type Poser,
|
|
97
|
+
type PoserName,
|
|
98
|
+
type TextureSubstitution,
|
|
99
|
+
type Viewport,
|
|
100
|
+
} from './render_shared.ts';
|
|
101
|
+
// The one name here that is the runtime's: a candidate's pages, read off the shape spine-core's loader returns.
|
|
102
|
+
import type { Posable } from './render.ts';
|
|
103
|
+
import {
|
|
104
|
+
applyFit,
|
|
105
|
+
boxHeight,
|
|
106
|
+
boxWidth,
|
|
107
|
+
contentBoxOfPlate,
|
|
108
|
+
ContrastHistogram,
|
|
109
|
+
fitDistance,
|
|
110
|
+
fitFraming,
|
|
111
|
+
fitIsSettled,
|
|
112
|
+
fitSeparation,
|
|
113
|
+
frameContentBox,
|
|
114
|
+
offsetIsWorthApplying,
|
|
115
|
+
OffsetScan,
|
|
116
|
+
shiftViewport,
|
|
117
|
+
unionBoxes,
|
|
118
|
+
isContent,
|
|
119
|
+
BACKGROUND_TOLERANCE,
|
|
120
|
+
CYCLE_PIXELS,
|
|
121
|
+
REFINE_MIN_GAIN,
|
|
122
|
+
REFINE_MIN_GAIN_MAE,
|
|
123
|
+
REFINE_RADIUS,
|
|
124
|
+
type BoxPair,
|
|
125
|
+
type ContentBox,
|
|
126
|
+
type FramingFit,
|
|
127
|
+
type OffsetGain,
|
|
128
|
+
} from './framing.ts';
|
|
129
|
+
import {
|
|
130
|
+
componentField,
|
|
131
|
+
driftBound,
|
|
132
|
+
isAttributable,
|
|
133
|
+
matchSlots,
|
|
134
|
+
searchRadius,
|
|
135
|
+
SUBPIXEL_CLAMP,
|
|
136
|
+
type SlotTrack,
|
|
137
|
+
} from './slots.ts';
|
|
138
|
+
import { chainsOf, type BoneChain } from './chains.ts';
|
|
139
|
+
import { readPlate, type Plate, type RGBA } from '../tools/plate.ts';
|
|
140
|
+
import { GLYPH_H, textWidth } from '../tools/font5x7.ts';
|
|
141
|
+
|
|
142
|
+
export {
|
|
143
|
+
componentField,
|
|
144
|
+
componentsOf,
|
|
145
|
+
driftBound,
|
|
146
|
+
matchSlots,
|
|
147
|
+
searchRadius,
|
|
148
|
+
SUBPIXEL_CLAMP,
|
|
149
|
+
type Component,
|
|
150
|
+
type ComponentField,
|
|
151
|
+
type MatchMethod,
|
|
152
|
+
type SlotTrack,
|
|
153
|
+
} from './slots.ts';
|
|
154
|
+
export { chainsOf, chainBySlot, type BoneChain } from './chains.ts';
|
|
155
|
+
export type { BoxPair, ContentBox, FramingFit, OffsetGain } from './framing.ts';
|
|
156
|
+
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
// the reference side — frames only, and mechanically so
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* The honesty invariant, as a function: this path is a frame under `--frames`.
|
|
163
|
+
*
|
|
164
|
+
* Every reference-side read in this module goes through it, which is what makes
|
|
165
|
+
* "`check` reads only PNG frames" a property of the code rather than a claim in
|
|
166
|
+
* a comment — a path that climbs out of the frames directory, or that is neither
|
|
167
|
+
* a PNG nor the sidecar, throws with both paths named. It is exported so the
|
|
168
|
+
* selftest can make it fire: an invariant nobody has seen refuse anything is not
|
|
169
|
+
* an invariant.
|
|
170
|
+
*/
|
|
171
|
+
export function assertFrameReadable(framesRoot: string, path: string): void {
|
|
172
|
+
const abs = resolve(path);
|
|
173
|
+
const inside = relative(resolve(framesRoot), abs);
|
|
174
|
+
if (inside === '' || inside.startsWith('..') || isAbsolute(inside)) {
|
|
175
|
+
throw new CheckError(`${abs} is outside --frames ${resolve(framesRoot)}; check reads frames and nothing else`);
|
|
176
|
+
}
|
|
177
|
+
const name = basename(abs);
|
|
178
|
+
if (!name.endsWith('.png') && name !== FRAMES_SIDECAR) {
|
|
179
|
+
throw new CheckError(
|
|
180
|
+
`${abs} is neither a .png frame nor ${FRAMES_SIDECAR}; check never reads a reference skeleton — see src/check.ts`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function readFrameFile(framesRoot: string, path: string): Buffer {
|
|
186
|
+
assertFrameReadable(framesRoot, path);
|
|
187
|
+
return readFileSync(resolve(path));
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export class CheckError extends Error {}
|
|
191
|
+
|
|
192
|
+
/** Where a frames directory's sidecar is, and which of its sets `--frames` selected. */
|
|
193
|
+
interface Located {
|
|
194
|
+
/** The skeleton root — where `frames.json` sits. */
|
|
195
|
+
root: string;
|
|
196
|
+
sidecar: FramesSidecar | null;
|
|
197
|
+
/** Set directories to compare; empty means "every set in the sidecar". */
|
|
198
|
+
only: string[];
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Resolve `--frames <dir>`: either a skeleton root holding the sidecar, or one
|
|
203
|
+
* animation directory inside one.
|
|
204
|
+
*/
|
|
205
|
+
export function locateFrames(framesDir: string): Located {
|
|
206
|
+
const dir = resolve(framesDir);
|
|
207
|
+
if (!existsSync(dir)) throw new CheckError(`no frames directory at ${dir}`);
|
|
208
|
+
if (existsSync(join(dir, FRAMES_SIDECAR))) {
|
|
209
|
+
return { root: dir, sidecar: readSidecar(dir), only: [] };
|
|
210
|
+
}
|
|
211
|
+
const parent = dirname(dir);
|
|
212
|
+
if (existsSync(join(parent, FRAMES_SIDECAR))) {
|
|
213
|
+
const sidecar = readSidecar(parent);
|
|
214
|
+
const name = basename(dir);
|
|
215
|
+
if (sidecar && sidecar.sets.some((s) => s.dir === name)) {
|
|
216
|
+
return { root: parent, sidecar, only: [name] };
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return { root: dir, sidecar: null, only: [] };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function readSidecar(root: string): FramesSidecar | null {
|
|
223
|
+
const raw = readFrameFile(root, join(root, FRAMES_SIDECAR)).toString('utf8');
|
|
224
|
+
const parsed: unknown = JSON.parse(raw);
|
|
225
|
+
if (typeof parsed !== 'object' || parsed === null) return null;
|
|
226
|
+
// Before the spec, because it is the more specific answer: a `check --out`
|
|
227
|
+
// directory carries this build's own spec, and what is wrong with it is not
|
|
228
|
+
// its version but what it is a picture OF — see `COMPARISON_FIELD`.
|
|
229
|
+
if (Object.prototype.hasOwnProperty.call(parsed, COMPARISON_FIELD)) {
|
|
230
|
+
throw new CheckError(
|
|
231
|
+
`${join(root, FRAMES_SIDECAR)} carries ${JSON.stringify(COMPARISON_FIELD)}: it was written by \`rigc check ` +
|
|
232
|
+
'--out`, and a check\'s pictures are a comparison, not a reference frame set — each one holds a reference ' +
|
|
233
|
+
'pane, but the file is four panes and a table. Point --frames at the frames that comparison was made ' +
|
|
234
|
+
`against, which its ${JSON.stringify(COMPARISON_FIELD)}.frames names.`,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
const sidecar = parsed as FramesSidecar;
|
|
238
|
+
if (sidecar.spec !== FRAMES_SPEC) {
|
|
239
|
+
throw new CheckError(
|
|
240
|
+
`${join(root, FRAMES_SIDECAR)} declares spec ${JSON.stringify(sidecar.spec)}; this build reads ${FRAMES_SPEC}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
return sidecar;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** The `f0000.png` frames in a directory, by index, in index order. */
|
|
247
|
+
function framesOnDisk(root: string, dir: string): Array<{ index: number; file: string }> {
|
|
248
|
+
const abs = join(root, dir);
|
|
249
|
+
if (!existsSync(abs)) throw new CheckError(`no frame directory at ${abs}`);
|
|
250
|
+
const out: Array<{ index: number; file: string }> = [];
|
|
251
|
+
for (const name of readdirSync(abs)) {
|
|
252
|
+
const m = /^f(\d+)\.png$/.exec(name);
|
|
253
|
+
if (!m) continue;
|
|
254
|
+
out.push({ index: Number(m[1]), file: join(abs, name) });
|
|
255
|
+
}
|
|
256
|
+
out.sort((a, b) => a.index - b.index);
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ---------------------------------------------------------------------------
|
|
261
|
+
// the measures
|
|
262
|
+
// ---------------------------------------------------------------------------
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* How much a frame moved since the frame before it — on **each side separately**.
|
|
266
|
+
*
|
|
267
|
+
* ## Why this is not the MAE again
|
|
268
|
+
*
|
|
269
|
+
* Every other measure here compares the candidate against the reference *at one
|
|
270
|
+
* moment*. This one compares each side against **itself** a frame earlier, and
|
|
271
|
+
* then compares those two numbers. What that catches is a class of defect the
|
|
272
|
+
* aggregate MAE is structurally blind to, because it is small in every single
|
|
273
|
+
* frame and wrong in the relationship between them:
|
|
274
|
+
*
|
|
275
|
+
* - **A held pose that is not held.** Rung 6's reference is pixel-identical across
|
|
276
|
+
* f64–f67. A greedy key reduction had sloped a line through that plateau —
|
|
277
|
+
* legal under its own per-key tolerance, invisible to `validate`, invisible to
|
|
278
|
+
* `diff`, and worth so little MAE per frame that nothing flagged it. Re-rendering
|
|
279
|
+
* the candidate and diffing **its own** f67 against **its own** f68 showed 91 px
|
|
280
|
+
* moving where the reference moves 3.
|
|
281
|
+
* - **A one-frame event that never fires.** The same run's tracker reveal landed a
|
|
282
|
+
* fraction of a millisecond past the animation's last sample, so it never
|
|
283
|
+
* happened. `diff` read `animations.deform` and `draw_order` as matching, the
|
|
284
|
+
* gate was green, and only looking at the last two frames found it.
|
|
285
|
+
*
|
|
286
|
+
* Both were found by that run building its own render-diff outside the tool
|
|
287
|
+
* (`bench/runs/2026-08-23-rung6-1/LOOP.md` §10, issue #53). `check` has both frame
|
|
288
|
+
* sequences in hand already, so it is the tool's job and not the author's.
|
|
289
|
+
*
|
|
290
|
+
* ⚠️ Only between **adjacent** frames. A set that commits stills rather than every
|
|
291
|
+
* frame — rung 2's contact sheets ship `f0000` and `f0310` — has no frame-to-frame
|
|
292
|
+
* delta to report, and the difference between two frames 310 apart is not one. That
|
|
293
|
+
* is `null` here rather than a number about the wrong thing.
|
|
294
|
+
*/
|
|
295
|
+
export interface FrameChange {
|
|
296
|
+
/** The frame this one is measured against; always `index - 1`. */
|
|
297
|
+
previous: number;
|
|
298
|
+
/** Pixels the candidate moved since its own previous frame. */
|
|
299
|
+
candidate: number;
|
|
300
|
+
/** Pixels the reference moved since its own previous frame. */
|
|
301
|
+
reference: number;
|
|
302
|
+
/** The same two as a mean absolute RGB difference over the whole frame, 0..255. */
|
|
303
|
+
candidateMae: number;
|
|
304
|
+
referenceMae: number;
|
|
305
|
+
/**
|
|
306
|
+
* How the two compare — see `CHANGE_RATIO`.
|
|
307
|
+
*
|
|
308
|
+
* `moves` means the candidate changed materially more than the reference did
|
|
309
|
+
* here, `holds` materially less. Both are diagnoses the per-frame MAE cannot
|
|
310
|
+
* give: a frame that is merely off by a constant offset agrees on this measure.
|
|
311
|
+
*/
|
|
312
|
+
verdict: 'agrees' | 'moves' | 'holds';
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* How much of a difference is the **texture** rather than the rig — the
|
|
317
|
+
* decomposition `--texture-from` measures, and the hole issue #171 filed.
|
|
318
|
+
*
|
|
319
|
+
* ## What was unattributed, and what it was worth
|
|
320
|
+
*
|
|
321
|
+
* The reference frames are rendered through the example's own **packed** atlas,
|
|
322
|
+
* which may carry a `scale:` line — the ladder's examples are packed at 0.4 and
|
|
323
|
+
* 0.5 — while a candidate samples the art at its own resolution. That stays true
|
|
324
|
+
* of a `--pack`ed candidate (issue #4): rigc's packer is lossless and writes no
|
|
325
|
+
* `scale:` line, so it rearranges texels without resampling one and the constant
|
|
326
|
+
* measured here is unchanged by it.
|
|
327
|
+
* Every edge of every part is then filtered from a different source.
|
|
328
|
+
* That difference is a constant of the *pipeline*: it is invisible to the content
|
|
329
|
+
* box, to the fit residual and to the whole-pixel refinement (a resampling
|
|
330
|
+
* difference is not an offset), and **no key an author writes can move it**.
|
|
331
|
+
*
|
|
332
|
+
* It was measured twice by hand before it was measured here — rung 3 read MAE
|
|
333
|
+
* 6.13/6.01 from the loose art against 2.25/2.30 through the supplied pack, and
|
|
334
|
+
* rung 5's re-climb found the same mechanism — so about two thirds of those
|
|
335
|
+
* figures was texture. Nothing in the report said so, which is the part that
|
|
336
|
+
* matters: a run that does not know it spends its budget hunting a rig that is
|
|
337
|
+
* already right, and two runs whose atlases differ cannot be compared at all.
|
|
338
|
+
*
|
|
339
|
+
* ## The three figures, and what relates them
|
|
340
|
+
*
|
|
341
|
+
* Everything here is measured over **the same pixels** `mae` and `maeReference`
|
|
342
|
+
* average, in the same box, from the same pose — the substitution changes texels
|
|
343
|
+
* and nothing else (`substituteTexture`). Writing `a` for the candidate's own
|
|
344
|
+
* render, `f` for its substituted render and `b` for the reference frame:
|
|
345
|
+
*
|
|
346
|
+
* - `mae` is `mean|a − b|` and is **unchanged** by any of this;
|
|
347
|
+
* - `floor` is `mean|a − f|` — the resampling on its own. Same geometry, same
|
|
348
|
+
* pose, same rasteriser: there is no rig content in it at all;
|
|
349
|
+
* - `aboveFloor` is `mean|f − b|` — the figure with the texture difference taken
|
|
350
|
+
* out, which is what the hand-run diagnostic was reporting.
|
|
351
|
+
*
|
|
352
|
+
* ⭐ **The floor bounds the claim rather than subtracting from it.** Absolute
|
|
353
|
+
* errors do not add, so `mae = floor + aboveFloor` would be false. What is true,
|
|
354
|
+
* per pixel and therefore of the means, is the triangle inequality:
|
|
355
|
+
*
|
|
356
|
+
* |mae − aboveFloor| ≤ floor
|
|
357
|
+
*
|
|
358
|
+
* so the floor is an upper bound on how much of the MAE the texture can explain,
|
|
359
|
+
* and `mae − aboveFloor` is what it actually explained on these frames. A
|
|
360
|
+
* `floor` near zero is a proof that the texture is *not* the story here; a
|
|
361
|
+
* `floor` that accounts for most of `mae` says the rest of the report is being
|
|
362
|
+
* read at the wrong scale.
|
|
363
|
+
*
|
|
364
|
+
* ⚠️ **`aboveFloor` is not a better number and must not be recorded as one.** The
|
|
365
|
+
* artifact under measurement ships its own atlas, so `mae` is the figure that
|
|
366
|
+
* belongs in a record and this is the explanation of where it went. And the
|
|
367
|
+
* coarser texture **loses** detail the finer one has: on rung 3 a one-pixel move
|
|
368
|
+
* the reference makes stops being visible through the half-scale pack, so a
|
|
369
|
+
* substituted run can miss a frame-change disagreement the graded run reports.
|
|
370
|
+
*/
|
|
371
|
+
export interface TextureFloor {
|
|
372
|
+
/** `mean|own − substituted|` over the union alpha — the resampling alone. */
|
|
373
|
+
floor: number;
|
|
374
|
+
/** `mean|substituted − reference|` over the same pixels — the MAE without it. */
|
|
375
|
+
aboveFloor: number;
|
|
376
|
+
/** The same two over the REFERENCE's own drawn pixels — `maeReference`'s denominator. */
|
|
377
|
+
floorReference: number;
|
|
378
|
+
aboveFloorReference: number;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface FrameCheck {
|
|
382
|
+
index: number;
|
|
383
|
+
/** The reference PNG, so a worst-frame line is directly openable. */
|
|
384
|
+
file: string;
|
|
385
|
+
/** Mean absolute RGB difference over the union alpha, 0..255. */
|
|
386
|
+
mae: number;
|
|
387
|
+
/**
|
|
388
|
+
* The same total difference over the REFERENCE's own drawn pixels alone.
|
|
389
|
+
*
|
|
390
|
+
* ⭐ The figure to optimise against, and the reason is the denominator. `mae`
|
|
391
|
+
* divides by the pixels either side drew, and the candidate owns half of that:
|
|
392
|
+
* drawing something large and mostly transparent adds many cheap pixels to the
|
|
393
|
+
* union and the *mean falls*, so an optimiser can buy a better score by growing
|
|
394
|
+
* (issue #119 — a muzzle flare walked its own scale to 13x doing exactly this).
|
|
395
|
+
* This denominator is the reference's, which nothing the candidate does can
|
|
396
|
+
* move, so the only way down is to draw the reference's picture.
|
|
397
|
+
*
|
|
398
|
+
* ⚠️ Not bounded by 255, and deliberately: a candidate that draws far more than
|
|
399
|
+
* the reference has more absolute error than the reference has pixels to carry
|
|
400
|
+
* it, and the figure says so instead of saturating.
|
|
401
|
+
*
|
|
402
|
+
* `mae` is still the right figure for comparing two builds of the same rig,
|
|
403
|
+
* where the union is near enough the same on both sides.
|
|
404
|
+
*/
|
|
405
|
+
maeReference: number;
|
|
406
|
+
/**
|
|
407
|
+
* The same difference averaged over the WHOLE frame, background included.
|
|
408
|
+
*
|
|
409
|
+
* Reported beside `mae` and never instead of it. Most of a frame is background
|
|
410
|
+
* on both sides, so this number is small for every candidate and the gap
|
|
411
|
+
* between a good one and a bad one is smaller still — but it is the number an
|
|
412
|
+
* ad-hoc re-render check naturally computes, so a run comparing itself against
|
|
413
|
+
* an older log needs it to be able to.
|
|
414
|
+
*/
|
|
415
|
+
maeFrame: number;
|
|
416
|
+
unionPixels: number;
|
|
417
|
+
candidatePixels: number;
|
|
418
|
+
referencePixels: number;
|
|
419
|
+
components: number;
|
|
420
|
+
/** Components no slot reached — something in the shot the candidate has not drawn. */
|
|
421
|
+
unmatchedComponents: number;
|
|
422
|
+
worstSlot: string | null;
|
|
423
|
+
worstDrift: number | null;
|
|
424
|
+
/** How many slots got an attributable drift, out of how many drew anything. */
|
|
425
|
+
attributed: number;
|
|
426
|
+
drawn: number;
|
|
427
|
+
slots: SlotTrack[];
|
|
428
|
+
/** This frame against the one before it, on each side — `null` unless adjacent. */
|
|
429
|
+
change: FrameChange | null;
|
|
430
|
+
/** What of this frame's difference is texture — `null` without `--texture-from`. */
|
|
431
|
+
textureFloor: TextureFloor | null;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
/**
|
|
435
|
+
* One bone chain's slice of a set — the row an author reads before deciding what
|
|
436
|
+
* to re-key.
|
|
437
|
+
*
|
|
438
|
+
* The chains come from the CANDIDATE's bone tree (`src/chains.ts` owns the rule
|
|
439
|
+
* and the reasoning); the reference stays pixels, so this is a decomposition of
|
|
440
|
+
* your own figure and never a reading of the answer.
|
|
441
|
+
*/
|
|
442
|
+
export interface ChainCheck {
|
|
443
|
+
/** The chain, named as `src/chains.ts` names it. */
|
|
444
|
+
chain: string;
|
|
445
|
+
/** How many slots it owns. */
|
|
446
|
+
slots: number;
|
|
447
|
+
/** How many of those drew anything in at least one compared frame. */
|
|
448
|
+
drewSlots: number;
|
|
449
|
+
/** The worst attributable slot drift anywhere in it, in frame pixels. */
|
|
450
|
+
worstDrift: number;
|
|
451
|
+
/** Which slot that was, and in which frame — `null`/`-1` when none was attributable. */
|
|
452
|
+
worstDriftSlot: string | null;
|
|
453
|
+
worstDriftFrame: number;
|
|
454
|
+
/** The mean of every attributable slot drift in it, over `driftSamples` of them. */
|
|
455
|
+
meanDrift: number;
|
|
456
|
+
driftSamples: number;
|
|
457
|
+
/** How many frames contributed at least one of those samples. */
|
|
458
|
+
driftFrames: number;
|
|
459
|
+
/**
|
|
460
|
+
* The absolute RGB difference attributed to this chain, summed over the
|
|
461
|
+
* REFERENCE's own drawn pixels — never over the union.
|
|
462
|
+
*
|
|
463
|
+
* ⭐ The denominator lesson from issue #119, applied to a share. A reference
|
|
464
|
+
* pixel goes to the chain whose ink is nearest to it, so the chains partition
|
|
465
|
+
* the reference's drawn pixels and the shares add up to the whole. What the
|
|
466
|
+
* candidate controls is only *which* chain a pixel lands in, and growing a
|
|
467
|
+
* chain's ink pulls MORE of the reference's pixels — and their error — into it.
|
|
468
|
+
* There is no move here that makes a chain look better by drawing more, which is
|
|
469
|
+
* exactly what the union MAE could not say.
|
|
470
|
+
*/
|
|
471
|
+
error: number;
|
|
472
|
+
/** How many reference-drawn pixels it took, summed over frames. */
|
|
473
|
+
referencePixels: number;
|
|
474
|
+
/**
|
|
475
|
+
* `error` per pixel it took — the MAE *inside* this chain, 0..255.
|
|
476
|
+
*
|
|
477
|
+
* Printed beside the share because the share alone confounds being wrong with
|
|
478
|
+
* being big: spineboy's head, goggles, eye and mouth are one chain covering a
|
|
479
|
+
* lot of the figure, so it can carry a third of the error at a per-pixel figure
|
|
480
|
+
* below the run's own mean. The share says where the error IS; this says whether
|
|
481
|
+
* the chain is actually worse than the rest of the figure.
|
|
482
|
+
*/
|
|
483
|
+
mae: number;
|
|
484
|
+
/** `error` over the set's own total, 0..1 — see `AnimationCheck.chainDenominator`. */
|
|
485
|
+
maeShare: number;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* The whole shot against the contact sheet beside it — the frames `check` has no
|
|
490
|
+
* file for.
|
|
491
|
+
*
|
|
492
|
+
* ## Why a sheet is a frame set and not a picture of one
|
|
493
|
+
*
|
|
494
|
+
* A long shot does not commit 311 near-duplicate PNGs. It commits a couple of
|
|
495
|
+
* stills and folds every sampled frame into one `contact.png`, and `check` used to
|
|
496
|
+
* read the two stills, say `2 compared`, and mean it — an honest number with a hole
|
|
497
|
+
* behind it: nothing whatever was measured about the other 309 frames, so a clean
|
|
498
|
+
* table said nothing about the shot (issue #36). A sheet is the same thing a frame
|
|
499
|
+
* is, at a smaller scale and with a frame number burned into the corner, and
|
|
500
|
+
* reading it reads no reference skeleton.
|
|
501
|
+
*
|
|
502
|
+
* So the candidate is sampled at the set's own rate, rendered into the same world
|
|
503
|
+
* box the set was framed in at the sheet's own scale, and each frame is compared
|
|
504
|
+
* against its own tile. The prototype this replaces was written in-run by the
|
|
505
|
+
* second rung-2 attempt, which found with it what the two-still table could not:
|
|
506
|
+
* flat MAE 4.85–4.95 over all 1,244 frames of four shots, no spikes — evidence
|
|
507
|
+
* that trajectories, ring rates and attachment swaps land where and when they
|
|
508
|
+
* should.
|
|
509
|
+
*
|
|
510
|
+
* ## What it deliberately does not measure: the per-frame change
|
|
511
|
+
*
|
|
512
|
+
* The tiles are adjacent, so `FrameChange` looks reachable here, and it is not:
|
|
513
|
+
* `CHANGE_EXCESS` is two dozen pixels at frame scale, and a quarter-scale tile has
|
|
514
|
+
* a sixteenth of the pixels to move. The thresholds would have to be re-derived
|
|
515
|
+
* against sheets before that column could mean anything, and a figure printed at
|
|
516
|
+
* the wrong scale is worse than one not printed. MAE only, and the report says so.
|
|
517
|
+
*/
|
|
518
|
+
export interface SheetCheck {
|
|
519
|
+
/** The sheet itself, so a worst-tile line is openable. */
|
|
520
|
+
file: string;
|
|
521
|
+
/** The grid, measured off the sheet — see `sheetGeometry`. */
|
|
522
|
+
columns: number;
|
|
523
|
+
tileWidth: number;
|
|
524
|
+
tileHeight: number;
|
|
525
|
+
/** Frame pixels per tile pixel: how much smaller a tile is than a frame. */
|
|
526
|
+
tileScale: number;
|
|
527
|
+
/** How many tiles the sheet holds, and how many the candidate could be compared on. */
|
|
528
|
+
tiles: number;
|
|
529
|
+
compared: number;
|
|
530
|
+
/** Mean and worst over the tiles, in the same two denominators the frames use. */
|
|
531
|
+
meanMae: number;
|
|
532
|
+
meanMaeReference: number;
|
|
533
|
+
worstMae: number;
|
|
534
|
+
worstTile: number;
|
|
535
|
+
/** The worst tiles by MAE, worst first — at most `WORST_FRAMES` of them. */
|
|
536
|
+
worst: Array<{ index: number; mae: number }>;
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
export interface AnimationCheck {
|
|
540
|
+
dir: string;
|
|
541
|
+
/** The animation the frames show, per the sidecar. */
|
|
542
|
+
animation: string | null;
|
|
543
|
+
/** The candidate animation played against it. */
|
|
544
|
+
candidateAnimation: string | null;
|
|
545
|
+
fps: number;
|
|
546
|
+
referenceFrames: number;
|
|
547
|
+
candidateFrames: number;
|
|
548
|
+
compared: number;
|
|
549
|
+
meanMae: number;
|
|
550
|
+
/** Mean of the per-frame reference-denominator MAE — see `FrameCheck.maeReference`. */
|
|
551
|
+
meanMaeReference: number;
|
|
552
|
+
/**
|
|
553
|
+
* How much this set draws, against how much the reference draws: the mean over
|
|
554
|
+
* its frames of `candidatePixels / referencePixels`.
|
|
555
|
+
*
|
|
556
|
+
* 1 means the two shots put ink on the same amount of the frame. Above 1 the
|
|
557
|
+
* candidate is drawing more than the reference does, which is the move that
|
|
558
|
+
* makes the union MAE cheaper — see `OVERDRAW_RATIO`, which is where the
|
|
559
|
+
* threshold and the corpus it came from are written down.
|
|
560
|
+
*/
|
|
561
|
+
drawnRatio: number;
|
|
562
|
+
/** Mean of the per-frame whole-frame MAE — see `FrameCheck.maeFrame`. */
|
|
563
|
+
meanMaeFrame: number;
|
|
564
|
+
/**
|
|
565
|
+
* The mean over this set's frames of each frame's own decomposition — see
|
|
566
|
+
* `TextureFloor`. `null` without `--texture-from`.
|
|
567
|
+
*/
|
|
568
|
+
textureFloor: TextureFloor | null;
|
|
569
|
+
worstMae: number;
|
|
570
|
+
worstMaeFrame: number;
|
|
571
|
+
worstDrift: number;
|
|
572
|
+
worstDriftFrame: number;
|
|
573
|
+
worstDriftSlot: string | null;
|
|
574
|
+
/** Frames in which no slot at all could be attributed — the drift's denominator. */
|
|
575
|
+
framesWithoutDrift: number;
|
|
576
|
+
/** Adjacent frame pairs a frame-to-frame change could be measured across. */
|
|
577
|
+
changePairs: number;
|
|
578
|
+
/** How many of those the candidate's own change disagrees with the reference's. */
|
|
579
|
+
changeDisagreements: number;
|
|
580
|
+
/** The widest of those disagreements, and `-1` when there is none. */
|
|
581
|
+
worstChangeFrame: number;
|
|
582
|
+
/**
|
|
583
|
+
* This set, broken down by the candidate's own bone chains — see `ChainCheck`.
|
|
584
|
+
*
|
|
585
|
+
* Chains that own no slot at all are left out: they have nothing to attribute.
|
|
586
|
+
* They are still in `CheckReport.chains`, so the roster stays a complete account
|
|
587
|
+
* of where every bone went.
|
|
588
|
+
*/
|
|
589
|
+
chains: ChainCheck[];
|
|
590
|
+
/**
|
|
591
|
+
* The set's whole difference over the reference's own drawn pixels — the
|
|
592
|
+
* denominator every `ChainCheck.maeShare` divides by.
|
|
593
|
+
*
|
|
594
|
+
* The same numerator `meanMaeReference` averages, kept as a total because a
|
|
595
|
+
* share needs the total and a mean has already divided it away.
|
|
596
|
+
*/
|
|
597
|
+
chainDenominator: number;
|
|
598
|
+
/** The part of it no chain could take, because the candidate drew nothing at all. */
|
|
599
|
+
unattributedError: number;
|
|
600
|
+
frames: FrameCheck[];
|
|
601
|
+
/**
|
|
602
|
+
* The box THIS set's candidate frames were rendered into.
|
|
603
|
+
*
|
|
604
|
+
* Under the default per-shot scope every set carries its own, and they are
|
|
605
|
+
* different boxes; under `--framing shared` they are all the same one. Either
|
|
606
|
+
* way it is here rather than only at the top of the report, because it is
|
|
607
|
+
* upstream of every number in this row.
|
|
608
|
+
*/
|
|
609
|
+
viewport: Framing;
|
|
610
|
+
/** How this set's box was chosen — see `FramingSource`. */
|
|
611
|
+
framing: FramingHow;
|
|
612
|
+
/** Where this set's drawn pixels ended up against the reference's. */
|
|
613
|
+
framingFit: FramingReport | null;
|
|
614
|
+
/**
|
|
615
|
+
* What the declared-box probe measured for this set, taken or not — see
|
|
616
|
+
* `DeclaredBoxProbe`. Reported either way, because "refused, and by this much,
|
|
617
|
+
* on this ground" is the fact issue #194 found missing.
|
|
618
|
+
*/
|
|
619
|
+
declaredBox: DeclaredBoxProbe | null;
|
|
620
|
+
/**
|
|
621
|
+
* The whole shot against the contact sheet, when the set ships one and does not
|
|
622
|
+
* ship every frame — see `SheetCheck`. `null` when there is nothing to add: no
|
|
623
|
+
* sheet, or every sampled frame already on disk as a frame of its own.
|
|
624
|
+
*/
|
|
625
|
+
sheet: SheetCheck | null;
|
|
626
|
+
notes: string[];
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
/** A world box and the pixel grid it was drawn into. */
|
|
630
|
+
export interface Framing {
|
|
631
|
+
x: number;
|
|
632
|
+
y: number;
|
|
633
|
+
width: number;
|
|
634
|
+
height: number;
|
|
635
|
+
/** Frame pixels per world unit. */
|
|
636
|
+
scale: number;
|
|
637
|
+
pixelWidth: number;
|
|
638
|
+
pixelHeight: number;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* How the world box the candidate was rendered into was arrived at.
|
|
643
|
+
*
|
|
644
|
+
* - `derived` — fitted to the candidate's own drawn pixels, from a start taken
|
|
645
|
+
* from its posed geometry. The default, and the only option without a sidecar.
|
|
646
|
+
* - `declared` — the box `frames.json` records, reached from that same box and
|
|
647
|
+
* kept because the candidate's own pixels land on the reference's in it. See
|
|
648
|
+
* `frameByDeclaredBox` for why a measured coincidence licenses it.
|
|
649
|
+
* - `pinned` — `--viewport`, which is a claim by the author and is not checked.
|
|
650
|
+
*/
|
|
651
|
+
export type FramingSource = 'derived' | 'declared' | 'pinned';
|
|
652
|
+
|
|
653
|
+
/** The same three, named for the report line rather than for the code path. */
|
|
654
|
+
export type FramingHow = 'candidate-pixels' | 'frames-viewport' | 'viewport-flag';
|
|
655
|
+
|
|
656
|
+
/**
|
|
657
|
+
* Why `frames.json`'s own box was taken for a set, or was not — and the two
|
|
658
|
+
* numbers that decided it.
|
|
659
|
+
*
|
|
660
|
+
* ## 🎯 The clause it exists to state, and the case that made it necessary
|
|
661
|
+
*
|
|
662
|
+
* Taking the frames' own box is the single largest thing `check` decides on its
|
|
663
|
+
* own: it is the difference between measuring in the box the frames were drawn at
|
|
664
|
+
* and measuring in a fit of it, and `frameByDeclaredBox` records the MAE that
|
|
665
|
+
* costs. The decision used to be reported only by its *outcome* — one word on the
|
|
666
|
+
* `framed to` line — so a set that was refused said nothing about how far it
|
|
667
|
+
* missed by or on which of the two grounds.
|
|
668
|
+
*
|
|
669
|
+
* That is issue #194. Rung 7's candidate has its setup box on the reference's to
|
|
670
|
+
* the pixel and is refused on all twelve sets, because the test is on **extent**
|
|
671
|
+
* and its union content box is 15.4 px narrower at the extremes. The refusal cost
|
|
672
|
+
* every set 0.7–1.3 MAE against the declared box, and the run had no way to say
|
|
673
|
+
* whether that was a coordinate error or a silhouette — the two readings the
|
|
674
|
+
* numbers here separate. See `EXTENT_SPREAD` for the clause that now settles it.
|
|
675
|
+
*/
|
|
676
|
+
export interface DeclaredBoxProbe {
|
|
677
|
+
/**
|
|
678
|
+
* The correction a fit at the declared box asks for, in frame pixels — the
|
|
679
|
+
* worst displacement over the candidate's own content box corners.
|
|
680
|
+
*/
|
|
681
|
+
distance: number;
|
|
682
|
+
/** What that same fit leaves over across every edge of every frame, in px rms. */
|
|
683
|
+
rms: number;
|
|
684
|
+
/** How far the correction was allowed to reach — see `EXTENT_SPREAD_REACH`. */
|
|
685
|
+
reach: number;
|
|
686
|
+
/** How many frames the probe was made from. */
|
|
687
|
+
frames: number;
|
|
688
|
+
/** Was the box taken for this set? */
|
|
689
|
+
taken: boolean;
|
|
690
|
+
/**
|
|
691
|
+
* Which clause decided it.
|
|
692
|
+
*
|
|
693
|
+
* - `coincident` — the correction is under `COINCIDENT_PIXELS`. Taken; the
|
|
694
|
+
* original clause, and unchanged.
|
|
695
|
+
* - `extent-spread` — the correction is larger than that but reaches no further
|
|
696
|
+
* than `reach`, and `rms` says one similarity still cannot put the two shots on
|
|
697
|
+
* each other. Both halves are needed — see `EXTENT_SPREAD_REACH`, which records
|
|
698
|
+
* the fixture that proves it. Taken.
|
|
699
|
+
* - `coordinates` — the correction reaches too far, or the fit explains it to
|
|
700
|
+
* within a pixel, so the two shots are in different coordinates (a different
|
|
701
|
+
* origin, or different units). Refused, and framed by the fitted path where the
|
|
702
|
+
* blindness to units lives.
|
|
703
|
+
* - `no-pixels` — the candidate drew nothing at all in the declared box, which
|
|
704
|
+
* is the loudest possible "not these coordinates". Refused.
|
|
705
|
+
*/
|
|
706
|
+
clause: 'coincident' | 'extent-spread' | 'coordinates' | 'no-pixels';
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Whether the framing is decided per frame set, or once across every set.
|
|
711
|
+
*
|
|
712
|
+
* ## What `per-shot` actually scopes, and why only that
|
|
713
|
+
*
|
|
714
|
+
* A framing is decided over the frames it is measured on, so pointing `check` at a
|
|
715
|
+
* skeleton root used to decide ONE for every set under it — and one badly-framed
|
|
716
|
+
* shot was then paid for by all the others. Measured on the spineboy rung, 8 shots
|
|
717
|
+
* and 147 frames: `idle` read **41.59** MAE at the root against the **18.77** it
|
|
718
|
+
* reads on its own frames, with not one key different (issue #100).
|
|
719
|
+
*
|
|
720
|
+
* `per-shot` moves exactly one decision into the set: **whether the box
|
|
721
|
+
* `frames.json` records is this set's box too.** That decision is a measurement
|
|
722
|
+
* with no floor — either the set's own drawn pixels land in the declared box or
|
|
723
|
+
* they do not — and over the union one shot that does not can put the pooled
|
|
724
|
+
* correction over `COINCIDENT_PIXELS` and take every other shot down with it. Per
|
|
725
|
+
* set, the ones that qualify read exactly what pinning by hand reads.
|
|
726
|
+
*
|
|
727
|
+
* ⚠️ It does NOT fit a separate chain per set, and that is a measured decision
|
|
728
|
+
* rather than a simplification. Per-set FITTING is worse: `fitFraming` registers
|
|
729
|
+
* extent, extent is not alignment, and one shot's frames do not constrain that
|
|
730
|
+
* enough — spineboy's `hit` reads 92.36 fitted on its own against 60.59 in the
|
|
731
|
+
* shared fit, and its two-frame `shoot@30fps` set reads 101.94 against 42.98. So a
|
|
732
|
+
* set that cannot take the declared box is measured in the shared framing, where
|
|
733
|
+
* every frame in the run constrains the answer.
|
|
734
|
+
*
|
|
735
|
+
* `shared` is the old behaviour, and it answers one question well: *does a single
|
|
736
|
+
* box serve every set?* The report prints that fit either way — see
|
|
737
|
+
* `CheckReport.sharedFraming`.
|
|
738
|
+
*
|
|
739
|
+
* ⚠️ The two are different measurements and their absolute numbers are not
|
|
740
|
+
* comparable across builds. The report says which one it did.
|
|
741
|
+
*/
|
|
742
|
+
export type FramingScope = 'per-shot' | 'shared';
|
|
743
|
+
|
|
744
|
+
/** What the framing pass concluded, and how sure it is of it. */
|
|
745
|
+
export interface FramingReport {
|
|
746
|
+
/**
|
|
747
|
+
* The residual fit measured at the box the framing chain chose.
|
|
748
|
+
*
|
|
749
|
+
* ⚠️ **Before** the MAE-refined pass below it, when that pass moved the box —
|
|
750
|
+
* and deliberately, because the two answer different questions and both are
|
|
751
|
+
* worth printing: this says how far the two *extents* were from registering,
|
|
752
|
+
* `refinement` says how far the *pictures* were from each other after that. Its
|
|
753
|
+
* `settled` / `agrees` / `cycled` words describe the chain that reached the box,
|
|
754
|
+
* which is a fact about the chain and does not change afterwards.
|
|
755
|
+
*/
|
|
756
|
+
fit: FramingFit;
|
|
757
|
+
/** How many render/measure/correct passes ran. */
|
|
758
|
+
passes: number;
|
|
759
|
+
/** Did the correction converge to the identity, or was it still moving? */
|
|
760
|
+
settled: boolean;
|
|
761
|
+
/** How the box was chosen — see `FramingSource`. */
|
|
762
|
+
source: FramingSource;
|
|
763
|
+
/**
|
|
764
|
+
* Did the correction fall into a repeating orbit instead of converging?
|
|
765
|
+
*
|
|
766
|
+
* When it did, `settled` is false and **more passes cannot help**: the loop is
|
|
767
|
+
* re-measuring states it has already been in. That is a fact about the fit
|
|
768
|
+
* having no fixed point on this shot, not about the pass budget, and the two
|
|
769
|
+
* used to print the same warning.
|
|
770
|
+
*/
|
|
771
|
+
cycled: boolean;
|
|
772
|
+
/**
|
|
773
|
+
* Do the two content boxes agree, at the viewport that was used?
|
|
774
|
+
*
|
|
775
|
+
* `fitDistance` under `COINCIDENT_PIXELS`. This is what separates "the loop
|
|
776
|
+
* fell short of its own target and the two shots are nevertheless in the same
|
|
777
|
+
* place" from "the loop fell short because these are different shapes" — the
|
|
778
|
+
* first is the tool's floor and the second is a finding.
|
|
779
|
+
*/
|
|
780
|
+
agrees: boolean;
|
|
781
|
+
/**
|
|
782
|
+
* Whether the fit was APPLIED or only measured.
|
|
783
|
+
*
|
|
784
|
+
* `--viewport` pins the box, so the fit is reported and not used — which is the
|
|
785
|
+
* most useful thing about pinning it: it separates "my keys are wrong" from
|
|
786
|
+
* "my framing is wrong", and those are two different repairs.
|
|
787
|
+
*/
|
|
788
|
+
applied: boolean;
|
|
789
|
+
/**
|
|
790
|
+
* The same two content boxes in **world units**, when the sidecar records the
|
|
791
|
+
* reference's scale.
|
|
792
|
+
*
|
|
793
|
+
* ⭐ This is the one place a difference of pure scale can show up at all. The
|
|
794
|
+
* framing deliberately absorbs it — a candidate is authored in its own
|
|
795
|
+
* coordinates, so "twice as big in world units" is a choice of units and not an
|
|
796
|
+
* error, and a tool that reported it as one would be reporting the thing it was
|
|
797
|
+
* built to be blind to. But an author who measured the shot off the frames IS
|
|
798
|
+
* working in the frames' units, and for them these two numbers are directly
|
|
799
|
+
* comparable and a 2 % disagreement is a real finding. So it is printed, with
|
|
800
|
+
* what it does and does not mean attached.
|
|
801
|
+
*/
|
|
802
|
+
units: { candidate: Extent; reference: Extent; ratio: number } | null;
|
|
803
|
+
/**
|
|
804
|
+
* What the MAE-refined final pass found, and whether it moved the box.
|
|
805
|
+
*
|
|
806
|
+
* `null` when nothing could be searched (no frame with reference ink). See
|
|
807
|
+
* `FramingRefinement`.
|
|
808
|
+
*/
|
|
809
|
+
refinement: FramingRefinement | null;
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* The final framing pass, which asks a different question from every pass above
|
|
814
|
+
* it: not *do the two extents register?* but *is a constant pixel of this set's
|
|
815
|
+
* MAE a framing offset?*
|
|
816
|
+
*
|
|
817
|
+
* ## Why it exists, and why it is the last thing that happens
|
|
818
|
+
*
|
|
819
|
+
* Issue #146 measured the answer on the spineboy candidates: a **constant**
|
|
820
|
+
* translation of one or two pixels is worth 12 % of `death`'s headline
|
|
821
|
+
* reference-denominator MAE and up to 30 % of a fitted set's, while what is left
|
|
822
|
+
* after it is taken out is per-frame and small. That is `fitFraming`'s documented
|
|
823
|
+
* "extent is not alignment" floor arriving as a tenth of the number an author is
|
|
824
|
+
* reading as motion. `OffsetScan` searches whole-pixel offsets against the MAE
|
|
825
|
+
* itself, so it cannot walk off the answer the way the extent refinement measured
|
|
826
|
+
* and rejected in `frameByDeclaredBox` did — the figure it minimises is the figure
|
|
827
|
+
* the report prints.
|
|
828
|
+
*
|
|
829
|
+
* ## ⭐ Where it is allowed to move the box, and where it only reports
|
|
830
|
+
*
|
|
831
|
+
* **A fitted box is an estimate and gets corrected. An exact box does not.**
|
|
832
|
+
*
|
|
833
|
+
* - `derived` — the box came from a fit of extents, so a constant pixel in it is
|
|
834
|
+
* the estimator's own floor. The offset is applied.
|
|
835
|
+
* - `declared` — `frames.json`'s box is not an estimate of where the frames were
|
|
836
|
+
* drawn, it is where they were drawn (`frameByDeclaredBox`). A constant pixel
|
|
837
|
+
* *there* is the candidate's own figure sitting a pixel off inside the right
|
|
838
|
+
* box, which is a finding an author can act on and the framing must not absorb.
|
|
839
|
+
* Searched, reported, never applied — and measured: over the committed corpus
|
|
840
|
+
* every declared-box set's best offset is the exact identity, so this branch
|
|
841
|
+
* has cost nothing so far and would only ever fire on a real offset.
|
|
842
|
+
* - `pinned` — `--viewport` is the author's claim about their own coordinates and
|
|
843
|
+
* nothing here overrides it, exactly as the fit above is measured and not
|
|
844
|
+
* applied.
|
|
845
|
+
*/
|
|
846
|
+
export interface FramingRefinement {
|
|
847
|
+
/** The best whole-pixel offset found, in frame pixels. `0, 0` means none was. */
|
|
848
|
+
dx: number;
|
|
849
|
+
dy: number;
|
|
850
|
+
/** Was it applied to the box the set was measured in? */
|
|
851
|
+
applied: boolean;
|
|
852
|
+
/** The set's mean reference-denominator MAE at the box the framing chose... */
|
|
853
|
+
before: number;
|
|
854
|
+
/** ...and at `dx, dy`, which is the same figure with the constant taken out. */
|
|
855
|
+
after: number;
|
|
856
|
+
/** How far the search looked, and how many frames it pooled. */
|
|
857
|
+
radius: number;
|
|
858
|
+
frames: number;
|
|
859
|
+
/**
|
|
860
|
+
* Why the offset was not applied — `null` when it was.
|
|
861
|
+
*
|
|
862
|
+
* `identity` is the answer this pass gives on a set whose framing is already
|
|
863
|
+
* where the picture is, and it is a measurement rather than a default: the
|
|
864
|
+
* search ran over the whole window and the identity won it.
|
|
865
|
+
*/
|
|
866
|
+
declined: 'identity' | 'below-threshold' | 'box-is-exact' | 'pinned' | null;
|
|
867
|
+
}
|
|
868
|
+
|
|
869
|
+
/** A width and a height in world units. */
|
|
870
|
+
export interface Extent {
|
|
871
|
+
width: number;
|
|
872
|
+
height: number;
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
/** The substitution `--texture-from` made, and what it could not reach. */
|
|
876
|
+
export interface TextureFromReport {
|
|
877
|
+
/** The atlas whose texels stood in, as it was named on the command line. */
|
|
878
|
+
atlas: string;
|
|
879
|
+
/** Every `scale:` its text declares — the line that says a pack is coarser. */
|
|
880
|
+
scales: number[];
|
|
881
|
+
/**
|
|
882
|
+
* The same for the CANDIDATE's own atlas, so the two are read side by side —
|
|
883
|
+
* `null` when the candidate has no atlas beside it to read them off (issue
|
|
884
|
+
* #1020), which is not the same claim as declaring none.
|
|
885
|
+
*/
|
|
886
|
+
candidateScales: number[] | null;
|
|
887
|
+
/**
|
|
888
|
+
* Regions the substituting atlas does not have, or the candidate packs rotated
|
|
889
|
+
* so its own corner order cannot be inverted — see `artUvsOf`. Those pieces kept
|
|
890
|
+
* their own texture, so a non-empty list means the floor below is a MIXTURE and
|
|
891
|
+
* is not the whole of the resampling.
|
|
892
|
+
*/
|
|
893
|
+
unmatched: string[];
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
export interface CheckReport {
|
|
897
|
+
candidate: { skeleton: string; atlas: string };
|
|
898
|
+
/**
|
|
899
|
+
* Which implementation of the posing seam posed the CANDIDATE, and why
|
|
900
|
+
* (issue #968): `core` — rigc's own, reading the `skeleton.model.json` a
|
|
901
|
+
* build writes beside the pair — or `spine` (spine-core), with the reason the
|
|
902
|
+
* core was not used. `note` is `render`'s `poser` line, word for word.
|
|
903
|
+
*
|
|
904
|
+
* In the report because a figure is only readable beside what produced it:
|
|
905
|
+
* the reference side is pixels either way, and a report that did not say
|
|
906
|
+
* which poser drew the candidate would be a number with two possible
|
|
907
|
+
* subjects. The two are measured to give the same report on every tree row;
|
|
908
|
+
* this field is what lets a reader see which one this is.
|
|
909
|
+
*/
|
|
910
|
+
poser: { name: PoserName; note: string };
|
|
911
|
+
framesDir: string;
|
|
912
|
+
framesRoot: string;
|
|
913
|
+
/**
|
|
914
|
+
* The skin the CANDIDATE was posed under, or `null` for no skin at all.
|
|
915
|
+
*
|
|
916
|
+
* ⭐ In the report rather than only in the run's arguments because a figure is
|
|
917
|
+
* only readable beside what produced it: on a multi-skin rig the same
|
|
918
|
+
* candidate and the same frames give a different number per skin, and a
|
|
919
|
+
* `check.json` that did not say which one it was is a number with no subject
|
|
920
|
+
* (issue #571).
|
|
921
|
+
*/
|
|
922
|
+
skin: string | null;
|
|
923
|
+
/**
|
|
924
|
+
* The skin `frames.json` records for the reference frames, or `null`.
|
|
925
|
+
*
|
|
926
|
+
* `null` covers two facts that are the same on disk — the frames set no skin,
|
|
927
|
+
* and the frames were rendered before the field existed — which is why a
|
|
928
|
+
* mismatch against it is refused and an absence is only noted. See `notes`.
|
|
929
|
+
*/
|
|
930
|
+
referenceSkin: string | null;
|
|
931
|
+
/** One framing per set, or one across every set — see `FramingScope`. */
|
|
932
|
+
framingScope: FramingScope;
|
|
933
|
+
/**
|
|
934
|
+
* How the candidate's own world box was chosen, when ONE box covers the run.
|
|
935
|
+
*
|
|
936
|
+
* `null` under a per-shot scope with more than one set compared: there is no
|
|
937
|
+
* single answer then, and each `AnimationCheck` carries its own. A run that
|
|
938
|
+
* compared exactly one set fills these in whatever the scope, because for one
|
|
939
|
+
* set the two scopes are the same measurement.
|
|
940
|
+
*/
|
|
941
|
+
framing: FramingHow | null;
|
|
942
|
+
/** The box the CANDIDATE was rendered into, at the reference's pixel size. */
|
|
943
|
+
viewport: Framing | null;
|
|
944
|
+
/** Where the candidate's drawn pixels ended up against the reference's. */
|
|
945
|
+
framingFit: FramingReport | null;
|
|
946
|
+
/**
|
|
947
|
+
* The declared-box probe over every set pooled — see `DeclaredBoxProbe`.
|
|
948
|
+
*
|
|
949
|
+
* The run-level answer, which is the one a shared scope is measured by. Under a
|
|
950
|
+
* per-shot scope each `AnimationCheck` carries its own and that is the one that
|
|
951
|
+
* decided its framing; this stays reported because it is the figure the shared
|
|
952
|
+
* box on the line beside it came from.
|
|
953
|
+
*/
|
|
954
|
+
declaredBox: DeclaredBoxProbe | null;
|
|
955
|
+
/**
|
|
956
|
+
* The framing ONE shared box gives across every set compared.
|
|
957
|
+
*
|
|
958
|
+
* Under `per-shot` it is both reported and used: every set that cannot take the
|
|
959
|
+
* frames' own declared box is measured in it. It is also the figure that says
|
|
960
|
+
* *why* a whole-root run is a different measurement — a set that reads well in
|
|
961
|
+
* the declared box and badly here is a set the old whole-root run was measuring
|
|
962
|
+
* through somebody else's silhouette, which is what `idle` reading 41.59 against
|
|
963
|
+
* 18.77 was (issue #100).
|
|
964
|
+
*
|
|
965
|
+
* `null` when the scope is already shared — `framingFit` is that number then —
|
|
966
|
+
* and when only one set was compared, where the two scopes are the same thing.
|
|
967
|
+
*/
|
|
968
|
+
sharedFraming: FramingReport | null;
|
|
969
|
+
/**
|
|
970
|
+
* The box the REFERENCE was rendered into, when the sidecar records one.
|
|
971
|
+
*
|
|
972
|
+
* Informational: the two skeletons do not share a coordinate system, so these
|
|
973
|
+
* numbers are not comparable term by term. The pixel dimensions are — they are
|
|
974
|
+
* the same grid — and a difference between them is the diagnostic.
|
|
975
|
+
*/
|
|
976
|
+
referenceViewport: Framing | null;
|
|
977
|
+
background: RGBA;
|
|
978
|
+
/**
|
|
979
|
+
* The candidate's bone tree, cut into chains — the roster the report prints.
|
|
980
|
+
*
|
|
981
|
+
* Printed rather than assumed, because a decomposition an author has to guess at
|
|
982
|
+
* is one they will read wrong: the table says `front-thigh` and the roster says
|
|
983
|
+
* which bones and which slots that name covers. `src/chains.ts` owns the rule.
|
|
984
|
+
*/
|
|
985
|
+
chains: BoneChain[];
|
|
986
|
+
animations: AnimationCheck[];
|
|
987
|
+
/** The texture-only substitution, when one was asked for — `null` otherwise. */
|
|
988
|
+
textureFrom: TextureFromReport | null;
|
|
989
|
+
notes: string[];
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
export interface CheckOptions {
|
|
993
|
+
skeletonText: string;
|
|
994
|
+
/**
|
|
995
|
+
* The candidate's atlas, or `null` when there is no file (issue #1020): a
|
|
996
|
+
* rigc build the core poses from a `rigc-compiled/2` document is drawn
|
|
997
|
+
* without one, and anything else is refused naming the file
|
|
998
|
+
* (`CandidateAtlasError`).
|
|
999
|
+
*/
|
|
1000
|
+
atlasText: string | null;
|
|
1001
|
+
/** Where the atlas's page paths resolve from. */
|
|
1002
|
+
atlasDir: string;
|
|
1003
|
+
framesDir: string;
|
|
1004
|
+
/** Labels for the report; the texts above are what is actually read. */
|
|
1005
|
+
labels?: { skeleton: string; atlas: string };
|
|
1006
|
+
/** Only used when there is no sidecar to take the rate from. */
|
|
1007
|
+
fps?: number;
|
|
1008
|
+
/**
|
|
1009
|
+
* Pin the candidate's world box `x,y,width,height` instead of deriving it.
|
|
1010
|
+
*
|
|
1011
|
+
* Two uses, and the second is the one an authoring loop wants. It is the escape
|
|
1012
|
+
* hatch when the derivation cannot work — a candidate deliberately missing a
|
|
1013
|
+
* part has a different content box by construction, and pinning lets the rest
|
|
1014
|
+
* of the shot still be measured. And it is the way to hold the framing FIXED
|
|
1015
|
+
* across builds: the framing line is still reported, so a pinned run separates
|
|
1016
|
+
* "my keys moved" from "my framing moved" without either hiding the other.
|
|
1017
|
+
*/
|
|
1018
|
+
viewport?: { x: number; y: number; width: number; height: number };
|
|
1019
|
+
/** Play this candidate animation against the frames, when the names differ. */
|
|
1020
|
+
as?: string;
|
|
1021
|
+
/**
|
|
1022
|
+
* Pose the candidate under this skin — see `PoseOptions.skin` (issue #571).
|
|
1023
|
+
*
|
|
1024
|
+
* Absent sets no skin, which resolves every slot through the default skin
|
|
1025
|
+
* alone: on a multi-skin rig that draws none of the art the named skins carry,
|
|
1026
|
+
* and a `check` of it compares blank against blank and reads 0.0000. A name
|
|
1027
|
+
* the candidate does not declare is refused with the ones it does, and the
|
|
1028
|
+
* reference frames' own recorded skin is checked against this — see the
|
|
1029
|
+
* `referenceSkin` field of `CheckReport` for what an absent record means.
|
|
1030
|
+
*/
|
|
1031
|
+
skin?: string;
|
|
1032
|
+
/**
|
|
1033
|
+
* Fit one framing per frame set, or one across every set compared.
|
|
1034
|
+
*
|
|
1035
|
+
* Defaults to `per-shot`. See `FramingScope` for what the choice costs and why
|
|
1036
|
+
* this is the default; it has no effect when only one set is compared, and none
|
|
1037
|
+
* when `viewport` pins the box (a pin is a claim about the candidate's own
|
|
1038
|
+
* coordinates, and those do not change between shots).
|
|
1039
|
+
*/
|
|
1040
|
+
framing?: FramingScope;
|
|
1041
|
+
/**
|
|
1042
|
+
* Also measure the run through **this atlas's texels**, keeping the candidate's
|
|
1043
|
+
* own geometry, and attribute the difference — see `TextureFloor`.
|
|
1044
|
+
*
|
|
1045
|
+
* ⚠️ Not the same thing as pointing `atlasText` at another atlas, which loads
|
|
1046
|
+
* the skeleton against it and moves the geometry too (issue #199). Nothing about
|
|
1047
|
+
* the graded figures changes when this is set: the framing is decided, and every
|
|
1048
|
+
* existing measure taken, from the candidate's own atlas exactly as before, and
|
|
1049
|
+
* this adds a second render per compared frame beside them.
|
|
1050
|
+
*/
|
|
1051
|
+
textureFrom?: { atlasText: string; atlasDir: string; label: string };
|
|
1052
|
+
/**
|
|
1053
|
+
* Keep the rasters behind the frames the report will list, for `--out` — see
|
|
1054
|
+
* `CheckPlates`. Nothing about the report changes when this is set: it is filled
|
|
1055
|
+
* in beside the comparison, from the plates the comparison was computed on.
|
|
1056
|
+
*/
|
|
1057
|
+
plates?: CheckPlates;
|
|
1058
|
+
/**
|
|
1059
|
+
* The files `skeletonText` and `atlasText` were read from — what chooses the
|
|
1060
|
+
* candidate's poser, exactly as `render` chooses its own (`candidatePosers`,
|
|
1061
|
+
* issue #968): rigc's core when the `skeleton.model.json` beside the skeleton
|
|
1062
|
+
* records these skeleton bytes, spine-core otherwise. They must be the files
|
|
1063
|
+
* the texts came from; `cli.ts` reads both from them. Absent, the candidate
|
|
1064
|
+
* is posed through spine-core and the report says why.
|
|
1065
|
+
*/
|
|
1066
|
+
candidatePaths?: { skeleton: string; atlas: string };
|
|
1067
|
+
/** `--poser`: force one implementation — see `candidatePosers`. `core` on an input that cannot carry it throws `PoserChoiceError`. */
|
|
1068
|
+
poser?: PoserName;
|
|
1069
|
+
/**
|
|
1070
|
+
* What builds the core poser — `candidatePosers`' own `make`. The suite's
|
|
1071
|
+
* `CH01` passes a planted copy, and nothing else passes any.
|
|
1072
|
+
*/
|
|
1073
|
+
makeCorePoser?: MakeCorePoser;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
/** Why a candidate handed to `check` as text alone is posed through spine-core — there is no directory to find a model document in. */
|
|
1077
|
+
const UNPLACED_CANDIDATE = 'the candidate was handed to check as text, with no path to find a skeleton.model.json beside';
|
|
1078
|
+
|
|
1079
|
+
// ---------------------------------------------------------------------------
|
|
1080
|
+
|
|
1081
|
+
/**
|
|
1082
|
+
* Compare a candidate against a set of reference frames.
|
|
1083
|
+
*
|
|
1084
|
+
* ## 🧭 Why the candidate is framed by its own pixels
|
|
1085
|
+
*
|
|
1086
|
+
* The obvious move — render the candidate into the world box the sidecar
|
|
1087
|
+
* records — is wrong, and wrong in a way that reads as a catastrophic failure
|
|
1088
|
+
* rather than as a mistake: on rung 3's honest candidate it reports MAE 146/255,
|
|
1089
|
+
* because that candidate put its origin on the pendulum's pivot and the
|
|
1090
|
+
* reference put its own somewhere else entirely. **A candidate is authored in
|
|
1091
|
+
* its own coordinate system, and under the ladder's honesty rule it could not be
|
|
1092
|
+
* authored in any other** — the reference's origin is in the file the author is
|
|
1093
|
+
* not allowed to open.
|
|
1094
|
+
*
|
|
1095
|
+
* So the candidate is framed by its own content. What changed (issue #34) is what
|
|
1096
|
+
* "content" means. It used to be the union of the **posed quad corners**, and a
|
|
1097
|
+
* region attachment's quad extends past its own artwork wherever the art is
|
|
1098
|
+
* transparent, so an outermost corner routinely sat where no pixel was. Combined
|
|
1099
|
+
* with a mapping that read only `minX`, `maxY` and the long side, that let one
|
|
1100
|
+
* corner of one quad in one frame set the scale for a whole run: rung 5's first
|
|
1101
|
+
* correct build reported **MAE 39.00 instead of 4.35** on a box 0.93 % narrow, and
|
|
1102
|
+
* rung 4 watched a rotation the pixels cannot see move the reported MAE from 27.6
|
|
1103
|
+
* to 84.9 by swinging one corner in and out of the box.
|
|
1104
|
+
*
|
|
1105
|
+
* Now both sides are measured the same way, **on drawn pixels**:
|
|
1106
|
+
*
|
|
1107
|
+
* 1. render the candidate at the frames' own rate and grid, and take the content
|
|
1108
|
+
* box of what it actually draws (`src/framing.ts`);
|
|
1109
|
+
* 2. take the reference's content box off the PNGs with the same predicate;
|
|
1110
|
+
* 3. fit the similarity transform — uniform scale plus translation, least squares
|
|
1111
|
+
* over **both** width and height — that carries one onto the other, and render
|
|
1112
|
+
* through it. No single corner can set the scale, and an invisible margin
|
|
1113
|
+
* cannot move it at all.
|
|
1114
|
+
*
|
|
1115
|
+
* The pass repeats until the correction is the identity, because the correction
|
|
1116
|
+
* changes the pixels it was measured on.
|
|
1117
|
+
*
|
|
1118
|
+
* ⚠️ What this is still not blind to: a candidate that is missing a part, or has
|
|
1119
|
+
* an extra one, genuinely has a different content box, and one uniform scale
|
|
1120
|
+
* cannot make two different shapes agree. That is no longer silently spent on the
|
|
1121
|
+
* framing — it is reported as the fit's **residual** and its aspect error, which
|
|
1122
|
+
* is the number to read before reading a drift. `--viewport` pins the box outright
|
|
1123
|
+
* when even that is not enough.
|
|
1124
|
+
*/
|
|
1125
|
+
export function checkAgainstFrames(options: CheckOptions): CheckReport {
|
|
1126
|
+
const located = locateFrames(options.framesDir);
|
|
1127
|
+
// A frame set `render --slot`/`--hide` wrote is a picture of PART of a rig
|
|
1128
|
+
// (issue #835), and it is refused before anything is posed. The clause is the
|
|
1129
|
+
// skin mismatch's below, one step stronger: there two skins are two pictures
|
|
1130
|
+
// of one rig, here the reference is not a picture of a whole rig at all, so a
|
|
1131
|
+
// whole candidate compared against it would print a real figure about art the
|
|
1132
|
+
// reference leaves out. No flag makes that comparable — a warning would still
|
|
1133
|
+
// print the figure — so there is no remedy here but the reference's own.
|
|
1134
|
+
const subsetKey = located.sidecar?.slots !== undefined ? 'slots' : located.sidecar?.hidden !== undefined ? 'hidden' : null;
|
|
1135
|
+
if (subsetKey !== null) {
|
|
1136
|
+
const recorded = located.sidecar?.[subsetKey];
|
|
1137
|
+
throw new CheckError(
|
|
1138
|
+
`--frames ${options.framesDir} records a slot subset (${subsetKey}: ${
|
|
1139
|
+
Array.isArray(recorded) ? recorded.join(', ') : JSON.stringify(recorded)
|
|
1140
|
+
}) in ${FRAMES_SIDECAR}; a partial render is not a reference set — render the reference without --slot/--hide`,
|
|
1141
|
+
);
|
|
1142
|
+
}
|
|
1143
|
+
const notes: string[] = [];
|
|
1144
|
+
|
|
1145
|
+
// Which implementation poses the candidate (issue #968), chosen once for the
|
|
1146
|
+
// whole run — every set, the setup pose and the non-finite sentence — so no
|
|
1147
|
+
// two figures in one report can come from two posers: `candidatePaths` over
|
|
1148
|
+
// the candidate's files, as `render` chooses, and spine-core alone when it
|
|
1149
|
+
// was handed texts only. Chosen BEFORE anything is read off the candidate
|
|
1150
|
+
// (issue #1014), so a rigc build the core poses reads its names, its stage,
|
|
1151
|
+
// its bone tree and its pages without loading spine-core.
|
|
1152
|
+
const { choice, facts, pages } = loadCandidate(
|
|
1153
|
+
{ skeletonText: options.skeletonText, atlasText: options.atlasText, atlasDir: options.atlasDir, label: options.labels?.skeleton ?? 'the candidate' },
|
|
1154
|
+
options.candidatePaths ?? null,
|
|
1155
|
+
options.poser,
|
|
1156
|
+
{ ...(options.makeCorePoser === undefined ? {} : { make: options.makeCorePoser }), unplaced: UNPLACED_CANDIDATE },
|
|
1157
|
+
);
|
|
1158
|
+
const posable: Pick<Posable, 'pages'> = { pages };
|
|
1159
|
+
// A candidate that declares no stage is framed exactly as one that does,
|
|
1160
|
+
// because no framing here reads a stage: the world box is fitted from what the
|
|
1161
|
+
// candidate draws. Said on the one candidate where a reader can ask what box
|
|
1162
|
+
// stood in for the absent one (issue #714) — `SkeletonJson` copies the header
|
|
1163
|
+
// fields across unconditionally (`SkeletonJson.js:70-73`), so an omitted
|
|
1164
|
+
// extent is `undefined` there, not 0, and the header says the same.
|
|
1165
|
+
if (!facts.declaresStage) {
|
|
1166
|
+
notes.push(
|
|
1167
|
+
'the candidate declares no stage (no `skeleton.width`/`height`), and nothing stands in for one: its world ' +
|
|
1168
|
+
'box is fitted from the pixels it draws, as it is for every candidate, so the absence moves no figure below.',
|
|
1169
|
+
);
|
|
1170
|
+
}
|
|
1171
|
+
// The substitution is loaded before anything is posed, because posing has to
|
|
1172
|
+
// record each piece's original-art UVs for it and that is the one thing about
|
|
1173
|
+
// this measure that cannot be added afterwards — see `PieceTexture`.
|
|
1174
|
+
//
|
|
1175
|
+
// Read by rigc's own atlas reader when the core poses the candidate, and by
|
|
1176
|
+
// spine-core's when the runtime does (issue #1020, `SubstitutionReader`): a
|
|
1177
|
+
// rigc build's `--texture-from` reads nothing through the runtime, and an
|
|
1178
|
+
// export's reading is the one it always had.
|
|
1179
|
+
const substitution = options.textureFrom
|
|
1180
|
+
? textureSubstitutionFromText(options.textureFrom.atlasText, options.textureFrom.atlasDir, choice.core === null ? 'spine' : 'rigc')
|
|
1181
|
+
: null;
|
|
1182
|
+
// The skin is refused here rather than deeper in the sampler, for the reason
|
|
1183
|
+
// every miss in this project is refused where the names are: the skeleton is
|
|
1184
|
+
// open on this line and the alternatives can be listed.
|
|
1185
|
+
if (options.skin !== undefined && !facts.skins.includes(options.skin)) {
|
|
1186
|
+
throw new CheckError(
|
|
1187
|
+
`the candidate declares no skin ${JSON.stringify(options.skin)}; it declares [${
|
|
1188
|
+
facts.skins.join(', ') || 'none'
|
|
1189
|
+
}]`,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
// `--poser core` on an input that cannot carry it is refused here, before
|
|
1193
|
+
// anything is posed.
|
|
1194
|
+
refuseUnchosen(choice);
|
|
1195
|
+
const poseOptions: PoseOptions | undefined =
|
|
1196
|
+
substitution || options.skin !== undefined
|
|
1197
|
+
? { ...(substitution ? { texture: true } : {}), ...(options.skin === undefined ? {} : { skin: options.skin }) }
|
|
1198
|
+
: undefined;
|
|
1199
|
+
let background: RGBA;
|
|
1200
|
+
let sets: FrameSet[];
|
|
1201
|
+
let pixelWidth: number;
|
|
1202
|
+
let pixelHeight: number;
|
|
1203
|
+
let referenceViewport: Framing | null = null;
|
|
1204
|
+
|
|
1205
|
+
if (located.sidecar) {
|
|
1206
|
+
const s = located.sidecar;
|
|
1207
|
+
background = s.background;
|
|
1208
|
+
pixelWidth = s.viewport.pixelWidth;
|
|
1209
|
+
pixelHeight = s.viewport.pixelHeight;
|
|
1210
|
+
referenceViewport = { ...s.viewport };
|
|
1211
|
+
sets = located.only.length > 0 ? s.sets.filter((set) => located.only.includes(set.dir)) : s.sets;
|
|
1212
|
+
if (options.fps !== undefined && sets.some((set) => set.fps !== options.fps)) {
|
|
1213
|
+
throw new CheckError(
|
|
1214
|
+
`--fps ${options.fps} disagrees with ${FRAMES_SIDECAR}, which records ` +
|
|
1215
|
+
`${[...new Set(sets.map((set) => set.fps))].join(', ')} fps. The frames' own rate is the one they were ` +
|
|
1216
|
+
'rendered at; drop --fps, or point --frames at the set you meant',
|
|
1217
|
+
);
|
|
1218
|
+
}
|
|
1219
|
+
} else {
|
|
1220
|
+
// No sidecar: the pixel grid comes from the frames themselves, the rate from
|
|
1221
|
+
// --fps, and the background from this build's default — and the report says
|
|
1222
|
+
// so rather than letting a default look like a measurement.
|
|
1223
|
+
const dir = basename(located.root);
|
|
1224
|
+
const parent = dirname(located.root);
|
|
1225
|
+
const disk = framesOnDisk(parent, dir);
|
|
1226
|
+
if (disk.length === 0) throw new CheckError(`no f####.png frames in ${located.root}`);
|
|
1227
|
+
const first = readPlateFrom(located.root, disk[0].file);
|
|
1228
|
+
pixelWidth = first.width;
|
|
1229
|
+
pixelHeight = first.height;
|
|
1230
|
+
background = BACKGROUND;
|
|
1231
|
+
const fps = options.fps ?? PROTOCOL_FPS;
|
|
1232
|
+
const animation = dir.replace(/@\d+(\.\d+)?fps$/, '');
|
|
1233
|
+
sets = [
|
|
1234
|
+
{
|
|
1235
|
+
dir,
|
|
1236
|
+
animation: facts.animations.length === 0 ? null : animation,
|
|
1237
|
+
fps,
|
|
1238
|
+
sampled: disk[disk.length - 1].index + 1,
|
|
1239
|
+
written: disk.length,
|
|
1240
|
+
stride: 1,
|
|
1241
|
+
duration: disk[disk.length - 1].index / fps,
|
|
1242
|
+
},
|
|
1243
|
+
];
|
|
1244
|
+
// Two sources write a set like this, and they need opposite advice (issue
|
|
1245
|
+
// #842): a rigc render older than the sidecar is fixed by rendering it again,
|
|
1246
|
+
// while a foreign player's frames predate nothing and no rigc tool renders
|
|
1247
|
+
// their source — what they need is the three things the sidecar would have
|
|
1248
|
+
// said, supplied by whoever made them.
|
|
1249
|
+
notes.push(
|
|
1250
|
+
`no ${FRAMES_SIDECAR} at ${located.root} or beside it. The rate is ` +
|
|
1251
|
+
`--fps ${fps}${options.fps === undefined ? ' (the protocol default, not a measurement of these frames)' : ''} ` +
|
|
1252
|
+
`and the background is this build's default, ${BACKGROUND.join(', ')}; neither is read from the frames. ` +
|
|
1253
|
+
'A set with no sidecar is one of two things. A rigc render older than the sidecar: re-render it with ' +
|
|
1254
|
+
'`rigc render` (bench/render_reference.ts for an editor export) and both become facts about the frames. ' +
|
|
1255
|
+
`A foreign source — a Live2D, Unity or video player: render it onto an opaque background of ` +
|
|
1256
|
+
`${BACKGROUND.join(', ')}, pass --fps at the rate it was rendered, and name the directory after the ` +
|
|
1257
|
+
'candidate animation it shows, or pass --as <that animation>.',
|
|
1258
|
+
);
|
|
1259
|
+
// The set root is the animation directory itself here, so reads resolve
|
|
1260
|
+
// against its parent the way a sidecar layout does.
|
|
1261
|
+
located.root = parent;
|
|
1262
|
+
located.only = [dir];
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
if (sets.length === 0) throw new CheckError(`no frame set to compare in ${options.framesDir}`);
|
|
1266
|
+
|
|
1267
|
+
// --- the skin the frames were rendered under, against the one asked for ----
|
|
1268
|
+
//
|
|
1269
|
+
// ⭐ The asymmetry is the honest part (issue #571). A sidecar that RECORDS a
|
|
1270
|
+
// skin is a claim, and a claim that disagrees is refused by name; a sidecar
|
|
1271
|
+
// that records none is making no claim at all — it either set no skin or was
|
|
1272
|
+
// written before the field existed, and those are the same bytes — so the run
|
|
1273
|
+
// proceeds and says out loud that nothing checked it. Inventing a refusal out
|
|
1274
|
+
// of an absent field would refuse every frame set in this repository.
|
|
1275
|
+
const referenceSkin = located.sidecar?.skin ?? null;
|
|
1276
|
+
if (referenceSkin !== null && referenceSkin !== options.skin) {
|
|
1277
|
+
throw new CheckError(
|
|
1278
|
+
`${FRAMES_SIDECAR} records that these frames were rendered under skin ${JSON.stringify(referenceSkin)}, and ` +
|
|
1279
|
+
`this run poses the candidate ${
|
|
1280
|
+
options.skin === undefined
|
|
1281
|
+
? 'under no skin at all (the default skin alone)'
|
|
1282
|
+
: `under skin ${JSON.stringify(options.skin)}`
|
|
1283
|
+
}. Two skins are two different pictures of one rig, so the comparison would be a number about the ` +
|
|
1284
|
+
`difference between them. Pass --skin ${JSON.stringify(referenceSkin)}, or render the reference frames ` +
|
|
1285
|
+
`${options.skin === undefined ? 'with no --skin' : `with --skin ${JSON.stringify(options.skin)}`}.`,
|
|
1286
|
+
);
|
|
1287
|
+
}
|
|
1288
|
+
if (referenceSkin === null && options.skin !== undefined) {
|
|
1289
|
+
notes.push(
|
|
1290
|
+
`the candidate is posed under skin ${JSON.stringify(options.skin)} and the reference frames record no skin ` +
|
|
1291
|
+
`at all, so nothing here could check that they are the same picture. A frame set rendered by \`rigc ` +
|
|
1292
|
+
`render --skin\` since #571 carries the name in ${FRAMES_SIDECAR} and this run would have compared it.`,
|
|
1293
|
+
);
|
|
1294
|
+
}
|
|
1295
|
+
|
|
1296
|
+
// Pose every set once. Its frames are wanted twice — to frame the candidate and
|
|
1297
|
+
// to compare it — and posing twice is both slower and a chance for the framing
|
|
1298
|
+
// and the comparison to disagree about what they measured.
|
|
1299
|
+
//
|
|
1300
|
+
// Posed through the chosen poser, and ALL of it inside one `throughPoser`
|
|
1301
|
+
// call: a core refusal partway (`CoreInputError` — a concave clip, a
|
|
1302
|
+
// non-finite vertex) re-poses every set on spine-core rather than leaving a
|
|
1303
|
+
// report whose sets were posed by two implementations. The animation roster
|
|
1304
|
+
// a set is checked against is the skeleton's, in its own order, whichever
|
|
1305
|
+
// poser runs — the model document lists the spec's order, and a refusal that
|
|
1306
|
+
// listed the names in another order would be a byte of the report that
|
|
1307
|
+
// depends on the poser.
|
|
1308
|
+
const have = [...facts.animations];
|
|
1309
|
+
const posing = throughPoser(choice, (poser, roster) => {
|
|
1310
|
+
const sampled = sets.map((set) => prepareSet(located.root, set, poser, have, options.as, poseOptions));
|
|
1311
|
+
// 🔒 A pose that is not finite is refused before anything measures it (issue
|
|
1312
|
+
// #873), in the words `render` and the geometry export use for it. Measured
|
|
1313
|
+
// on a planted bone at x=1e309 before this: an overflowing root read "posed no
|
|
1314
|
+
// drawable attachment", one at -1e309 "drew no pixel", and one at +1e309 or a
|
|
1315
|
+
// rotation at 1e309 exited 0 with MAE 1.00 — the part missing from every
|
|
1316
|
+
// frame, and nothing said why.
|
|
1317
|
+
const posedSets = sampled.filter((p) => p.missing === null);
|
|
1318
|
+
const overflow = posedSets.some((p) =>
|
|
1319
|
+
p.frames.some((frame) => frame.pieces.some((piece) => piece.world.some((value) => !Number.isFinite(value)))),
|
|
1320
|
+
);
|
|
1321
|
+
if (overflow) {
|
|
1322
|
+
const found = nonFinitePoseOf(
|
|
1323
|
+
poser,
|
|
1324
|
+
options.skin,
|
|
1325
|
+
posedSets.map((p) => ({ animation: p.candidateAnimation, fps: p.set.fps })),
|
|
1326
|
+
);
|
|
1327
|
+
if (found === null) {
|
|
1328
|
+
throw new Error('a drawn piece of the candidate is not finite and no bone or vertex of its pose is — the two were posed differently');
|
|
1329
|
+
}
|
|
1330
|
+
throw new CheckError(`the candidate is posed to a number that is not finite, so no frame of it can be compared: ${found}`);
|
|
1331
|
+
}
|
|
1332
|
+
// 🔒 A candidate whose every drawn vertex sits at one point is refused in
|
|
1333
|
+
// `render`'s own sentence (issue #997), not measured. Measured before this on
|
|
1334
|
+
// a rig whose one drawn slot hangs from a bone only a non-default skin poses:
|
|
1335
|
+
// "the candidate drew no pixel in any frame that was compared" — true, and
|
|
1336
|
+
// silent on the reason, which is a skin the run did not pose.
|
|
1337
|
+
const unframeable = unframeableSentence(
|
|
1338
|
+
posedSets.map((p) => p.frames),
|
|
1339
|
+
poser.slots,
|
|
1340
|
+
options.skin,
|
|
1341
|
+
roster,
|
|
1342
|
+
);
|
|
1343
|
+
if (unframeable !== null) throw new UnframeablePoseError(unframeable);
|
|
1344
|
+
return sampled;
|
|
1345
|
+
});
|
|
1346
|
+
const prepared = posing.value;
|
|
1347
|
+
const pairs = prepared.flatMap((p) => p.pairs);
|
|
1348
|
+
if (pairs.length === 0) {
|
|
1349
|
+
notes.push('no reference frame has a candidate frame at the same index — nothing below was measured');
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
const maxSide = Math.max(pixelWidth, pixelHeight);
|
|
1353
|
+
// One edge level for both sides, read off the reference frames — see
|
|
1354
|
+
// `EDGE_FRACTION`. A handful of frames is enough: the level is a property of the
|
|
1355
|
+
// palette, not of a pose, and reading every frame twice to learn it is waste.
|
|
1356
|
+
const level = pairs.length === 0 ? BACKGROUND_TOLERANCE : edgeLevelOf(located.root, pairs, background);
|
|
1357
|
+
// Without a sidecar the background is an assumption, and it is an assumption
|
|
1358
|
+
// about a COLOUR: the box and the union alpha are found against it with alpha
|
|
1359
|
+
// unread. So a reference that is not opaque is refused rather than scored
|
|
1360
|
+
// (issue #842). Measured on gallery/look's `turn` with its background made
|
|
1361
|
+
// transparent and the candidate pinned by --viewport to the box the frames were
|
|
1362
|
+
// drawn in — where the same set over the assumed grey reads MAE 0.00 exactly —
|
|
1363
|
+
// it read 32.90 on every one of the 24 frames, which is the transparent area's
|
|
1364
|
+
// share of the frame times its distance from the grey: a figure about the
|
|
1365
|
+
// background, not the rig, and no framing moves it. A frame set WITH a sidecar
|
|
1366
|
+
// is `render`'s, which composites onto the colour it records, so it is not asked.
|
|
1367
|
+
const translucent: TranslucentFrame[] | null = located.sidecar ? null : [];
|
|
1368
|
+
const referenceBoxes =
|
|
1369
|
+
pairs.length === 0
|
|
1370
|
+
? []
|
|
1371
|
+
: referenceContentBoxes(located.root, pairs, background, level, pixelWidth, pixelHeight, translucent);
|
|
1372
|
+
if (translucent !== null && translucent.length > 0) {
|
|
1373
|
+
const first = translucent[0];
|
|
1374
|
+
const shown = translucent.slice(0, TRANSLUCENT_NAMED).map((t) => basename(t.file));
|
|
1375
|
+
throw new CheckError(
|
|
1376
|
+
`--frames ${options.framesDir} has no ${FRAMES_SIDECAR}, and ${translucent.length} of its ${pairs.length} ` +
|
|
1377
|
+
`compared reference frame(s) are not opaque (${shown.join(', ')}${
|
|
1378
|
+
translucent.length > shown.length ? `, and ${translucent.length - shown.length} more` : ''
|
|
1379
|
+
}): ${basename(first.file)} has alpha ${first.alpha} at (${first.x}, ${first.y}), where every pixel must ` +
|
|
1380
|
+
`be 255. Without a sidecar the frames are read against the background colour ${BACKGROUND.join(', ')} ` +
|
|
1381
|
+
'with alpha unread, so a pixel that is not opaque counts by its colour bytes alone: a transparent ' +
|
|
1382
|
+
'background counts as drawn and puts the figure over the whole frame, a number about the transparent ' +
|
|
1383
|
+
'area and not the rig, which --viewport does not change. Render the frames onto an opaque background of ' +
|
|
1384
|
+
`${BACKGROUND.join(', ')}.`,
|
|
1385
|
+
);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
const scope: FramingScope = options.framing ?? 'per-shot';
|
|
1389
|
+
const slices = sliceBySet(prepared, referenceBoxes);
|
|
1390
|
+
/** One per prepared set, in `prepared` order. */
|
|
1391
|
+
const framings: SetFraming[] = [];
|
|
1392
|
+
let topViewport: Viewport | null = null;
|
|
1393
|
+
let topHow: FramingHow | null = null;
|
|
1394
|
+
let topFit: FramingReport | null = null;
|
|
1395
|
+
let topProbe: DeclaredBoxProbe | null = null;
|
|
1396
|
+
let sharedFraming: FramingReport | null = null;
|
|
1397
|
+
|
|
1398
|
+
const reportFor = (fit: FramingFit, at: Viewport, over: Omit<FramingReport, 'units' | 'fit'>): FramingReport => ({
|
|
1399
|
+
...over,
|
|
1400
|
+
fit,
|
|
1401
|
+
units: extentsOf(fit, at.scale, referenceViewport),
|
|
1402
|
+
});
|
|
1403
|
+
|
|
1404
|
+
if (options.viewport) {
|
|
1405
|
+
// A pin is a claim about the CANDIDATE's own coordinates, and those do not
|
|
1406
|
+
// change between shots — so one box covers the run whatever the scope. The
|
|
1407
|
+
// per-set fits below are free: every frame is measured in that one box once,
|
|
1408
|
+
// and splitting the result per set costs nothing.
|
|
1409
|
+
const v = options.viewport;
|
|
1410
|
+
const pinned = viewportOfSize(v.x, v.y, v.width, v.height, maxSide / Math.max(v.width, v.height), pixelWidth, pixelHeight);
|
|
1411
|
+
notes.push(
|
|
1412
|
+
`the candidate's world box was pinned by --viewport ${v.x},${v.y},${v.width},${v.height} rather than derived ` +
|
|
1413
|
+
"from its own pixels — that is a claim about the candidate's coordinates, and nothing here checks it. The " +
|
|
1414
|
+
'framing line below is still measured, so it says what the pin cost.',
|
|
1415
|
+
);
|
|
1416
|
+
const pinnedShape = { passes: 1, settled: false, source: 'pinned' as const, cycled: false, applied: false };
|
|
1417
|
+
// A pin is one claim for the whole run, so the refined pass is measured over
|
|
1418
|
+
// the run as a whole — and never applied, for the same reason the fit is not.
|
|
1419
|
+
const refinement = refinementOf(scanOffsets(located.root, prepared, posable.pages, pinned, background), 'pinned');
|
|
1420
|
+
const perSet = prepared.map((p, i) =>
|
|
1421
|
+
pairUpBoxes([p], posable.pages, pinned, background, level, slices[i]),
|
|
1422
|
+
);
|
|
1423
|
+
for (const boxes of perSet) {
|
|
1424
|
+
const fit = boxes.length === 0 ? null : fitFraming(boxes);
|
|
1425
|
+
framings.push({
|
|
1426
|
+
viewport: pinned,
|
|
1427
|
+
how: 'viewport-flag',
|
|
1428
|
+
fit:
|
|
1429
|
+
fit === null
|
|
1430
|
+
? null
|
|
1431
|
+
: reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement }),
|
|
1432
|
+
notes: [],
|
|
1433
|
+
// A pin overrides the declared box outright, so there is no probe to
|
|
1434
|
+
// report: nothing was measured about whether that box applies here.
|
|
1435
|
+
declaredBox: null,
|
|
1436
|
+
});
|
|
1437
|
+
}
|
|
1438
|
+
const all = perSet.flat();
|
|
1439
|
+
topViewport = pinned;
|
|
1440
|
+
topHow = 'viewport-flag';
|
|
1441
|
+
if (all.length > 0) {
|
|
1442
|
+
const fit = fitFraming(all);
|
|
1443
|
+
topFit = reportFor(fit, pinned, { ...pinnedShape, agrees: fitDistance(fit) <= COINCIDENT_PIXELS, refinement });
|
|
1444
|
+
}
|
|
1445
|
+
} else if (referenceBoxes.every((b) => b === null)) {
|
|
1446
|
+
throw new CheckError(
|
|
1447
|
+
`no reference frame could be compared, so there is nothing to frame against${nothingToFrameWhy(
|
|
1448
|
+
prepared,
|
|
1449
|
+
[...facts.animations],
|
|
1450
|
+
options.as,
|
|
1451
|
+
located.sidecar !== null,
|
|
1452
|
+
)}`,
|
|
1453
|
+
);
|
|
1454
|
+
} else if (scope === 'shared' || prepared.length === 1) {
|
|
1455
|
+
const framed = frameCandidate(
|
|
1456
|
+
prepared,
|
|
1457
|
+
posable.pages,
|
|
1458
|
+
referenceBoxes,
|
|
1459
|
+
background,
|
|
1460
|
+
level,
|
|
1461
|
+
pixelWidth,
|
|
1462
|
+
pixelHeight,
|
|
1463
|
+
referenceViewport,
|
|
1464
|
+
);
|
|
1465
|
+
// One box for the run, so one refined pass over every frame in it: a single
|
|
1466
|
+
// shared framing that each set nudged its own way would not be a shared one.
|
|
1467
|
+
const refinement = refinementOf(
|
|
1468
|
+
scanOffsets(located.root, prepared, posable.pages, framed.viewport, background),
|
|
1469
|
+
framed.report.source,
|
|
1470
|
+
);
|
|
1471
|
+
const viewport =
|
|
1472
|
+
refinement !== null && refinement.applied
|
|
1473
|
+
? shiftViewport(framed.viewport, refinement.dx, refinement.dy, pixelWidth, pixelHeight)
|
|
1474
|
+
: framed.viewport;
|
|
1475
|
+
const fit = {
|
|
1476
|
+
...framed.report,
|
|
1477
|
+
units: extentsOf(framed.report.fit, framed.viewport.scale, referenceViewport),
|
|
1478
|
+
refinement,
|
|
1479
|
+
};
|
|
1480
|
+
const how = HOW_BY_SOURCE[framed.report.source];
|
|
1481
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
1482
|
+
framings.push({ viewport, how, fit, notes: [], declaredBox: framed.probe });
|
|
1483
|
+
}
|
|
1484
|
+
topViewport = viewport;
|
|
1485
|
+
topHow = how;
|
|
1486
|
+
topFit = fit;
|
|
1487
|
+
topProbe = framed.probe;
|
|
1488
|
+
notes.push(...framingNotes(framed.report, framed.probe));
|
|
1489
|
+
if (prepared.length > 1) {
|
|
1490
|
+
notes.push(
|
|
1491
|
+
`one framing was fitted across all ${prepared.length} frame set(s) (--framing shared). Its absolute numbers ` +
|
|
1492
|
+
'are not comparable with a per-shot run, and one badly-fitted set moves every other set in it.',
|
|
1493
|
+
);
|
|
1494
|
+
}
|
|
1495
|
+
} else {
|
|
1496
|
+
// Per shot: the DECLARED BOX is decided per set, and every set that does not
|
|
1497
|
+
// qualify for it is measured in the one shared framing.
|
|
1498
|
+
//
|
|
1499
|
+
// ## Why the split falls exactly there, and not "fit each set on its own"
|
|
1500
|
+
//
|
|
1501
|
+
// The obvious reading of issue #100 is that each set should get its own fitted
|
|
1502
|
+
// framing. It was written that way and measured, and it is worse — on the
|
|
1503
|
+
// spineboy rung, per-set fitting reads `hit` **92.36** against the shared
|
|
1504
|
+
// fit's 60.59 and `shoot@30fps` **101.94** against 42.98 (a two-frame set,
|
|
1505
|
+
// framed 24 % off). The reason is `fitFraming`'s own: it registers **extent**,
|
|
1506
|
+
// and extent is not alignment, so on a shot whose silhouette genuinely differs
|
|
1507
|
+
// the chain has a local minimum of the correction that is not a minimum of the
|
|
1508
|
+
// difference. More frames constrain that; one shot's worth does not.
|
|
1509
|
+
//
|
|
1510
|
+
// What actually produced the good column in that run is the other half — the
|
|
1511
|
+
// box `frames.json` records, which is not an estimate of anything and has no
|
|
1512
|
+
// floor. Over the union it was refused, because ONE badly-fitted shot put the
|
|
1513
|
+
// pooled correction over `COINCIDENT_PIXELS` and the whole root fell back to a
|
|
1514
|
+
// fit. Per set, the four sets that ARE in the frames' coordinates take it and
|
|
1515
|
+
// read exactly what pinning by hand reads: `idle` **18.77** against 41.59,
|
|
1516
|
+
// `walk` 32.00 against 45.33.
|
|
1517
|
+
//
|
|
1518
|
+
// So a set is framed by the frames' own box when its OWN pixels land there,
|
|
1519
|
+
// and by the shared fit otherwise. Every set is then at least as well framed
|
|
1520
|
+
// as a whole-root run framed it, and four of spineboy's sixteen much better.
|
|
1521
|
+
const shared = frameCandidate(
|
|
1522
|
+
prepared,
|
|
1523
|
+
posable.pages,
|
|
1524
|
+
referenceBoxes,
|
|
1525
|
+
background,
|
|
1526
|
+
level,
|
|
1527
|
+
pixelWidth,
|
|
1528
|
+
pixelHeight,
|
|
1529
|
+
referenceViewport,
|
|
1530
|
+
);
|
|
1531
|
+
const sharedShape: SetFraming = {
|
|
1532
|
+
viewport: shared.viewport,
|
|
1533
|
+
how: HOW_BY_SOURCE[shared.report.source],
|
|
1534
|
+
fit: { ...shared.report, units: extentsOf(shared.report.fit, shared.viewport.scale, referenceViewport) },
|
|
1535
|
+
notes: [],
|
|
1536
|
+
declaredBox: shared.probe,
|
|
1537
|
+
};
|
|
1538
|
+
sharedFraming = sharedShape.fit;
|
|
1539
|
+
topProbe = shared.probe;
|
|
1540
|
+
let own = 0;
|
|
1541
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
1542
|
+
const p = prepared[i];
|
|
1543
|
+
const probed =
|
|
1544
|
+
p.pairs.length === 0
|
|
1545
|
+
? { framed: null, probe: null }
|
|
1546
|
+
: frameByDeclaredBox(
|
|
1547
|
+
[p],
|
|
1548
|
+
posable.pages,
|
|
1549
|
+
slices[i],
|
|
1550
|
+
background,
|
|
1551
|
+
level,
|
|
1552
|
+
pixelWidth,
|
|
1553
|
+
pixelHeight,
|
|
1554
|
+
referenceViewport,
|
|
1555
|
+
);
|
|
1556
|
+
const declared = probed.framed;
|
|
1557
|
+
const chosen: SetFraming = declared
|
|
1558
|
+
? {
|
|
1559
|
+
viewport: declared.viewport,
|
|
1560
|
+
how: HOW_BY_SOURCE[declared.report.source],
|
|
1561
|
+
fit: {
|
|
1562
|
+
...declared.report,
|
|
1563
|
+
units: extentsOf(declared.report.fit, declared.viewport.scale, referenceViewport),
|
|
1564
|
+
},
|
|
1565
|
+
notes: framingNotes(declared.report, probed.probe),
|
|
1566
|
+
declaredBox: probed.probe,
|
|
1567
|
+
}
|
|
1568
|
+
: { ...sharedShape, notes: framingNotes(shared.report, probed.probe), declaredBox: probed.probe };
|
|
1569
|
+
if (declared) own++;
|
|
1570
|
+
// Per set, because the constant this pass removes is per set: issue #146
|
|
1571
|
+
// measured spineboy's `death` wanting (−1, +1) and its `jump` (0, −1) in the
|
|
1572
|
+
// same run and the same shared box. That is not the per-set *fitting* this
|
|
1573
|
+
// scope rejects — the offset is measured against the MAE itself, where one
|
|
1574
|
+
// shot's frames constrain the answer completely.
|
|
1575
|
+
framings.push(refined(located.root, [p], posable.pages, chosen, background, pixelWidth, pixelHeight));
|
|
1576
|
+
}
|
|
1577
|
+
notes.push(
|
|
1578
|
+
`the framing was decided per frame set: ${own} of ${prepared.length} set(s) were measured in ` +
|
|
1579
|
+
`${FRAMES_SIDECAR}'s own box because their own pixels land there, and the rest in the one shared framing on ` +
|
|
1580
|
+
'the "shared box" line. A set framed by the frames\' own box cannot be moved by any other set. --framing ' +
|
|
1581
|
+
'shared measures every set in the shared framing instead, which is a different measurement and not ' +
|
|
1582
|
+
'comparable with this one.',
|
|
1583
|
+
);
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// The candidate's own decomposition, derived once and used by every set — see
|
|
1587
|
+
// `src/chains.ts`. Reading the CANDIDATE's tree is what keeps this on the right
|
|
1588
|
+
// side of the honesty rule: the reference is still nothing but pixels.
|
|
1589
|
+
const chains = chainsOf(
|
|
1590
|
+
facts.bones.map(({ name, parent }) => ({ name, parent })),
|
|
1591
|
+
facts.slots.map(({ name, bone }) => ({ name, bone })),
|
|
1592
|
+
);
|
|
1593
|
+
const chainOfSlot = new Map<string, number>();
|
|
1594
|
+
chains.forEach((chain, index) => {
|
|
1595
|
+
for (const slot of chain.slots) chainOfSlot.set(slot, index);
|
|
1596
|
+
});
|
|
1597
|
+
|
|
1598
|
+
const animations: AnimationCheck[] = [];
|
|
1599
|
+
const unmatched = new Set<string>();
|
|
1600
|
+
for (let i = 0; i < prepared.length; i++) {
|
|
1601
|
+
const f = framings[i];
|
|
1602
|
+
animations.push(
|
|
1603
|
+
checkOneSet(
|
|
1604
|
+
located.root,
|
|
1605
|
+
prepared[i],
|
|
1606
|
+
posable,
|
|
1607
|
+
f,
|
|
1608
|
+
background,
|
|
1609
|
+
chains,
|
|
1610
|
+
chainOfSlot,
|
|
1611
|
+
substitution,
|
|
1612
|
+
unmatched,
|
|
1613
|
+
options.plates ?? null,
|
|
1614
|
+
),
|
|
1615
|
+
);
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// The candidate's `scale:` lines as its facts read them: off its atlas, or —
|
|
1619
|
+
// a build posed from a `rigc-compiled/3` document — off the pages the
|
|
1620
|
+
// document states (issue #1026), so a build drawn with its atlas gone reports
|
|
1621
|
+
// them as it does with the atlas there. `null` when neither was read (issue
|
|
1622
|
+
// #1020): a `/2` or `/1` document states where each region sits and not the
|
|
1623
|
+
// `scale:` line, so with its atlas gone the report says the line was not read
|
|
1624
|
+
// rather than that none was declared.
|
|
1625
|
+
const candidateScales = facts.atlasScales === null ? null : [...facts.atlasScales];
|
|
1626
|
+
if (substitution === null) {
|
|
1627
|
+
// ⭐ Named at the top of every report rather than only when it is measured.
|
|
1628
|
+
// Issue #171's finding was not that the floor was mis-measured; it was that
|
|
1629
|
+
// nothing in the report said the figures had one — so a reader could take a
|
|
1630
|
+
// texture difference for rig error twice over, on two rungs, before anybody
|
|
1631
|
+
// noticed the atlas.
|
|
1632
|
+
notes.push(
|
|
1633
|
+
'part of every MAE below is TEXTURE, not animation, and it is not attributed here. The frames were rendered ' +
|
|
1634
|
+
"through the reference's own atlas; this candidate samples its own" +
|
|
1635
|
+
`${
|
|
1636
|
+
candidateScales === null
|
|
1637
|
+
? ' (its atlas is not beside it, so whether that declares a scale: line is not read — the model document does not state one)'
|
|
1638
|
+
: candidateScales.length > 0
|
|
1639
|
+
? ` (declared at scale: ${[...new Set(candidateScales)].join(', ')})`
|
|
1640
|
+
: ''
|
|
1641
|
+
}` +
|
|
1642
|
+
', and if the two were packed at different scales every edge of every part is filtered from a different ' +
|
|
1643
|
+
'source in every frame. That difference is a constant no key can move and is invisible to the content box, ' +
|
|
1644
|
+
'the fit residual and the whole-pixel refinement. Pass --texture-from <the atlas the frames were rendered ' +
|
|
1645
|
+
"through> to measure it: the candidate's own geometry is kept and only the texels are swapped, so the run " +
|
|
1646
|
+
'reports the floor and what sits above it beside the figure of record.',
|
|
1647
|
+
);
|
|
1648
|
+
} else if (options.textureFrom) {
|
|
1649
|
+
notes.push(
|
|
1650
|
+
`the texture floor below was measured against ${options.textureFrom.label} — the candidate's own geometry ` +
|
|
1651
|
+
'through that atlas\'s texels. It is a DIAGNOSTIC and not a better number: the artifact ships its own ' +
|
|
1652
|
+
'atlas, so the MAE is still the figure of record and the floor is the account of where part of it went.',
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
return {
|
|
1657
|
+
candidate: {
|
|
1658
|
+
skeleton: options.labels?.skeleton ?? '(in memory)',
|
|
1659
|
+
atlas: options.labels?.atlas ?? '(in memory)',
|
|
1660
|
+
},
|
|
1661
|
+
poser: { name: posing.poser, note: posing.note },
|
|
1662
|
+
framesDir: resolve(options.framesDir),
|
|
1663
|
+
framesRoot: located.root,
|
|
1664
|
+
skin: options.skin ?? null,
|
|
1665
|
+
referenceSkin,
|
|
1666
|
+
framingScope: scope,
|
|
1667
|
+
framing: topHow,
|
|
1668
|
+
viewport: topViewport === null ? null : framingOfViewport(topViewport),
|
|
1669
|
+
framingFit: topFit,
|
|
1670
|
+
declaredBox: topProbe,
|
|
1671
|
+
sharedFraming,
|
|
1672
|
+
referenceViewport,
|
|
1673
|
+
background,
|
|
1674
|
+
chains,
|
|
1675
|
+
animations,
|
|
1676
|
+
textureFrom:
|
|
1677
|
+
substitution === null || !options.textureFrom
|
|
1678
|
+
? null
|
|
1679
|
+
: {
|
|
1680
|
+
atlas: options.textureFrom.label,
|
|
1681
|
+
scales: substitution.scales,
|
|
1682
|
+
candidateScales,
|
|
1683
|
+
unmatched: [...unmatched].sort(),
|
|
1684
|
+
},
|
|
1685
|
+
notes,
|
|
1686
|
+
};
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
/** The framing one prepared set was measured in. */
|
|
1690
|
+
interface SetFraming {
|
|
1691
|
+
viewport: Viewport;
|
|
1692
|
+
how: FramingHow;
|
|
1693
|
+
fit: FramingReport | null;
|
|
1694
|
+
notes: string[];
|
|
1695
|
+
/**
|
|
1696
|
+
* What the declared-box probe measured for this set, taken or not — see
|
|
1697
|
+
* `DeclaredBoxProbe`. `null` when there was no box to probe (no sidecar) or
|
|
1698
|
+
* nothing to probe it with (`--viewport`, or a set with no compared frame).
|
|
1699
|
+
*/
|
|
1700
|
+
declaredBox: DeclaredBoxProbe | null;
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** `FramingSource` said in the report's own words. */
|
|
1704
|
+
const HOW_BY_SOURCE: Record<FramingSource, FramingHow> = {
|
|
1705
|
+
derived: 'candidate-pixels',
|
|
1706
|
+
declared: 'frames-viewport',
|
|
1707
|
+
pinned: 'viewport-flag',
|
|
1708
|
+
};
|
|
1709
|
+
|
|
1710
|
+
/**
|
|
1711
|
+
* `referenceBoxes` cut into one array per prepared set, in `prepared` order.
|
|
1712
|
+
*
|
|
1713
|
+
* The array is built by `prepared.flatMap((p) => p.pairs)`, so this is the inverse
|
|
1714
|
+
* of that flatten and nothing else. It exists because a per-shot framing measures
|
|
1715
|
+
* one set at a time and `frameCandidate` indexes its boxes the flat way.
|
|
1716
|
+
*/
|
|
1717
|
+
function sliceBySet(prepared: PreparedSet[], referenceBoxes: Array<ContentBox | null>): Array<Array<ContentBox | null>> {
|
|
1718
|
+
const out: Array<Array<ContentBox | null>> = [];
|
|
1719
|
+
let at = 0;
|
|
1720
|
+
for (const p of prepared) {
|
|
1721
|
+
out.push(referenceBoxes.slice(at, at + p.pairs.length));
|
|
1722
|
+
at += p.pairs.length;
|
|
1723
|
+
}
|
|
1724
|
+
return out;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1727
|
+
/** A rendering viewport as the report states it. */
|
|
1728
|
+
function framingOfViewport(v: Viewport): Framing {
|
|
1729
|
+
return {
|
|
1730
|
+
x: v.minX,
|
|
1731
|
+
y: v.minY,
|
|
1732
|
+
width: v.maxX - v.minX,
|
|
1733
|
+
height: v.maxY - v.minY,
|
|
1734
|
+
scale: v.scale,
|
|
1735
|
+
pixelWidth: v.width,
|
|
1736
|
+
pixelHeight: v.height,
|
|
1737
|
+
};
|
|
1738
|
+
}
|
|
1739
|
+
|
|
1740
|
+
function readPlateFrom(root: string, file: string): Plate {
|
|
1741
|
+
readFrameFile(root, file); // the guard; readPlate does the decoding
|
|
1742
|
+
return readPlate(file);
|
|
1743
|
+
}
|
|
1744
|
+
|
|
1745
|
+
// ---------------------------------------------------------------------------
|
|
1746
|
+
// framing
|
|
1747
|
+
// ---------------------------------------------------------------------------
|
|
1748
|
+
|
|
1749
|
+
/**
|
|
1750
|
+
* How many render → measure → correct passes the framing is allowed.
|
|
1751
|
+
*
|
|
1752
|
+
* A faithful candidate settles on the first look. What the old ceiling of 4 read
|
|
1753
|
+
* as "a jitter floor by the third or fourth pass" is, measured properly, an
|
|
1754
|
+
* **orbit**: on rung 6 the correction repeats with period 4, so passes 4–7 come
|
|
1755
|
+
* back as 8–11 and again as 12–15, to within 0.02 px. Stopping at 4 stopped
|
|
1756
|
+
* mid-orbit, on whichever phase pass 4 happened to be — and that phase was the
|
|
1757
|
+
* worst of the four, framed 0.063 % off the scale the frames were rendered at and
|
|
1758
|
+
* worth **5 MAE points** on that shot (8.73 against a pinned 3.50, issue #52).
|
|
1759
|
+
*
|
|
1760
|
+
* One more pass reaches the orbit's closest phase and takes the same shot to 5.62;
|
|
1761
|
+
* 6, 8, 12, 16 and 24 passes all return that same viewport, because `chosen`
|
|
1762
|
+
* keeps the closest pass and the orbit has no better one. So the ceiling is raised
|
|
1763
|
+
* to two full periods — enough to see every phase of an orbit this size — and
|
|
1764
|
+
* `fitSeparation` stops the loop the moment it recognises one, which costs the
|
|
1765
|
+
* cycling case one pass rather than four. A shot that is genuinely still
|
|
1766
|
+
* converging is unaffected: it settles and breaks out first.
|
|
1767
|
+
*
|
|
1768
|
+
* ⚠️ This is not a way to reach the right framing. Nothing in an extent fit can
|
|
1769
|
+
* be: at the box `frames.json` records — the one the frames were actually drawn
|
|
1770
|
+
* at — rung 6's edge residual is 0.41 px rms, **worse** than the 0.23 px the orbit
|
|
1771
|
+
* reaches, because the candidate's silhouette genuinely differs and the best fit
|
|
1772
|
+
* of two extents is not the best alignment of two pictures (`fitFraming`, "the
|
|
1773
|
+
* floor, stated plainly"). That is what `frameByDeclaredBox` is for.
|
|
1774
|
+
*/
|
|
1775
|
+
const FRAMING_PASSES = 8;
|
|
1776
|
+
|
|
1777
|
+
/**
|
|
1778
|
+
* How far the candidate's drawn pixels may sit from the reference's and still be
|
|
1779
|
+
* called the same place, in pixels.
|
|
1780
|
+
*
|
|
1781
|
+
* One pixel, and the margin on either side of it is enormous rather than fine.
|
|
1782
|
+
* What this threshold has to separate is a candidate authored in the frames' own
|
|
1783
|
+
* world coordinates from one authored in its own, and those differ by an origin
|
|
1784
|
+
* or a unit — tens to hundreds of pixels — not by a fraction of one. Rung 6's
|
|
1785
|
+
* candidate reads 0.45 px in the declared box; rung 3's mechanical transcription
|
|
1786
|
+
* reads 0.07; a rig scaled by 2 % reads about 5 before the scale is taken out and
|
|
1787
|
+
* about 0.07 after. Nothing measured so far lands between 1 and 5.
|
|
1788
|
+
*/
|
|
1789
|
+
export const COINCIDENT_PIXELS = 1;
|
|
1790
|
+
|
|
1791
|
+
/**
|
|
1792
|
+
* How far the correction may **reach**, as a fraction of the reference's own
|
|
1793
|
+
* content box, for the disagreement to be read as a silhouette rather than as
|
|
1794
|
+
* coordinates.
|
|
1795
|
+
*
|
|
1796
|
+
* ## 🎯 The question this settles, in the terms it is decidable in
|
|
1797
|
+
*
|
|
1798
|
+
* `COINCIDENT_PIXELS` above answers *"is this candidate in the frames' own
|
|
1799
|
+
* coordinates?"* with the worst corner displacement of one similarity fit, and on
|
|
1800
|
+
* a candidate whose silhouette is right that is a clean answer: a different origin
|
|
1801
|
+
* or a different unit is worth tens to hundreds of pixels, a right one 0.07.
|
|
1802
|
+
*
|
|
1803
|
+
* It is **not** clean on a candidate whose silhouette differs at the extremes,
|
|
1804
|
+
* because `fitFraming` registers extent: a union box a few per cent narrower than
|
|
1805
|
+
* the reference's reads as a few per cent of scale, which is arithmetically the
|
|
1806
|
+
* same displacement a units error gives. Rung 7 is that case twice over — the
|
|
1807
|
+
* candidate's setup box lands on the reference's **to the pixel** and it is
|
|
1808
|
+
* refused on all twelve sets — and the refusal is not free: pinned to the declared
|
|
1809
|
+
* box, that candidate reads better on **every one** of its twelve sets, by 0.28 to
|
|
1810
|
+
* 2.01 MAE over the reference's own pixels (issue #194).
|
|
1811
|
+
*
|
|
1812
|
+
* ## Two conditions, because one is not enough — measured
|
|
1813
|
+
*
|
|
1814
|
+
* The fixtures that must keep being refused are `selftest` C04 (the same rig at
|
|
1815
|
+
* 2 % different units, which the framing must stay blind to) and C06 (the same rig
|
|
1816
|
+
* 300 units away, which is the ordinary candidate under the ladder's honesty
|
|
1817
|
+
* rule). Probed at the declared box, per set:
|
|
1818
|
+
*
|
|
1819
|
+
* | candidate | correction | correction ÷ reference box | rms |
|
|
1820
|
+
* | --- | --- | --- | --- |
|
|
1821
|
+
* | rung 3, faithful | 0.08 px | 0.03 % | 0.10 px |
|
|
1822
|
+
* | rung 3, x1.02 units (C04) | 2.60–3.65 px | 1.1–1.6 % | **0.20–0.27 px** |
|
|
1823
|
+
* | rung 3, one part +20 units (C12) | 2.07–2.11 px | 0.9–1.8 % | **0.53–0.83 px** |
|
|
1824
|
+
* | rung 3, +300 units (C06) | 40.32 px | **17.2 %** | 44.15 px |
|
|
1825
|
+
* | rung 7, twelve sets | 1.67–13.02 px | 0.18–1.4 % | **3.32–16.85 px** |
|
|
1826
|
+
*
|
|
1827
|
+
* ⚠️ The obvious single clause — *"take it when the fit admits it cannot explain
|
|
1828
|
+
* what it asks for", `rms ≥ distance`* — was written first and **is wrong**: the
|
|
1829
|
+
* moved rig draws partly outside the declared box, so its content box is truncated
|
|
1830
|
+
* and the fit is garbage with a 44 px residual. That reads 1.09 against the
|
|
1831
|
+
* faithful candidate's own 1.25 and takes the box, framing a rig 300 units away as
|
|
1832
|
+
* though it were in the frames' coordinates (MAE 144). C06 caught it, which is
|
|
1833
|
+
* what C06 is for.
|
|
1834
|
+
*
|
|
1835
|
+
* ⇒ Both halves have to be asserted, and each carries its own margin:
|
|
1836
|
+
*
|
|
1837
|
+
* - **the correction reaches no further than `EXTENT_SPREAD_REACH` of the
|
|
1838
|
+
* reference's own content box** — the two shots are in the same *place*. This is
|
|
1839
|
+
* the half C06 fails: 17.2 % against a 5 % ceiling, with rung 7 at 1.4 %, so the
|
|
1840
|
+
* line is 3.4x clear on both sides. Relative to the shot rather than in pixels,
|
|
1841
|
+
* because a pixel count would have to be re-derived for every frame size;
|
|
1842
|
+
* - **and one similarity still cannot put the two shots on each other**, which is
|
|
1843
|
+
* `rms` past `COINCIDENT_PIXELS` — the same pixel the report already warns at
|
|
1844
|
+
* (*"no single scale and offset puts the two shots on each other — they are
|
|
1845
|
+
* different shapes, not the same shape misframed"*). This is the half that says
|
|
1846
|
+
* *silhouette*, and it is the half C04 fails: a pure difference of units is a
|
|
1847
|
+
* similarity, so the fit absorbs it **exactly** and leaves 0.27 px where rung 7
|
|
1848
|
+
* leaves 3.32 at its quietest. 3.3x clear above, 3.7x clear below.
|
|
1849
|
+
*
|
|
1850
|
+
* ⚠️ **What this deliberately does not do is widen `COINCIDENT_PIXELS`.** A plain
|
|
1851
|
+
* tolerance of "a few px at the extremes" accepts C04, whose whole point is that
|
|
1852
|
+
* the framing must stay blind to units and must not pay a fit's floor for a
|
|
1853
|
+
* candidate with no silhouette disagreement to plead. Both halves fire only on
|
|
1854
|
+
* evidence the fit itself produced, and `DeclaredBoxProbe` makes the report say
|
|
1855
|
+
* which clause decided, with its numbers.
|
|
1856
|
+
*
|
|
1857
|
+
* ⚠️ **What it leaves open, stated rather than hidden**: a candidate whose units
|
|
1858
|
+
* are off by *less* than this reach AND whose silhouette genuinely differs is
|
|
1859
|
+
* taken, and pays that scale error inside the declared box instead of having it
|
|
1860
|
+
* absorbed by a fit. On the shots measured that is a few pixels either way, the
|
|
1861
|
+
* same order as the fit's own floor — and the probe line prints the correction it
|
|
1862
|
+
* declined to apply, so the trade is visible rather than silent. `--viewport` and
|
|
1863
|
+
* `--framing shared` both override it.
|
|
1864
|
+
*/
|
|
1865
|
+
export const EXTENT_SPREAD_REACH = 0.05;
|
|
1866
|
+
|
|
1867
|
+
/**
|
|
1868
|
+
* The two content boxes in world units, each divided by its own render scale.
|
|
1869
|
+
*
|
|
1870
|
+
* `null` without a sidecar: the reference's scale is the only thing that makes
|
|
1871
|
+
* its pixels into units, and a frame set that predates `frames.json` does not
|
|
1872
|
+
* record one. Inventing a default there would print a number that looks measured.
|
|
1873
|
+
*/
|
|
1874
|
+
function extentsOf(
|
|
1875
|
+
fit: FramingFit,
|
|
1876
|
+
candidateScale: number,
|
|
1877
|
+
referenceViewport: Framing | null,
|
|
1878
|
+
): { candidate: Extent; reference: Extent; ratio: number } | null {
|
|
1879
|
+
if (!referenceViewport || candidateScale <= 0 || referenceViewport.scale <= 0) return null;
|
|
1880
|
+
const candidate = {
|
|
1881
|
+
width: boxWidth(fit.candidate) / candidateScale,
|
|
1882
|
+
height: boxHeight(fit.candidate) / candidateScale,
|
|
1883
|
+
};
|
|
1884
|
+
const reference = {
|
|
1885
|
+
width: boxWidth(fit.reference) / referenceViewport.scale,
|
|
1886
|
+
height: boxHeight(fit.reference) / referenceViewport.scale,
|
|
1887
|
+
};
|
|
1888
|
+
const area = reference.width * reference.height;
|
|
1889
|
+
// Area rather than either side: one ratio for a shot whose two axes can differ.
|
|
1890
|
+
const ratio = area > 0 ? Math.sqrt((candidate.width * candidate.height) / area) : 1;
|
|
1891
|
+
return { candidate, reference, ratio };
|
|
1892
|
+
}
|
|
1893
|
+
|
|
1894
|
+
/** How many reference frames the edge level is estimated from. */
|
|
1895
|
+
const LEVEL_SAMPLES = 8;
|
|
1896
|
+
|
|
1897
|
+
/** The edge threshold both sides are measured with — see `EDGE_FRACTION`. */
|
|
1898
|
+
function edgeLevelOf(root: string, pairs: FramePair[], background: RGBA): number {
|
|
1899
|
+
const histogram = new ContrastHistogram();
|
|
1900
|
+
const step = Math.max(1, Math.ceil(pairs.length / LEVEL_SAMPLES));
|
|
1901
|
+
for (let i = 0; i < pairs.length; i += step) histogram.add(readPlateFrom(root, pairs[i].file), background);
|
|
1902
|
+
return histogram.level();
|
|
1903
|
+
}
|
|
1904
|
+
|
|
1905
|
+
/** How many translucent reference frames the refusal names before it counts the rest. */
|
|
1906
|
+
const TRANSLUCENT_NAMED = 3;
|
|
1907
|
+
|
|
1908
|
+
/** The first pixel of a reference frame that is not fully opaque — see `referenceContentBoxes`. */
|
|
1909
|
+
interface TranslucentFrame {
|
|
1910
|
+
file: string;
|
|
1911
|
+
x: number;
|
|
1912
|
+
y: number;
|
|
1913
|
+
alpha: number;
|
|
1914
|
+
}
|
|
1915
|
+
|
|
1916
|
+
/** The first pixel whose alpha is not 255, in row order, or null when the frame is opaque. */
|
|
1917
|
+
function firstTranslucentPixel(plate: Plate): { x: number; y: number; alpha: number } | null {
|
|
1918
|
+
for (let i = 3; i < plate.data.length; i += 4) {
|
|
1919
|
+
if (plate.data[i] !== 255) {
|
|
1920
|
+
const at = (i - 3) / 4;
|
|
1921
|
+
return { x: at % plate.width, y: Math.floor(at / plate.width), alpha: plate.data[i] };
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
return null;
|
|
1925
|
+
}
|
|
1926
|
+
|
|
1927
|
+
/**
|
|
1928
|
+
* Each reference frame's own content box, and a check that they are one grid.
|
|
1929
|
+
*
|
|
1930
|
+
* `translucent`, when given, collects every frame that is not fully opaque. The
|
|
1931
|
+
* content box and the union alpha are found against the background COLOUR and
|
|
1932
|
+
* alpha is never read (`backgroundDistance`), so a transparent pixel is drawn to
|
|
1933
|
+
* both of them whatever its colour bytes say. It is collected here rather than in
|
|
1934
|
+
* a pass of its own because this is where every compared frame is already
|
|
1935
|
+
* decoded, and a frame that is not compared cannot move a figure.
|
|
1936
|
+
*/
|
|
1937
|
+
function referenceContentBoxes(
|
|
1938
|
+
root: string,
|
|
1939
|
+
pairs: FramePair[],
|
|
1940
|
+
background: RGBA,
|
|
1941
|
+
level: number,
|
|
1942
|
+
pixelWidth: number,
|
|
1943
|
+
pixelHeight: number,
|
|
1944
|
+
translucent: TranslucentFrame[] | null,
|
|
1945
|
+
): Array<ContentBox | null> {
|
|
1946
|
+
return pairs.map((pair) => {
|
|
1947
|
+
const plate = readPlateFrom(root, pair.file);
|
|
1948
|
+
if (plate.width !== pixelWidth || plate.height !== pixelHeight) {
|
|
1949
|
+
throw new CheckError(
|
|
1950
|
+
`${pair.file} is ${plate.width}x${plate.height} but the viewport says ${pixelWidth}x${pixelHeight}; ` +
|
|
1951
|
+
'the frames and the sidecar disagree about their own size',
|
|
1952
|
+
);
|
|
1953
|
+
}
|
|
1954
|
+
if (translucent !== null) {
|
|
1955
|
+
const at = firstTranslucentPixel(plate);
|
|
1956
|
+
if (at !== null) translucent.push({ file: pair.file, ...at });
|
|
1957
|
+
}
|
|
1958
|
+
return contentBoxOfPlate(plate, background, level);
|
|
1959
|
+
});
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
/**
|
|
1963
|
+
* The two content boxes of every frame that has both, in one array.
|
|
1964
|
+
*
|
|
1965
|
+
* Per frame rather than unioned, because that is what the fit is made from — see
|
|
1966
|
+
* `fitFraming`. `referenceBoxes` is indexed the same way `prepared.flatMap(pairs)`
|
|
1967
|
+
* is, which is the order it was built in.
|
|
1968
|
+
*/
|
|
1969
|
+
function pairUpBoxes(
|
|
1970
|
+
prepared: PreparedSet[],
|
|
1971
|
+
pages: Map<string, Plate>,
|
|
1972
|
+
viewport: Viewport,
|
|
1973
|
+
background: RGBA,
|
|
1974
|
+
level: number,
|
|
1975
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
1976
|
+
): BoxPair[] {
|
|
1977
|
+
const out: BoxPair[] = [];
|
|
1978
|
+
let at = 0;
|
|
1979
|
+
for (const p of prepared) {
|
|
1980
|
+
for (const pair of p.pairs) {
|
|
1981
|
+
const reference = referenceBoxes[at++];
|
|
1982
|
+
const candidate = frameContentBox(pair.frame, pages, viewport, background, level);
|
|
1983
|
+
if (candidate && reference) out.push({ candidate, reference });
|
|
1984
|
+
}
|
|
1985
|
+
}
|
|
1986
|
+
return out;
|
|
1987
|
+
}
|
|
1988
|
+
|
|
1989
|
+
/** One measured pass of the framing loop. */
|
|
1990
|
+
interface FramingPass {
|
|
1991
|
+
viewport: Viewport;
|
|
1992
|
+
fit: FramingFit;
|
|
1993
|
+
distance: number;
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
/** A framing for one run of sets: the box, and what it still leaves over. */
|
|
1997
|
+
interface FramedSets {
|
|
1998
|
+
viewport: Viewport;
|
|
1999
|
+
report: Omit<FramingReport, 'units'>;
|
|
2000
|
+
}
|
|
2001
|
+
|
|
2002
|
+
/** A chain of passes and why it stopped. */
|
|
2003
|
+
interface FramingChain {
|
|
2004
|
+
passes: FramingPass[];
|
|
2005
|
+
settled: boolean;
|
|
2006
|
+
cycled: boolean;
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
/**
|
|
2010
|
+
* Render → measure → correct, from one starting viewport, until it stops.
|
|
2011
|
+
*
|
|
2012
|
+
* ## Why it iterates
|
|
2013
|
+
*
|
|
2014
|
+
* The correction is measured on a render, and applying it changes the render it
|
|
2015
|
+
* was measured on. One pass leaves the candidate close; a second measures what is
|
|
2016
|
+
* left. It stops as soon as the correction is the identity to within
|
|
2017
|
+
* `SETTLED_PIXELS`, which for a faithful candidate is the first look.
|
|
2018
|
+
*
|
|
2019
|
+
* ## And why it also watches for an orbit
|
|
2020
|
+
*
|
|
2021
|
+
* When the fit has no fixed point on a shot — which happens whenever the
|
|
2022
|
+
* candidate's silhouette genuinely differs, because the fit registers extent and
|
|
2023
|
+
* extent is not alignment — the sequence does not wander and does not converge. It
|
|
2024
|
+
* **cycles**, and every further pass re-measures a state it has already been in at
|
|
2025
|
+
* the cost of a full re-render of every frame. `fitSeparation` recognises that in
|
|
2026
|
+
* one comparison per pass, so a cycling shot stops one pass after its orbit closes
|
|
2027
|
+
* instead of burning the whole budget, and the report can say which of the two
|
|
2028
|
+
* happened. Rung 6 cycles with period 4 (issue #52).
|
|
2029
|
+
*/
|
|
2030
|
+
function runFramingChain(
|
|
2031
|
+
seed: Viewport,
|
|
2032
|
+
prepared: PreparedSet[],
|
|
2033
|
+
pages: Map<string, Plate>,
|
|
2034
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
2035
|
+
background: RGBA,
|
|
2036
|
+
level: number,
|
|
2037
|
+
pixelWidth: number,
|
|
2038
|
+
pixelHeight: number,
|
|
2039
|
+
cap: number,
|
|
2040
|
+
): FramingChain {
|
|
2041
|
+
let viewport = seed;
|
|
2042
|
+
const passes: FramingPass[] = [];
|
|
2043
|
+
for (let pass = 1; pass <= cap; pass++) {
|
|
2044
|
+
const boxes = pairUpBoxes(prepared, pages, viewport, background, level, referenceBoxes);
|
|
2045
|
+
// A viewport the candidate draws nothing into ends the chain rather than
|
|
2046
|
+
// failing it, and both ways of reaching one are real. The declared box gets
|
|
2047
|
+
// there on its first pass whenever the candidate is authored somewhere else
|
|
2048
|
+
// entirely — rung 1's `drop` candidate is nowhere near the frames' own world
|
|
2049
|
+
// box — which is the declared path being refused, not an error. And a chain
|
|
2050
|
+
// that does not converge can walk its own box off its content later on, where
|
|
2051
|
+
// the answer is the closest pass already measured. The caller decides what an
|
|
2052
|
+
// empty chain means; only the fitted path treats it as a failure.
|
|
2053
|
+
if (boxes.length === 0) return { passes, settled: false, cycled: false };
|
|
2054
|
+
const fit = fitFraming(boxes);
|
|
2055
|
+
const cycled = passes.some((seen) => fitSeparation(fit, seen.fit) < CYCLE_PIXELS);
|
|
2056
|
+
passes.push({ viewport, fit, distance: fitDistance(fit) });
|
|
2057
|
+
if (fitIsSettled(fit)) return { passes, settled: true, cycled: false };
|
|
2058
|
+
if (cycled) return { passes, settled: false, cycled: true };
|
|
2059
|
+
viewport = applyFit(viewport, fit, pixelWidth, pixelHeight);
|
|
2060
|
+
}
|
|
2061
|
+
return { passes, settled: false, cycled: false };
|
|
2062
|
+
}
|
|
2063
|
+
|
|
2064
|
+
/**
|
|
2065
|
+
* The pass whose correction is closest to the identity.
|
|
2066
|
+
*
|
|
2067
|
+
* ⚠️ The **closest** pass rather than the last, and the viewport handed back is
|
|
2068
|
+
* therefore one that was actually MEASURED, with the fit beside it being what it
|
|
2069
|
+
* still leaves over — never a correction applied on the way out and never looked
|
|
2070
|
+
* at. Near the answer the correction can jitter or orbit instead of converging, so
|
|
2071
|
+
* taking the last pass would hand back whichever phase the loop happened to stop
|
|
2072
|
+
* on, and applying one more unverified correction is a coin flip. A report that
|
|
2073
|
+
* describes a viewport nobody rendered is worse than a slightly worse viewport.
|
|
2074
|
+
*/
|
|
2075
|
+
function closestPass(chain: FramingChain): FramingPass {
|
|
2076
|
+
let best = chain.passes[0];
|
|
2077
|
+
for (const pass of chain.passes) if (pass.distance < best.distance) best = pass;
|
|
2078
|
+
return best;
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
/**
|
|
2082
|
+
* Put the candidate's drawn pixels on the reference's drawn pixels.
|
|
2083
|
+
*
|
|
2084
|
+
* Two ways in, and the second is tried first because when it applies it is exact
|
|
2085
|
+
* rather than estimated — see `frameByDeclaredBox`. The fitted path is the general
|
|
2086
|
+
* one and the only one available without a sidecar.
|
|
2087
|
+
*
|
|
2088
|
+
* ## Why the fitted start is trimmed
|
|
2089
|
+
*
|
|
2090
|
+
* The starting box is the union of the posed quads **trimmed to their opaque
|
|
2091
|
+
* texels**, not the quads themselves. It is only a starting point — the framing is
|
|
2092
|
+
* fitted on rendered pixels either way — but the fit's landing point depends on
|
|
2093
|
+
* where it starts, so a start that moved with an invisible margin would leave the
|
|
2094
|
+
* margin able to move the answer after all, by a fraction of a pixel instead of by
|
|
2095
|
+
* two. With the trim, art padded on both sides is byte-identical work: same start,
|
|
2096
|
+
* same passes, same numbers. `selftest` C03 asserts exactly that.
|
|
2097
|
+
*/
|
|
2098
|
+
function frameCandidate(
|
|
2099
|
+
prepared: PreparedSet[],
|
|
2100
|
+
pages: Map<string, Plate>,
|
|
2101
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
2102
|
+
background: RGBA,
|
|
2103
|
+
level: number,
|
|
2104
|
+
pixelWidth: number,
|
|
2105
|
+
pixelHeight: number,
|
|
2106
|
+
referenceViewport: Framing | null,
|
|
2107
|
+
): FramedSets & { probe: DeclaredBoxProbe | null } {
|
|
2108
|
+
const declared = frameByDeclaredBox(
|
|
2109
|
+
prepared,
|
|
2110
|
+
pages,
|
|
2111
|
+
referenceBoxes,
|
|
2112
|
+
background,
|
|
2113
|
+
level,
|
|
2114
|
+
pixelWidth,
|
|
2115
|
+
pixelHeight,
|
|
2116
|
+
referenceViewport,
|
|
2117
|
+
);
|
|
2118
|
+
// The declared-box probe measures every frame in `frames.json`'s own box
|
|
2119
|
+
// whether or not it ends up being used, and that measurement is the only one
|
|
2120
|
+
// taken in a box every set shares. Handing it back is what lets a per-shot run
|
|
2121
|
+
// report `sharedFit` without a second render — see `CheckReport.sharedFit`.
|
|
2122
|
+
if (declared.framed) return { ...declared.framed, probe: declared.probe };
|
|
2123
|
+
|
|
2124
|
+
const chain = runFramingChain(
|
|
2125
|
+
seedFromGeometry(prepared, pages, referenceBoxes, pixelWidth, pixelHeight),
|
|
2126
|
+
prepared,
|
|
2127
|
+
pages,
|
|
2128
|
+
referenceBoxes,
|
|
2129
|
+
background,
|
|
2130
|
+
level,
|
|
2131
|
+
pixelWidth,
|
|
2132
|
+
pixelHeight,
|
|
2133
|
+
FRAMING_PASSES,
|
|
2134
|
+
);
|
|
2135
|
+
if (chain.passes.length === 0) {
|
|
2136
|
+
throw new CheckError('the candidate drew no pixel in any frame that was compared');
|
|
2137
|
+
}
|
|
2138
|
+
const chosen = closestPass(chain);
|
|
2139
|
+
return {
|
|
2140
|
+
viewport: chosen.viewport,
|
|
2141
|
+
report: {
|
|
2142
|
+
fit: chosen.fit,
|
|
2143
|
+
passes: chain.passes.length,
|
|
2144
|
+
settled: chain.settled,
|
|
2145
|
+
source: 'derived',
|
|
2146
|
+
cycled: chain.cycled,
|
|
2147
|
+
agrees: chosen.distance <= COINCIDENT_PIXELS,
|
|
2148
|
+
applied: true,
|
|
2149
|
+
// Filled in by the refined pass, which runs once the box is decided.
|
|
2150
|
+
refinement: null,
|
|
2151
|
+
},
|
|
2152
|
+
probe: declared.probe,
|
|
2153
|
+
};
|
|
2154
|
+
}
|
|
2155
|
+
|
|
2156
|
+
/**
|
|
2157
|
+
* The starting viewport, from the candidate's own posed geometry laid onto the
|
|
2158
|
+
* reference's own drawn extent.
|
|
2159
|
+
*
|
|
2160
|
+
* ## Why the reference's extent and not the frame
|
|
2161
|
+
*
|
|
2162
|
+
* The seed used to scale the candidate's trimmed quads to **fill the frame**, and
|
|
2163
|
+
* that is an assumption about the reference: that the shot its frames show was
|
|
2164
|
+
* framed around itself. Over a whole skeleton root it holds well enough, because
|
|
2165
|
+
* the sidecar's one box was chosen to hold every set. Over one SHORT set it can be
|
|
2166
|
+
* badly wrong — rung 3's `light` covers about half of the box its frames were
|
|
2167
|
+
* rendered in, so filling the frame starts it near 2x too large, and the chain
|
|
2168
|
+
* walks that back by only a few per cent a pass: `--frames <root>/light` on a
|
|
2169
|
+
* candidate in its own coordinates read **MAE 141** with a framing 65 % off, after
|
|
2170
|
+
* spending its whole pass budget (issue #100).
|
|
2171
|
+
*
|
|
2172
|
+
* The reference's own content box is already measured, on the same frames, with
|
|
2173
|
+
* the same predicate — it is what the fit is trying to reach. Starting there costs
|
|
2174
|
+
* nothing and starts the chain where it used to end up: the same shot now settles
|
|
2175
|
+
* on the first or second pass.
|
|
2176
|
+
*
|
|
2177
|
+
* The scale matches the two boxes by **area** rather than by either side, because
|
|
2178
|
+
* a candidate whose silhouette differs has two different side ratios and picking
|
|
2179
|
+
* one of them would seed the chain with that difference as a scale error.
|
|
2180
|
+
*
|
|
2181
|
+
* ⚠️ Falls back to filling the frame when there is no reference box to aim at —
|
|
2182
|
+
* every frame unreadable, or a set with nothing on disk.
|
|
2183
|
+
*/
|
|
2184
|
+
function seedFromGeometry(
|
|
2185
|
+
prepared: PreparedSet[],
|
|
2186
|
+
pages: Map<string, Plate>,
|
|
2187
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
2188
|
+
pixelWidth: number,
|
|
2189
|
+
pixelHeight: number,
|
|
2190
|
+
): Viewport {
|
|
2191
|
+
const quads = trimmedUnionBounds(
|
|
2192
|
+
prepared.map((p) => p.pairs.map((pair) => pair.frame)),
|
|
2193
|
+
pages,
|
|
2194
|
+
);
|
|
2195
|
+
if (!Number.isFinite(quads.minX)) {
|
|
2196
|
+
throw new CheckError('the candidate posed no drawable attachment in any frame that was compared');
|
|
2197
|
+
}
|
|
2198
|
+
const pad = Math.max(quads.maxX - quads.minX, quads.maxY - quads.minY) * PAD;
|
|
2199
|
+
const world = {
|
|
2200
|
+
minX: quads.minX - pad,
|
|
2201
|
+
minY: quads.minY - pad,
|
|
2202
|
+
maxX: quads.maxX + pad,
|
|
2203
|
+
maxY: quads.maxY + pad,
|
|
2204
|
+
};
|
|
2205
|
+
const worldWidth = world.maxX - world.minX;
|
|
2206
|
+
const worldHeight = world.maxY - world.minY;
|
|
2207
|
+
|
|
2208
|
+
let reference: ContentBox | null = null;
|
|
2209
|
+
for (const box of referenceBoxes) reference = unionBoxes(reference, box);
|
|
2210
|
+
if (reference !== null && boxWidth(reference) > 0 && boxHeight(reference) > 0) {
|
|
2211
|
+
// The reference's box is the trimmed content, so pad it the same way the
|
|
2212
|
+
// candidate's is before the two are matched — otherwise the pad is a scale
|
|
2213
|
+
// error the chain then has to undo.
|
|
2214
|
+
const refWidth = boxWidth(reference) * (1 + 2 * PAD);
|
|
2215
|
+
const refHeight = boxHeight(reference) * (1 + 2 * PAD);
|
|
2216
|
+
const scale = Math.sqrt((refWidth * refHeight) / (worldWidth * worldHeight));
|
|
2217
|
+
const left = reference.left - boxWidth(reference) * PAD;
|
|
2218
|
+
const top = reference.top - boxHeight(reference) * PAD;
|
|
2219
|
+
// `projector` is px = (wx - minX)·k and py = (maxY - wy)·k, so putting the
|
|
2220
|
+
// candidate's padded box on the reference's is one subtraction per axis.
|
|
2221
|
+
const minX = world.minX - left / scale;
|
|
2222
|
+
const maxY = world.maxY + top / scale;
|
|
2223
|
+
return viewportOfSize(minX, maxY - pixelHeight / scale, pixelWidth / scale, pixelHeight / scale, scale, pixelWidth, pixelHeight);
|
|
2224
|
+
}
|
|
2225
|
+
|
|
2226
|
+
const maxSide = Math.max(pixelWidth, pixelHeight);
|
|
2227
|
+
return viewportOfSize(
|
|
2228
|
+
world.minX,
|
|
2229
|
+
world.minY,
|
|
2230
|
+
worldWidth,
|
|
2231
|
+
worldHeight,
|
|
2232
|
+
maxSide / Math.max(worldWidth, worldHeight),
|
|
2233
|
+
pixelWidth,
|
|
2234
|
+
pixelHeight,
|
|
2235
|
+
);
|
|
2236
|
+
}
|
|
2237
|
+
|
|
2238
|
+
/**
|
|
2239
|
+
* The box `frames.json` records, used as the candidate's own — when, and only
|
|
2240
|
+
* when, the candidate's pixels are measured to land in it.
|
|
2241
|
+
*
|
|
2242
|
+
* ## 🔒 This is not reading the answer
|
|
2243
|
+
*
|
|
2244
|
+
* The thing the honesty rule protects is the reference **skeleton** — its bones,
|
|
2245
|
+
* its keys, its curves — and none of that is here. `frames.json` is a sidecar
|
|
2246
|
+
* `check` already reads, whose `viewport` it already prints, and which
|
|
2247
|
+
* [`docs/AUTHORING.md`](../docs/AUTHORING.md) §9 already tells an author to hand
|
|
2248
|
+
* back through `--viewport`. What changes is only that the tool now *checks* the
|
|
2249
|
+
* condition the guide asks the author to assert, instead of requiring them to
|
|
2250
|
+
* notice it and type it.
|
|
2251
|
+
*
|
|
2252
|
+
* ## Why a declared box beats a fitted one
|
|
2253
|
+
*
|
|
2254
|
+
* A fit is an estimate with a floor. `fitFraming` registers two shots by their
|
|
2255
|
+
* **extent**, so when the candidate's silhouette genuinely differs the best fit of
|
|
2256
|
+
* the extents is about a third of a pixel away from the best alignment of the
|
|
2257
|
+
* pictures — and on a small high-contrast shot a third of a pixel is several MAE.
|
|
2258
|
+
* Rung 6 measures that floor as a 5-point tax: 8.73 fitted against 3.50 in the box
|
|
2259
|
+
* the frames were actually drawn at, with every content box, residual and rms
|
|
2260
|
+
* under the method's own noise (issue #52).
|
|
2261
|
+
*
|
|
2262
|
+
* The declared box has no such floor. It is not an estimate of where the frames
|
|
2263
|
+
* were drawn; it is where they were drawn. So the only question is whether it
|
|
2264
|
+
* applies to *this* candidate, and that is one measurement: render the candidate
|
|
2265
|
+
* into the declared box, fit, and keep the box when the correction it asks for is
|
|
2266
|
+
* under `COINCIDENT_PIXELS`. Nothing is corrected and nothing is iterated —
|
|
2267
|
+
* either the candidate is in the frames' coordinates or it is not.
|
|
2268
|
+
*
|
|
2269
|
+
* ⚠️ **Correcting the declared box makes it worse, measured.** The obvious extra
|
|
2270
|
+
* step — accept it, then run the usual passes from there to polish — was written
|
|
2271
|
+
* and measured, and it walks off the answer: rung 5's candidate, whose author
|
|
2272
|
+
* matched the reference's world box by hand, reads **4.35** at the declared box and
|
|
2273
|
+
* **6.24** after three refining passes, and 4.35 is the figure `fitFraming` records
|
|
2274
|
+
* as this shot's correct framing. Rung 6 reads 3.50 at the box and drifts the same
|
|
2275
|
+
* way. The refinement is the extent fit, and the extent fit is exactly what the
|
|
2276
|
+
* declared box is here to avoid.
|
|
2277
|
+
*
|
|
2278
|
+
* A candidate authored in its own coordinates — the ordinary case, and the one the
|
|
2279
|
+
* ladder's honesty rule guarantees — misses by a wide margin and is framed by the
|
|
2280
|
+
* fitted path exactly as before. Rung 3's candidate put its origin on the pendulum's
|
|
2281
|
+
* pivot and the reference put its own elsewhere; in the declared box that candidate
|
|
2282
|
+
* reports MAE 146/255, and its fit says so long before the pixels are ever compared.
|
|
2283
|
+
* Rung 1's `drop` candidate draws nothing at all in the declared box.
|
|
2284
|
+
*
|
|
2285
|
+
* ⚠️ So does a rig in the frames' coordinates at **different units**, which is a
|
|
2286
|
+
* choice the framing must stay blind to (`selftest` C04). It is refused here and
|
|
2287
|
+
* framed by the fitted path, where the blindness lives — and it therefore pays the
|
|
2288
|
+
* fit's floor where a same-units candidate does not. Recovering the unit from the
|
|
2289
|
+
* fit and scaling the declared box by it was measured too: on a rig scaled by 2 %
|
|
2290
|
+
* it recovers 1.0196 against a true 1.02 and lands two thirds of the way back, which
|
|
2291
|
+
* is more machinery for a case no candidate in the corpus has and still not exact.
|
|
2292
|
+
*/
|
|
2293
|
+
function frameByDeclaredBox(
|
|
2294
|
+
prepared: PreparedSet[],
|
|
2295
|
+
pages: Map<string, Plate>,
|
|
2296
|
+
referenceBoxes: Array<ContentBox | null>,
|
|
2297
|
+
background: RGBA,
|
|
2298
|
+
level: number,
|
|
2299
|
+
pixelWidth: number,
|
|
2300
|
+
pixelHeight: number,
|
|
2301
|
+
referenceViewport: Framing | null,
|
|
2302
|
+
): { framed: FramedSets | null; probe: DeclaredBoxProbe | null } {
|
|
2303
|
+
if (!referenceViewport || referenceViewport.scale <= 0) return { framed: null, probe: null };
|
|
2304
|
+
const viewport = viewportOfSize(
|
|
2305
|
+
referenceViewport.x,
|
|
2306
|
+
referenceViewport.y,
|
|
2307
|
+
referenceViewport.width,
|
|
2308
|
+
referenceViewport.height,
|
|
2309
|
+
referenceViewport.scale,
|
|
2310
|
+
pixelWidth,
|
|
2311
|
+
pixelHeight,
|
|
2312
|
+
);
|
|
2313
|
+
const boxes = pairUpBoxes(prepared, pages, viewport, background, level, referenceBoxes);
|
|
2314
|
+
// Nothing drawn in the declared box is the loudest possible "not these
|
|
2315
|
+
// coordinates", not a failure: rung 1's `drop` candidate is nowhere near it.
|
|
2316
|
+
if (boxes.length === 0) {
|
|
2317
|
+
return {
|
|
2318
|
+
framed: null,
|
|
2319
|
+
probe: { distance: 0, rms: 0, reach: 0, frames: 0, taken: false, clause: 'no-pixels' },
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
2322
|
+
const fit = fitFraming(boxes);
|
|
2323
|
+
const distance = fitDistance(fit);
|
|
2324
|
+
const reach = EXTENT_SPREAD_REACH * Math.max(boxWidth(fit.reference), boxHeight(fit.reference));
|
|
2325
|
+
// The two clauses, in the order they are decidable — see `EXTENT_SPREAD_REACH`.
|
|
2326
|
+
// The first is a measurement of coincidence; the second needs BOTH of its halves,
|
|
2327
|
+
// and the comment above the constant records the fixture that proves it.
|
|
2328
|
+
const clause =
|
|
2329
|
+
distance <= COINCIDENT_PIXELS
|
|
2330
|
+
? 'coincident'
|
|
2331
|
+
: distance <= reach && fit.rms > COINCIDENT_PIXELS
|
|
2332
|
+
? 'extent-spread'
|
|
2333
|
+
: 'coordinates';
|
|
2334
|
+
const probe: DeclaredBoxProbe = {
|
|
2335
|
+
distance,
|
|
2336
|
+
rms: fit.rms,
|
|
2337
|
+
reach,
|
|
2338
|
+
frames: fit.frames,
|
|
2339
|
+
taken: clause !== 'coordinates',
|
|
2340
|
+
clause,
|
|
2341
|
+
};
|
|
2342
|
+
if (!probe.taken) return { framed: null, probe };
|
|
2343
|
+
return {
|
|
2344
|
+
framed: {
|
|
2345
|
+
viewport,
|
|
2346
|
+
report: {
|
|
2347
|
+
fit,
|
|
2348
|
+
passes: 1,
|
|
2349
|
+
settled: fitIsSettled(fit),
|
|
2350
|
+
source: 'declared',
|
|
2351
|
+
cycled: false,
|
|
2352
|
+
// A measurement, not a constant: under the extent-spread clause the two
|
|
2353
|
+
// content boxes do NOT coincide, and a report that said they did would be
|
|
2354
|
+
// hiding the very disagreement that clause was invoked to name.
|
|
2355
|
+
agrees: distance <= COINCIDENT_PIXELS,
|
|
2356
|
+
applied: true,
|
|
2357
|
+
refinement: null,
|
|
2358
|
+
},
|
|
2359
|
+
},
|
|
2360
|
+
probe,
|
|
2361
|
+
};
|
|
2362
|
+
}
|
|
2363
|
+
|
|
2364
|
+
// ---------------------------------------------------------------------------
|
|
2365
|
+
// the MAE-refined final pass — see `FramingRefinement`
|
|
2366
|
+
// ---------------------------------------------------------------------------
|
|
2367
|
+
|
|
2368
|
+
/**
|
|
2369
|
+
* The reference-denominator MAE of these sets at every whole-pixel offset in a
|
|
2370
|
+
* ±`REFINE_RADIUS` window, in the box they were framed in.
|
|
2371
|
+
*
|
|
2372
|
+
* One render per frame, whatever the window's size: `OffsetScan` owns why that is
|
|
2373
|
+
* exact for whole pixels. The reference plates are read again here and again by
|
|
2374
|
+
* `checkOneSet`; decoding a PNG twice is cheaper than holding a set's frames in
|
|
2375
|
+
* memory, which on the ladder's largest set is a quarter of a gigabyte.
|
|
2376
|
+
*/
|
|
2377
|
+
function scanOffsets(
|
|
2378
|
+
root: string,
|
|
2379
|
+
prepared: PreparedSet[],
|
|
2380
|
+
pages: Map<string, Plate>,
|
|
2381
|
+
viewport: Viewport,
|
|
2382
|
+
background: RGBA,
|
|
2383
|
+
): OffsetGain | null {
|
|
2384
|
+
const scan = new OffsetScan(REFINE_RADIUS);
|
|
2385
|
+
for (const p of prepared) {
|
|
2386
|
+
for (const pair of p.pairs) {
|
|
2387
|
+
scan.add(
|
|
2388
|
+
renderFrame(pair.frame, pages, viewport, background),
|
|
2389
|
+
frameGeometry(pair.frame, pages, viewport).coverage,
|
|
2390
|
+
readPlateFrom(root, pair.file),
|
|
2391
|
+
background,
|
|
2392
|
+
);
|
|
2393
|
+
}
|
|
2394
|
+
}
|
|
2395
|
+
return scan.best();
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
/**
|
|
2399
|
+
* What to do with the offset a scan found, given how the box was chosen.
|
|
2400
|
+
*
|
|
2401
|
+
* The whole judgement of `FramingRefinement` in one function, so that "a fitted
|
|
2402
|
+
* box is corrected and an exact one is only reported" is a single readable rule
|
|
2403
|
+
* rather than a condition spread over three call sites.
|
|
2404
|
+
*/
|
|
2405
|
+
function refinementOf(gain: OffsetGain | null, source: FramingSource): FramingRefinement | null {
|
|
2406
|
+
if (gain === null) return null;
|
|
2407
|
+
const shape = {
|
|
2408
|
+
dx: gain.dx,
|
|
2409
|
+
dy: gain.dy,
|
|
2410
|
+
before: gain.identity,
|
|
2411
|
+
after: gain.best,
|
|
2412
|
+
radius: gain.radius,
|
|
2413
|
+
frames: gain.frames,
|
|
2414
|
+
};
|
|
2415
|
+
if (gain.dx === 0 && gain.dy === 0) return { ...shape, applied: false, declined: 'identity' };
|
|
2416
|
+
if (!offsetIsWorthApplying(gain)) return { ...shape, applied: false, declined: 'below-threshold' };
|
|
2417
|
+
if (source === 'pinned') return { ...shape, applied: false, declined: 'pinned' };
|
|
2418
|
+
if (source === 'declared') return { ...shape, applied: false, declined: 'box-is-exact' };
|
|
2419
|
+
return { ...shape, applied: true, declined: null };
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
/** One set's framing with the refined pass run over it, and applied if it may be. */
|
|
2423
|
+
function refined(
|
|
2424
|
+
root: string,
|
|
2425
|
+
prepared: PreparedSet[],
|
|
2426
|
+
pages: Map<string, Plate>,
|
|
2427
|
+
framing: SetFraming,
|
|
2428
|
+
background: RGBA,
|
|
2429
|
+
pixelWidth: number,
|
|
2430
|
+
pixelHeight: number,
|
|
2431
|
+
): SetFraming {
|
|
2432
|
+
if (framing.fit === null) return framing;
|
|
2433
|
+
const refinement = refinementOf(
|
|
2434
|
+
scanOffsets(root, prepared, pages, framing.viewport, background),
|
|
2435
|
+
framing.fit.source,
|
|
2436
|
+
);
|
|
2437
|
+
const viewport =
|
|
2438
|
+
refinement !== null && refinement.applied
|
|
2439
|
+
? shiftViewport(framing.viewport, refinement.dx, refinement.dy, pixelWidth, pixelHeight)
|
|
2440
|
+
: framing.viewport;
|
|
2441
|
+
return { ...framing, viewport, fit: { ...framing.fit, refinement } };
|
|
2442
|
+
}
|
|
2443
|
+
|
|
2444
|
+
/**
|
|
2445
|
+
* What the framing pass concluded, in the words that tell the three cases apart.
|
|
2446
|
+
*
|
|
2447
|
+
* The distinction the report used to be missing (issue #52): "did not settle, and
|
|
2448
|
+
* the two shots are nevertheless in the same place" is the tool reaching its own
|
|
2449
|
+
* floor, "did not settle, and they are not" is a finding about the candidate, and
|
|
2450
|
+
* "the correction is cycling" says more passes cannot change either answer.
|
|
2451
|
+
*/
|
|
2452
|
+
function framingNotes(report: Omit<FramingReport, 'units'>, probe: DeclaredBoxProbe | null): string[] {
|
|
2453
|
+
if (report.source === 'declared' && probe !== null && probe.clause === 'extent-spread') {
|
|
2454
|
+
// ⭐ The tolerance states itself. A clause that widens what `check` accepts
|
|
2455
|
+
// and says nothing about having done so would be the same defect the
|
|
2456
|
+
// framing line was built to close: a number without its convention.
|
|
2457
|
+
return [
|
|
2458
|
+
`the candidate's world box was taken from ${FRAMES_SIDECAR} rather than fitted even though the two content ` +
|
|
2459
|
+
`boxes do NOT coincide — the EXTENT-SPREAD tolerance engaged. A fit at that box asks to move the candidate ` +
|
|
2460
|
+
`${probe.distance.toFixed(2)} px, which reaches less than the ${probe.reach.toFixed(2)} px this clause allows ` +
|
|
2461
|
+
`(${(EXTENT_SPREAD_REACH * 100).toFixed(0)}% of the reference's own content box), and it leaves ` +
|
|
2462
|
+
`${probe.rms.toFixed(2)} px rms across the frames' edges — so no single scale and offset puts the two shots ` +
|
|
2463
|
+
`on each other, over ${probe.frames} frame(s). A difference of origin or of units IS a similarity and would ` +
|
|
2464
|
+
"be absorbed exactly; a residual this size is a SILHOUETTE difference at the extremes, and the frames' own " +
|
|
2465
|
+
"box is where the frames were drawn whatever this candidate's outline does out there. ⇒ Read the `union " +
|
|
2466
|
+
'residual` and `aspect` on the framing line as the finding; the box below is exact and carries no fit floor. ' +
|
|
2467
|
+
'Pass --viewport to override, or --framing shared to measure every set in the fitted box instead.',
|
|
2468
|
+
];
|
|
2469
|
+
}
|
|
2470
|
+
if (report.source === 'declared') {
|
|
2471
|
+
return [
|
|
2472
|
+
`the candidate's world box was taken from ${FRAMES_SIDECAR} rather than fitted, because rendering it into ` +
|
|
2473
|
+
`that box put its own drawn pixels on the reference's to within ${report.fit.rms.toFixed(2)} px rms — so ` +
|
|
2474
|
+
"the candidate is authored in the frames' own coordinates, measured rather than assumed, and the box they " +
|
|
2475
|
+
'were rendered at is exact where a fit of it is an estimate. The framing line below is still measured; what ' +
|
|
2476
|
+
`it leaves over is the extent fit's own floor${report.settled ? '' : ', which is why it does not read as the identity'}. ` +
|
|
2477
|
+
'Pass --viewport to override.',
|
|
2478
|
+
];
|
|
2479
|
+
}
|
|
2480
|
+
const refused =
|
|
2481
|
+
probe === null || probe.clause !== 'coordinates'
|
|
2482
|
+
? []
|
|
2483
|
+
: [
|
|
2484
|
+
`${FRAMES_SIDECAR}'s own box was refused for this set: a fit at it asks to move the candidate ` +
|
|
2485
|
+
`${probe.distance.toFixed(2)} px — ${probe.distance > probe.reach ? `further than the ${probe.reach.toFixed(2)} px the extent-spread tolerance reaches` : `and explains all but ${probe.rms.toFixed(2)} px rms of it, which a silhouette difference cannot be`}` +
|
|
2486
|
+
` — over ${probe.frames} frame(s). That is what a different origin or a different unit looks like, so ` +
|
|
2487
|
+
'this candidate is in its own coordinates and is framed by the fitted path, which is blind to units by ' +
|
|
2488
|
+
"design and pays the extent fit's own floor for it.",
|
|
2489
|
+
];
|
|
2490
|
+
if (report.settled) return refused;
|
|
2491
|
+
const how = report.cycled
|
|
2492
|
+
? `the framing correction fell into a repeating orbit after ${report.passes} pass(es) rather than settling, so ` +
|
|
2493
|
+
'more passes cannot help: the fit has no fixed point on this shot'
|
|
2494
|
+
: `the framing did not settle in ${report.passes} pass(es)`;
|
|
2495
|
+
return [
|
|
2496
|
+
...refused,
|
|
2497
|
+
report.agrees
|
|
2498
|
+
? `${how}. The two content boxes nevertheless agree to within ${report.fit.rms.toFixed(2)} px rms, so this is ` +
|
|
2499
|
+
"the fit's own floor and not a shape mismatch — the fit registers extent, and on a silhouette that differs " +
|
|
2500
|
+
'anywhere the best fit of the extents is not the best alignment of the pictures. Pass --viewport to pin the ' +
|
|
2501
|
+
'box when you know your own coordinates.'
|
|
2502
|
+
: `${how}, and the two content boxes do not agree either — the correction below is what is left over after ` +
|
|
2503
|
+
'the closest pass. A residual much larger than a pixel means the two shots are different shapes, which is ' +
|
|
2504
|
+
'a finding about the candidate rather than about the loop.',
|
|
2505
|
+
];
|
|
2506
|
+
}
|
|
2507
|
+
|
|
2508
|
+
// ---------------------------------------------------------------------------
|
|
2509
|
+
// posing the candidate against one frame set
|
|
2510
|
+
// ---------------------------------------------------------------------------
|
|
2511
|
+
|
|
2512
|
+
/** One reference frame and the candidate frame that shares its index. */
|
|
2513
|
+
interface FramePair {
|
|
2514
|
+
index: number;
|
|
2515
|
+
file: string;
|
|
2516
|
+
frame: Frame;
|
|
2517
|
+
}
|
|
2518
|
+
|
|
2519
|
+
/** One frame set, posed and paired up with the frames on disk. */
|
|
2520
|
+
interface PreparedSet {
|
|
2521
|
+
set: FrameSet;
|
|
2522
|
+
candidateAnimation: string | null;
|
|
2523
|
+
candidateFrames: number;
|
|
2524
|
+
referenceFrames: number;
|
|
2525
|
+
pairs: FramePair[];
|
|
2526
|
+
/**
|
|
2527
|
+
* Every frame the candidate sampled, in index order — not only the ones a file
|
|
2528
|
+
* on disk pairs with.
|
|
2529
|
+
*
|
|
2530
|
+
* The contact sheet holds a tile for every SAMPLED frame, so a set that commits
|
|
2531
|
+
* two stills out of 311 needs all 311 poses to be compared against it
|
|
2532
|
+
* (`SheetCheck`). Kept as poses rather than plates: a `Frame` is geometry, and
|
|
2533
|
+
* holding 311 rendered plates of a busy shot is a quarter of a gigabyte.
|
|
2534
|
+
*/
|
|
2535
|
+
frames: Frame[];
|
|
2536
|
+
notes: string[];
|
|
2537
|
+
/** Set when nothing could be compared at all, saying why. */
|
|
2538
|
+
missing: string | null;
|
|
2539
|
+
}
|
|
2540
|
+
|
|
2541
|
+
function prepareSet(
|
|
2542
|
+
root: string,
|
|
2543
|
+
set: FrameSet,
|
|
2544
|
+
poser: Poser,
|
|
2545
|
+
/** The skeleton's animation names in its own order — see the call. */
|
|
2546
|
+
have: readonly string[],
|
|
2547
|
+
as: string | undefined,
|
|
2548
|
+
poseOptions: PoseOptions | undefined,
|
|
2549
|
+
): PreparedSet {
|
|
2550
|
+
const notes: string[] = [];
|
|
2551
|
+
const wanted = as ?? set.animation;
|
|
2552
|
+
const disk = framesOnDisk(root, set.dir);
|
|
2553
|
+
|
|
2554
|
+
if (wanted !== null && !have.includes(wanted)) {
|
|
2555
|
+
return {
|
|
2556
|
+
set,
|
|
2557
|
+
candidateAnimation: null,
|
|
2558
|
+
candidateFrames: 0,
|
|
2559
|
+
referenceFrames: disk.length,
|
|
2560
|
+
pairs: [],
|
|
2561
|
+
frames: [],
|
|
2562
|
+
notes: [],
|
|
2563
|
+
missing:
|
|
2564
|
+
`the candidate has no animation called ${JSON.stringify(wanted)} — it has [${have.join(', ') || 'none'}]. ` +
|
|
2565
|
+
'Nothing was compared for this set; name the candidate animation with --as <name> if it is called ' +
|
|
2566
|
+
'something else.',
|
|
2567
|
+
};
|
|
2568
|
+
}
|
|
2569
|
+
|
|
2570
|
+
let candidateFrames: Frame[];
|
|
2571
|
+
let candidateAnimation: string | null;
|
|
2572
|
+
if (wanted === null) {
|
|
2573
|
+
if (have.length > 0) {
|
|
2574
|
+
notes.push(
|
|
2575
|
+
`these frames are a setup pose (the skeleton that made them has no animation), but the candidate has ` +
|
|
2576
|
+
`[${have.join(', ')}] — the setup pose is what was compared`,
|
|
2577
|
+
);
|
|
2578
|
+
}
|
|
2579
|
+
candidateFrames = sampleSetupPose(poser, poseOptions);
|
|
2580
|
+
candidateAnimation = null;
|
|
2581
|
+
} else {
|
|
2582
|
+
candidateFrames = sampleAnimation(poser, wanted, set.fps, poseOptions);
|
|
2583
|
+
candidateAnimation = wanted;
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
if (candidateFrames.length !== set.sampled) {
|
|
2587
|
+
notes.push(
|
|
2588
|
+
`the candidate samples to ${candidateFrames.length} frame(s) at ${set.fps} fps where the reference sampled ` +
|
|
2589
|
+
`${set.sampled} — the two animations do not last the same time, and only the frames both have were compared`,
|
|
2590
|
+
);
|
|
2591
|
+
}
|
|
2592
|
+
|
|
2593
|
+
const byIndex = new Map<number, Frame>();
|
|
2594
|
+
for (const frame of candidateFrames) byIndex.set(frame.index, frame);
|
|
2595
|
+
const pairs: FramePair[] = [];
|
|
2596
|
+
for (const { index, file } of disk) {
|
|
2597
|
+
const frame = byIndex.get(index);
|
|
2598
|
+
if (frame) pairs.push({ index, file, frame });
|
|
2599
|
+
}
|
|
2600
|
+
if (pairs.length === 0 && disk.length > 0) {
|
|
2601
|
+
notes.push(`none of the ${disk.length} reference frame(s) has a candidate frame at the same index`);
|
|
2602
|
+
}
|
|
2603
|
+
return {
|
|
2604
|
+
set,
|
|
2605
|
+
candidateAnimation,
|
|
2606
|
+
candidateFrames: candidateFrames.length,
|
|
2607
|
+
referenceFrames: disk.length,
|
|
2608
|
+
pairs,
|
|
2609
|
+
frames: candidateFrames,
|
|
2610
|
+
notes,
|
|
2611
|
+
missing: null,
|
|
2612
|
+
};
|
|
2613
|
+
}
|
|
2614
|
+
|
|
2615
|
+
/**
|
|
2616
|
+
* What the "nothing to frame against" refusal adds when the sets can say why.
|
|
2617
|
+
*
|
|
2618
|
+
* The one reason a whole run has no reference box that a reader can act on is a
|
|
2619
|
+
* name that matched no candidate animation, and `prepareSet` already composed
|
|
2620
|
+
* that per set — then the refusal fired before any set was reported, so the one
|
|
2621
|
+
* sentence that named the fix never reached the reader (issue #842). This says
|
|
2622
|
+
* where each name came from, because the fix differs: a directory name is
|
|
2623
|
+
* renamed or overridden, an `--as` is corrected. Empty when every set matched,
|
|
2624
|
+
* so the refusal's other causes keep the sentence they had.
|
|
2625
|
+
*/
|
|
2626
|
+
function nothingToFrameWhy(
|
|
2627
|
+
prepared: PreparedSet[],
|
|
2628
|
+
have: string[],
|
|
2629
|
+
as: string | undefined,
|
|
2630
|
+
sidecar: boolean,
|
|
2631
|
+
): string {
|
|
2632
|
+
const unmatched = prepared.filter((p) => p.missing !== null);
|
|
2633
|
+
if (unmatched.length === 0) return '';
|
|
2634
|
+
const declared = `it declares [${have.join(', ') || 'none'}]`;
|
|
2635
|
+
if (as !== undefined) {
|
|
2636
|
+
return (
|
|
2637
|
+
`: --as ${JSON.stringify(as)} names no animation of the candidate — ${declared}. --as takes one candidate ` +
|
|
2638
|
+
'animation name, the one these frames show'
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
const tried = unmatched.map((p) => {
|
|
2642
|
+
const name = JSON.stringify(p.set.animation);
|
|
2643
|
+
if (sidecar) return `${name} (what ${FRAMES_SIDECAR} records for set ${JSON.stringify(p.set.dir)})`;
|
|
2644
|
+
return p.set.dir === p.set.animation
|
|
2645
|
+
? `${name} (the directory's own name)`
|
|
2646
|
+
: `${name} (the directory ${JSON.stringify(p.set.dir)}, its @fps suffix dropped)`;
|
|
2647
|
+
});
|
|
2648
|
+
return (
|
|
2649
|
+
`: the frames were matched to a candidate animation by name, and the candidate has no animation called ` +
|
|
2650
|
+
`${tried.join(', ')} — ${declared}. Pass --as <name> with the one these frames show, or name the ` +
|
|
2651
|
+
'directory after it'
|
|
2652
|
+
);
|
|
2653
|
+
}
|
|
2654
|
+
|
|
2655
|
+
function checkOneSet(
|
|
2656
|
+
root: string,
|
|
2657
|
+
prepared: PreparedSet,
|
|
2658
|
+
posable: Pick<Posable, 'pages'>,
|
|
2659
|
+
framing: SetFraming,
|
|
2660
|
+
background: RGBA,
|
|
2661
|
+
chains: BoneChain[],
|
|
2662
|
+
/** Slot name → its index in `chains`. */
|
|
2663
|
+
chainOfSlot: Map<string, number>,
|
|
2664
|
+
/** The texture-only substitution to attribute against, when one was asked for. */
|
|
2665
|
+
substitution: TextureSubstitution | null,
|
|
2666
|
+
/** Region names it could not reach, unioned across every set by the caller. */
|
|
2667
|
+
unmatched: Set<string>,
|
|
2668
|
+
/** Where to keep the rasters of the frames the report will list — `null` keeps none. */
|
|
2669
|
+
plates: CheckPlates | null,
|
|
2670
|
+
): AnimationCheck {
|
|
2671
|
+
const { set } = prepared;
|
|
2672
|
+
const viewport = framing.viewport;
|
|
2673
|
+
const blank: AnimationCheck = {
|
|
2674
|
+
dir: set.dir,
|
|
2675
|
+
animation: set.animation,
|
|
2676
|
+
candidateAnimation: prepared.candidateAnimation,
|
|
2677
|
+
fps: set.fps,
|
|
2678
|
+
referenceFrames: prepared.referenceFrames,
|
|
2679
|
+
candidateFrames: prepared.candidateFrames,
|
|
2680
|
+
compared: 0,
|
|
2681
|
+
meanMae: 0,
|
|
2682
|
+
meanMaeReference: 0,
|
|
2683
|
+
drawnRatio: 1,
|
|
2684
|
+
meanMaeFrame: 0,
|
|
2685
|
+
textureFloor: null,
|
|
2686
|
+
worstMae: 0,
|
|
2687
|
+
worstMaeFrame: -1,
|
|
2688
|
+
worstDrift: 0,
|
|
2689
|
+
worstDriftFrame: -1,
|
|
2690
|
+
worstDriftSlot: null,
|
|
2691
|
+
framesWithoutDrift: 0,
|
|
2692
|
+
changePairs: 0,
|
|
2693
|
+
changeDisagreements: 0,
|
|
2694
|
+
worstChangeFrame: -1,
|
|
2695
|
+
chains: [],
|
|
2696
|
+
chainDenominator: 0,
|
|
2697
|
+
unattributedError: 0,
|
|
2698
|
+
frames: [],
|
|
2699
|
+
viewport: framingOfViewport(viewport),
|
|
2700
|
+
framing: framing.how,
|
|
2701
|
+
framingFit: framing.fit,
|
|
2702
|
+
declaredBox: framing.declaredBox,
|
|
2703
|
+
sheet: null,
|
|
2704
|
+
notes: prepared.missing ? [prepared.missing] : [...framing.notes, ...prepared.notes],
|
|
2705
|
+
};
|
|
2706
|
+
if (prepared.missing !== null || prepared.pairs.length === 0) return blank;
|
|
2707
|
+
|
|
2708
|
+
const frames: FrameCheck[] = [];
|
|
2709
|
+
let maeSum = 0;
|
|
2710
|
+
let maeReferenceSum = 0;
|
|
2711
|
+
let drawnRatioSum = 0;
|
|
2712
|
+
let maeFrameSum = 0;
|
|
2713
|
+
let worstMae = 0;
|
|
2714
|
+
let worstMaeFrame = -1;
|
|
2715
|
+
let worstDrift = 0;
|
|
2716
|
+
let worstDriftFrame = -1;
|
|
2717
|
+
let worstDriftSlot: string | null = null;
|
|
2718
|
+
let framesWithoutDrift = 0;
|
|
2719
|
+
let changePairs = 0;
|
|
2720
|
+
let changeDisagreements = 0;
|
|
2721
|
+
let worstChangeFrame = -1;
|
|
2722
|
+
let worstChangeGap = 0;
|
|
2723
|
+
// The previous frame's two plates, kept so each side can be compared against
|
|
2724
|
+
// ITSELF a frame earlier. Both are already rendered or read for this frame, so
|
|
2725
|
+
// holding one frame of each costs one extra plate and no extra work.
|
|
2726
|
+
let previous: { index: number; candidate: Plate; reference: Plate } | null = null;
|
|
2727
|
+
const tally: ChainTally = {
|
|
2728
|
+
error: new Array<number>(chains.length).fill(0),
|
|
2729
|
+
pixels: new Array<number>(chains.length).fill(0),
|
|
2730
|
+
unattributed: 0,
|
|
2731
|
+
total: 0,
|
|
2732
|
+
};
|
|
2733
|
+
|
|
2734
|
+
// One page map for the substituted render, holding both sides: a piece whose
|
|
2735
|
+
// region the substituting atlas does not have keeps its own page, and a page
|
|
2736
|
+
// that happens to share a filename with one of the candidate's cannot shadow it
|
|
2737
|
+
// because `substituteTexture` prefixes every name it writes.
|
|
2738
|
+
const floorPages = substitution === null ? null : new Map([...posable.pages, ...substitution.pages]);
|
|
2739
|
+
const floorSum = { floor: 0, aboveFloor: 0, floorReference: 0, aboveFloorReference: 0 };
|
|
2740
|
+
plates?.begin(set.dir, prepared.pairs.length);
|
|
2741
|
+
|
|
2742
|
+
for (const { index, file, frame } of prepared.pairs) {
|
|
2743
|
+
const reference = readPlateFrom(root, file);
|
|
2744
|
+
const rendered = renderFrame(frame, posable.pages, viewport, background);
|
|
2745
|
+
let floorPlate: Plate | null = null;
|
|
2746
|
+
if (substitution !== null && floorPages !== null) {
|
|
2747
|
+
const swapped = substituteTexture(frame, substitution);
|
|
2748
|
+
for (const name of swapped.unmatched) unmatched.add(name);
|
|
2749
|
+
floorPlate = renderFrame(swapped.frame, floorPages, viewport, background);
|
|
2750
|
+
}
|
|
2751
|
+
const { check, coverage } = checkOneFrame(
|
|
2752
|
+
index,
|
|
2753
|
+
file,
|
|
2754
|
+
frame,
|
|
2755
|
+
posable.pages,
|
|
2756
|
+
viewport,
|
|
2757
|
+
background,
|
|
2758
|
+
reference,
|
|
2759
|
+
rendered,
|
|
2760
|
+
chainOfSlot,
|
|
2761
|
+
tally,
|
|
2762
|
+
floorPlate,
|
|
2763
|
+
);
|
|
2764
|
+
check.change = previous && previous.index === index - 1 ? frameChange(previous, rendered, reference) : null;
|
|
2765
|
+
previous = { index, candidate: rendered, reference };
|
|
2766
|
+
// After the change is known, because whether a frame will be listed depends
|
|
2767
|
+
// on it — see `CheckPlates.offer`.
|
|
2768
|
+
plates?.offer(set.dir, check, { reference, candidate: rendered, coverage });
|
|
2769
|
+
frames.push(check);
|
|
2770
|
+
maeSum += check.mae;
|
|
2771
|
+
maeReferenceSum += check.maeReference;
|
|
2772
|
+
if (check.textureFloor !== null) {
|
|
2773
|
+
floorSum.floor += check.textureFloor.floor;
|
|
2774
|
+
floorSum.aboveFloor += check.textureFloor.aboveFloor;
|
|
2775
|
+
floorSum.floorReference += check.textureFloor.floorReference;
|
|
2776
|
+
floorSum.aboveFloorReference += check.textureFloor.aboveFloorReference;
|
|
2777
|
+
}
|
|
2778
|
+
drawnRatioSum += check.referencePixels === 0 ? 1 : check.candidatePixels / check.referencePixels;
|
|
2779
|
+
maeFrameSum += check.maeFrame;
|
|
2780
|
+
if (check.attributed === 0) framesWithoutDrift++;
|
|
2781
|
+
if (check.change) {
|
|
2782
|
+
changePairs++;
|
|
2783
|
+
if (check.change.verdict !== 'agrees') {
|
|
2784
|
+
changeDisagreements++;
|
|
2785
|
+
const gap = Math.abs(check.change.candidate - check.change.reference);
|
|
2786
|
+
if (gap > worstChangeGap) {
|
|
2787
|
+
worstChangeGap = gap;
|
|
2788
|
+
worstChangeFrame = index;
|
|
2789
|
+
}
|
|
2790
|
+
}
|
|
2791
|
+
}
|
|
2792
|
+
if (check.mae > worstMae) {
|
|
2793
|
+
worstMae = check.mae;
|
|
2794
|
+
worstMaeFrame = index;
|
|
2795
|
+
}
|
|
2796
|
+
if (check.worstDrift !== null && check.worstDrift > worstDrift) {
|
|
2797
|
+
worstDrift = check.worstDrift;
|
|
2798
|
+
worstDriftFrame = index;
|
|
2799
|
+
worstDriftSlot = check.worstSlot;
|
|
2800
|
+
}
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
// The frames the set does not commit as files, against the sheet that holds
|
|
2804
|
+
// them — see `SheetCheck`. After the frame loop, because it is measured in the
|
|
2805
|
+
// box that loop was measured in.
|
|
2806
|
+
const sheet = checkAgainstSheet(root, prepared, posable, viewport, background);
|
|
2807
|
+
|
|
2808
|
+
return {
|
|
2809
|
+
...blank,
|
|
2810
|
+
compared: frames.length,
|
|
2811
|
+
sheet: sheet.sheet,
|
|
2812
|
+
chains: chainChecks(chains, frames, tally),
|
|
2813
|
+
chainDenominator: tally.total,
|
|
2814
|
+
unattributedError: tally.unattributed,
|
|
2815
|
+
meanMae: maeSum / frames.length,
|
|
2816
|
+
meanMaeReference: maeReferenceSum / frames.length,
|
|
2817
|
+
drawnRatio: drawnRatioSum / frames.length,
|
|
2818
|
+
meanMaeFrame: maeFrameSum / frames.length,
|
|
2819
|
+
textureFloor:
|
|
2820
|
+
substitution === null
|
|
2821
|
+
? null
|
|
2822
|
+
: {
|
|
2823
|
+
floor: floorSum.floor / frames.length,
|
|
2824
|
+
aboveFloor: floorSum.aboveFloor / frames.length,
|
|
2825
|
+
floorReference: floorSum.floorReference / frames.length,
|
|
2826
|
+
aboveFloorReference: floorSum.aboveFloorReference / frames.length,
|
|
2827
|
+
},
|
|
2828
|
+
worstMae,
|
|
2829
|
+
worstMaeFrame,
|
|
2830
|
+
worstDrift,
|
|
2831
|
+
worstDriftFrame,
|
|
2832
|
+
worstDriftSlot,
|
|
2833
|
+
framesWithoutDrift,
|
|
2834
|
+
changePairs,
|
|
2835
|
+
changeDisagreements,
|
|
2836
|
+
worstChangeFrame,
|
|
2837
|
+
frames,
|
|
2838
|
+
notes: [...framing.notes, ...prepared.notes, ...sheet.notes],
|
|
2839
|
+
};
|
|
2840
|
+
}
|
|
2841
|
+
|
|
2842
|
+
/**
|
|
2843
|
+
* How far a channel must move for a pixel to count as having **changed**.
|
|
2844
|
+
*
|
|
2845
|
+
* The same threshold `isContent` uses to decide there is anything there at all,
|
|
2846
|
+
* and for the same reason: below it the difference is the rasteriser's own last
|
|
2847
|
+
* bit, and a measure that counts those reports every frame as moving.
|
|
2848
|
+
*/
|
|
2849
|
+
export const CHANGE_TOLERANCE = BACKGROUND_TOLERANCE;
|
|
2850
|
+
|
|
2851
|
+
/**
|
|
2852
|
+
* How many times more one side has to move than the other to be a disagreement,
|
|
2853
|
+
* when **both** of them moved.
|
|
2854
|
+
*
|
|
2855
|
+
* Four, with `CHANGE_EXCESS` beside it, because a ratio alone means nothing on
|
|
2856
|
+
* small counts. Together the two read: *four times as much, and at least two dozen
|
|
2857
|
+
* pixels more.*
|
|
2858
|
+
*/
|
|
2859
|
+
export const CHANGE_RATIO = 4;
|
|
2860
|
+
|
|
2861
|
+
/**
|
|
2862
|
+
* ...and how many pixels more, when both sides moved.
|
|
2863
|
+
*
|
|
2864
|
+
* Measured rather than picked. Across the corpus's two mechanically faithful
|
|
2865
|
+
* transcriptions — the same skeleton on both sides, where the true answer is
|
|
2866
|
+
* "identical" — the largest excess between two adjacent frames that clears
|
|
2867
|
+
* `CHANGE_RATIO` at all is **12 px**, on one pair out of 152. Twenty-four is double
|
|
2868
|
+
* that, and the case it has to keep is rung 6's broken plateau at 91 against 3.
|
|
2869
|
+
*/
|
|
2870
|
+
export const CHANGE_EXCESS = 24;
|
|
2871
|
+
|
|
2872
|
+
/**
|
|
2873
|
+
* Did this side move materially more than that one?
|
|
2874
|
+
*
|
|
2875
|
+
* ⭐ **Stillness is categorical and gets no floor**, which is the reason this is a
|
|
2876
|
+
* predicate and not a threshold. A held pose is held *exactly* — rung 6's reference
|
|
2877
|
+
* is pixel-identical across f64-f67 — and a one-frame event is as small as the
|
|
2878
|
+
* thing it reveals, which on that same shot is **three pixels**. A floor big enough
|
|
2879
|
+
* to be safe about a moving frame would be big enough to hide both, so the two
|
|
2880
|
+
* regimes are separated instead: against a still side, moving at all is the
|
|
2881
|
+
* finding; against a moving side, `CHANGE_RATIO` and `CHANGE_EXCESS` apply.
|
|
2882
|
+
* Measured: neither faithful transcription has a single pair where one side is
|
|
2883
|
+
* still and the other is not.
|
|
2884
|
+
*/
|
|
2885
|
+
function disagrees(mine: number, theirs: number): boolean {
|
|
2886
|
+
if (mine === 0) return false;
|
|
2887
|
+
if (theirs === 0) return true;
|
|
2888
|
+
return mine > theirs * CHANGE_RATIO && mine - theirs > CHANGE_EXCESS;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
/**
|
|
2892
|
+
* One frame against the frame before it, on each side, and what that says.
|
|
2893
|
+
*
|
|
2894
|
+
* ⚠️ Over the **whole frame**, and not over either side's content mask the way the
|
|
2895
|
+
* MAE is. The omission is deliberate: a change is a change wherever it happens, and
|
|
2896
|
+
* masking it would hide precisely the case where one side draws something the other
|
|
2897
|
+
* does not — which is half of what this measure exists for. A one-frame reveal
|
|
2898
|
+
* appears on background pixels by definition.
|
|
2899
|
+
*/
|
|
2900
|
+
function frameChange(
|
|
2901
|
+
previous: { index: number; candidate: Plate; reference: Plate },
|
|
2902
|
+
candidate: Plate,
|
|
2903
|
+
reference: Plate,
|
|
2904
|
+
): FrameChange {
|
|
2905
|
+
const mine = plateDelta(previous.candidate, candidate);
|
|
2906
|
+
const theirs = plateDelta(previous.reference, reference);
|
|
2907
|
+
return {
|
|
2908
|
+
previous: previous.index,
|
|
2909
|
+
candidate: mine.pixels,
|
|
2910
|
+
reference: theirs.pixels,
|
|
2911
|
+
candidateMae: mine.mae,
|
|
2912
|
+
referenceMae: theirs.mae,
|
|
2913
|
+
verdict: disagrees(mine.pixels, theirs.pixels)
|
|
2914
|
+
? 'moves'
|
|
2915
|
+
: disagrees(theirs.pixels, mine.pixels)
|
|
2916
|
+
? 'holds'
|
|
2917
|
+
: 'agrees',
|
|
2918
|
+
};
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
/**
|
|
2922
|
+
* Changed pixels and mean absolute RGB difference between two plates of one size.
|
|
2923
|
+
*
|
|
2924
|
+
* Straight over `Plate.data` rather than through `Plate.get`, because this runs
|
|
2925
|
+
* twice per compared frame over the whole grid and `get` allocates a four-element
|
|
2926
|
+
* array per pixel. On the ladder's largest set that difference is most of what this
|
|
2927
|
+
* measure costs.
|
|
2928
|
+
*/
|
|
2929
|
+
function plateDelta(before: Plate, after: Plate): { pixels: number; mae: number } {
|
|
2930
|
+
const a = before.data;
|
|
2931
|
+
const b = after.data;
|
|
2932
|
+
const count = after.width * after.height;
|
|
2933
|
+
let pixels = 0;
|
|
2934
|
+
let sum = 0;
|
|
2935
|
+
for (let i = 0; i < count * 4; i += 4) {
|
|
2936
|
+
const dr = Math.abs(a[i] - b[i]);
|
|
2937
|
+
const dg = Math.abs(a[i + 1] - b[i + 1]);
|
|
2938
|
+
const db = Math.abs(a[i + 2] - b[i + 2]);
|
|
2939
|
+
sum += dr + dg + db;
|
|
2940
|
+
if (dr > CHANGE_TOLERANCE || dg > CHANGE_TOLERANCE || db > CHANGE_TOLERANCE) pixels++;
|
|
2941
|
+
}
|
|
2942
|
+
return { pixels, mae: sum / 3 / count };
|
|
2943
|
+
}
|
|
2944
|
+
|
|
2945
|
+
/**
|
|
2946
|
+
* Roll a set's frames up into one row per chain — the dashboard's rows.
|
|
2947
|
+
*
|
|
2948
|
+
* A chain that owns no slot is left out: it has nothing to attribute, and a row of
|
|
2949
|
+
* dashes in every set is noise in a table read sixteen times. The roster at the
|
|
2950
|
+
* foot of the report still lists it, so the account of where every bone went stays
|
|
2951
|
+
* complete.
|
|
2952
|
+
*/
|
|
2953
|
+
function chainChecks(chains: BoneChain[], frames: FrameCheck[], tally: ChainTally): ChainCheck[] {
|
|
2954
|
+
const out: ChainCheck[] = [];
|
|
2955
|
+
chains.forEach((chain, index) => {
|
|
2956
|
+
if (chain.slots.length === 0) return;
|
|
2957
|
+
const own = new Set(chain.slots);
|
|
2958
|
+
const drew = new Set<string>();
|
|
2959
|
+
let worstDrift = 0;
|
|
2960
|
+
let worstDriftSlot: string | null = null;
|
|
2961
|
+
let worstDriftFrame = -1;
|
|
2962
|
+
let driftSum = 0;
|
|
2963
|
+
let driftSamples = 0;
|
|
2964
|
+
let driftFrames = 0;
|
|
2965
|
+
for (const frame of frames) {
|
|
2966
|
+
let sampled = false;
|
|
2967
|
+
for (const track of frame.slots) {
|
|
2968
|
+
if (!own.has(track.slot)) continue;
|
|
2969
|
+
if (track.candidate !== null) drew.add(track.slot);
|
|
2970
|
+
if (!isAttributable(track)) continue;
|
|
2971
|
+
const drift = track.drift as number;
|
|
2972
|
+
driftSum += drift;
|
|
2973
|
+
driftSamples++;
|
|
2974
|
+
sampled = true;
|
|
2975
|
+
if (drift > worstDrift) {
|
|
2976
|
+
worstDrift = drift;
|
|
2977
|
+
worstDriftSlot = track.slot;
|
|
2978
|
+
worstDriftFrame = frame.index;
|
|
2979
|
+
}
|
|
2980
|
+
}
|
|
2981
|
+
if (sampled) driftFrames++;
|
|
2982
|
+
}
|
|
2983
|
+
out.push({
|
|
2984
|
+
chain: chain.name,
|
|
2985
|
+
slots: chain.slots.length,
|
|
2986
|
+
drewSlots: drew.size,
|
|
2987
|
+
worstDrift,
|
|
2988
|
+
worstDriftSlot,
|
|
2989
|
+
worstDriftFrame,
|
|
2990
|
+
meanDrift: driftSamples === 0 ? 0 : driftSum / driftSamples,
|
|
2991
|
+
driftSamples,
|
|
2992
|
+
driftFrames,
|
|
2993
|
+
error: tally.error[index],
|
|
2994
|
+
referencePixels: tally.pixels[index],
|
|
2995
|
+
mae: tally.pixels[index] === 0 ? 0 : tally.error[index] / tally.pixels[index],
|
|
2996
|
+
maeShare: tally.total === 0 ? 0 : tally.error[index] / tally.total,
|
|
2997
|
+
});
|
|
2998
|
+
});
|
|
2999
|
+
return out;
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
/**
|
|
3003
|
+
* A set's error, being split between the candidate's chains as its frames are read.
|
|
3004
|
+
*
|
|
3005
|
+
* Carried across frames rather than parked on each `FrameCheck` because a share is
|
|
3006
|
+
* a fact about the SET — and because a per-frame array of it would land in every
|
|
3007
|
+
* `--json` report and every `bench.json` for a number nobody reads per frame.
|
|
3008
|
+
*/
|
|
3009
|
+
interface ChainTally {
|
|
3010
|
+
/** Absolute difference over reference-drawn pixels attributed to each chain. */
|
|
3011
|
+
error: number[];
|
|
3012
|
+
/** How many such pixels each chain took. */
|
|
3013
|
+
pixels: number[];
|
|
3014
|
+
/** The same, over reference pixels no chain could take — the candidate drew nothing. */
|
|
3015
|
+
unattributed: number;
|
|
3016
|
+
/** Every reference-drawn pixel's difference, chain or not: the share's denominator. */
|
|
3017
|
+
total: number;
|
|
3018
|
+
}
|
|
3019
|
+
|
|
3020
|
+
/** How much a diagonal step costs the chamfer pass below. */
|
|
3021
|
+
const DIAGONAL_STEP = Math.SQRT2;
|
|
3022
|
+
|
|
3023
|
+
/**
|
|
3024
|
+
* Give every pixel of the frame the chain whose ink is nearest to it.
|
|
3025
|
+
*
|
|
3026
|
+
* Two chamfer passes over the owner mask — forward then backward, propagating
|
|
3027
|
+
* (distance, label) together. It is an approximate Euclidean transform and that is
|
|
3028
|
+
* enough: what it decides is which of a handful of well-separated regions a pixel
|
|
3029
|
+
* belongs to, not a distance anybody reads.
|
|
3030
|
+
*
|
|
3031
|
+
* ⚠️ Nearest **ink the candidate drew**, so a chain that draws nothing seeds
|
|
3032
|
+
* nothing and is handed no pixels at all — its share reads 0 % while its slots are
|
|
3033
|
+
* missing entirely. That is why the table prints `drewSlots` beside the share: 0 %
|
|
3034
|
+
* on `0/3 slots` is the loudest row here, not the quietest one.
|
|
3035
|
+
*
|
|
3036
|
+
* The distance comes back with the label because the caller bounds it — see
|
|
3037
|
+
* `chainRadii`.
|
|
3038
|
+
*/
|
|
3039
|
+
function nearestOwner(owner: Int32Array, width: number, height: number): { label: Int32Array; dist: Float32Array } {
|
|
3040
|
+
const label = Int32Array.from(owner);
|
|
3041
|
+
const dist = new Float32Array(width * height);
|
|
3042
|
+
for (let i = 0; i < label.length; i++) dist[i] = label[i] >= 0 ? 0 : Infinity;
|
|
3043
|
+
const relax = (at: number, from: number, step: number): void => {
|
|
3044
|
+
const reach = dist[from] + step;
|
|
3045
|
+
if (reach >= dist[at]) return;
|
|
3046
|
+
dist[at] = reach;
|
|
3047
|
+
label[at] = label[from];
|
|
3048
|
+
};
|
|
3049
|
+
for (let y = 0; y < height; y++) {
|
|
3050
|
+
for (let x = 0; x < width; x++) {
|
|
3051
|
+
const at = y * width + x;
|
|
3052
|
+
if (x > 0) relax(at, at - 1, 1);
|
|
3053
|
+
if (y > 0) {
|
|
3054
|
+
relax(at, at - width, 1);
|
|
3055
|
+
if (x > 0) relax(at, at - width - 1, DIAGONAL_STEP);
|
|
3056
|
+
if (x + 1 < width) relax(at, at - width + 1, DIAGONAL_STEP);
|
|
3057
|
+
}
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
for (let y = height - 1; y >= 0; y--) {
|
|
3061
|
+
for (let x = width - 1; x >= 0; x--) {
|
|
3062
|
+
const at = y * width + x;
|
|
3063
|
+
if (x + 1 < width) relax(at, at + 1, 1);
|
|
3064
|
+
if (y + 1 < height) {
|
|
3065
|
+
relax(at, at + width, 1);
|
|
3066
|
+
if (x + 1 < width) relax(at, at + width + 1, DIAGONAL_STEP);
|
|
3067
|
+
if (x > 0) relax(at, at + width - 1, DIAGONAL_STEP);
|
|
3068
|
+
}
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
return { label, dist };
|
|
3072
|
+
}
|
|
3073
|
+
|
|
3074
|
+
/**
|
|
3075
|
+
* How far each chain's attribution may reach, in frame pixels.
|
|
3076
|
+
*
|
|
3077
|
+
* The same judgement `src/slots.ts` makes about a slot — *past about its own long
|
|
3078
|
+
* side a part no longer overlaps where it was, and something out there is another
|
|
3079
|
+
* object rather than this one moved* — applied to the chain's own drawn box. Past
|
|
3080
|
+
* it, reference ink is left **unattributed** instead of being handed to whichever
|
|
3081
|
+
* chain happens to be nearest.
|
|
3082
|
+
*
|
|
3083
|
+
* ⚠️ This is the bound that keeps the dashboard honest about its own limits, and
|
|
3084
|
+
* it is a bound rather than a fix. Nothing candidate-side can know which part of
|
|
3085
|
+
* the REFERENCE a pixel belonged to; nearest-ink is a good guess while the figure
|
|
3086
|
+
* is roughly in place and a bad one once a part has left. So a part displaced past
|
|
3087
|
+
* its own size stops being blamed on its neighbour and starts showing up in the
|
|
3088
|
+
* `(unattributed)` row, next to the `reference component(s) no slot reaches` count
|
|
3089
|
+
* that says the same thing a different way.
|
|
3090
|
+
*/
|
|
3091
|
+
function chainRadii(
|
|
3092
|
+
footprints: Map<string, Footprint>,
|
|
3093
|
+
chainOfSlot: Map<string, number>,
|
|
3094
|
+
chains: number,
|
|
3095
|
+
): Float64Array {
|
|
3096
|
+
const minX = new Float64Array(chains).fill(Infinity);
|
|
3097
|
+
const minY = new Float64Array(chains).fill(Infinity);
|
|
3098
|
+
const maxX = new Float64Array(chains).fill(-Infinity);
|
|
3099
|
+
const maxY = new Float64Array(chains).fill(-Infinity);
|
|
3100
|
+
for (const [slot, foot] of footprints) {
|
|
3101
|
+
const chain = chainOfSlot.get(slot);
|
|
3102
|
+
if (chain === undefined || foot.pixels === 0) continue;
|
|
3103
|
+
if (foot.minX < minX[chain]) minX[chain] = foot.minX;
|
|
3104
|
+
if (foot.minY < minY[chain]) minY[chain] = foot.minY;
|
|
3105
|
+
if (foot.maxX > maxX[chain]) maxX[chain] = foot.maxX;
|
|
3106
|
+
if (foot.maxY > maxY[chain]) maxY[chain] = foot.maxY;
|
|
3107
|
+
}
|
|
3108
|
+
const out = new Float64Array(chains);
|
|
3109
|
+
for (let i = 0; i < chains; i++) {
|
|
3110
|
+
out[i] = maxX[i] < minX[i] ? -1 : searchRadius(maxX[i] - minX[i], maxY[i] - minY[i]);
|
|
3111
|
+
}
|
|
3112
|
+
return out;
|
|
3113
|
+
}
|
|
3114
|
+
|
|
3115
|
+
function checkOneFrame(
|
|
3116
|
+
index: number,
|
|
3117
|
+
file: string,
|
|
3118
|
+
frame: Frame,
|
|
3119
|
+
pages: Map<string, Plate>,
|
|
3120
|
+
viewport: Viewport,
|
|
3121
|
+
background: RGBA,
|
|
3122
|
+
reference: Plate,
|
|
3123
|
+
/** The candidate's own frame, rendered by the caller — it needs it too. */
|
|
3124
|
+
rendered: Plate,
|
|
3125
|
+
/** Slot name → chain index, for the per-chain split. */
|
|
3126
|
+
chainOfSlot: Map<string, number>,
|
|
3127
|
+
/** Accumulated across the set by the caller — see `ChainTally`. */
|
|
3128
|
+
tally: ChainTally,
|
|
3129
|
+
/**
|
|
3130
|
+
* The same frame drawn through another atlas's texels, when one was asked for —
|
|
3131
|
+
* see `TextureFloor`. `null` is the ordinary case and costs nothing.
|
|
3132
|
+
*/
|
|
3133
|
+
floorPlate: Plate | null,
|
|
3134
|
+
): { check: FrameCheck; coverage: Uint8Array } {
|
|
3135
|
+
const { coverage, footprints, owner } = frameGeometry(frame, pages, viewport, chainOfSlot);
|
|
3136
|
+
// Only worth the transform when something was drawn to be nearest TO.
|
|
3137
|
+
const nearest =
|
|
3138
|
+
owner !== null && owner.some((at) => at >= 0) ? nearestOwner(owner, viewport.width, viewport.height) : null;
|
|
3139
|
+
const radii = chainRadii(footprints, chainOfSlot, tally.error.length);
|
|
3140
|
+
|
|
3141
|
+
let union = 0;
|
|
3142
|
+
let candidatePixels = 0;
|
|
3143
|
+
let referencePixels = 0;
|
|
3144
|
+
let sum = 0;
|
|
3145
|
+
let sumAll = 0;
|
|
3146
|
+
// The decomposition's two numerators, over exactly the pixels `sum` runs over —
|
|
3147
|
+
// one denominator for all three figures is what makes the triangle bound in
|
|
3148
|
+
// `TextureFloor` hold as arithmetic rather than as an approximation.
|
|
3149
|
+
let floorSum = 0;
|
|
3150
|
+
let aboveSum = 0;
|
|
3151
|
+
let floorReferenceSum = 0;
|
|
3152
|
+
let aboveReferenceSum = 0;
|
|
3153
|
+
for (let y = 0; y < viewport.height; y++) {
|
|
3154
|
+
for (let x = 0; x < viewport.width; x++) {
|
|
3155
|
+
const inCandidate = coverage[y * viewport.width + x] === 1;
|
|
3156
|
+
const inReference = isContent(reference, x, y, background);
|
|
3157
|
+
if (inCandidate) candidatePixels++;
|
|
3158
|
+
if (inReference) referencePixels++;
|
|
3159
|
+
const a = rendered.get(x, y);
|
|
3160
|
+
const b = reference.get(x, y);
|
|
3161
|
+
const delta = (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
|
|
3162
|
+
sumAll += delta;
|
|
3163
|
+
if (floorPlate !== null && (inCandidate || inReference)) {
|
|
3164
|
+
const f = floorPlate.get(x, y);
|
|
3165
|
+
const floor = (Math.abs(a[0] - f[0]) + Math.abs(a[1] - f[1]) + Math.abs(a[2] - f[2])) / 3;
|
|
3166
|
+
const above = (Math.abs(f[0] - b[0]) + Math.abs(f[1] - b[1]) + Math.abs(f[2] - b[2])) / 3;
|
|
3167
|
+
floorSum += floor;
|
|
3168
|
+
aboveSum += above;
|
|
3169
|
+
if (inReference) {
|
|
3170
|
+
floorReferenceSum += floor;
|
|
3171
|
+
aboveReferenceSum += above;
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
if (inReference) {
|
|
3175
|
+
// The share's denominator is the reference's own drawn pixels, and the
|
|
3176
|
+
// split is over exactly those — issue #119's lesson, as a partition.
|
|
3177
|
+
tally.total += delta;
|
|
3178
|
+
const at = y * viewport.width + x;
|
|
3179
|
+
const found = nearest === null ? -1 : nearest.label[at];
|
|
3180
|
+
const chain = found >= 0 && nearest !== null && nearest.dist[at] <= radii[found] ? found : -1;
|
|
3181
|
+
if (chain >= 0) {
|
|
3182
|
+
tally.error[chain] += delta;
|
|
3183
|
+
tally.pixels[chain]++;
|
|
3184
|
+
} else {
|
|
3185
|
+
tally.unattributed += delta;
|
|
3186
|
+
}
|
|
3187
|
+
}
|
|
3188
|
+
if (!inCandidate && !inReference) continue;
|
|
3189
|
+
union++;
|
|
3190
|
+
sum += delta;
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
const field = componentField(reference, background);
|
|
3195
|
+
const components = field.components;
|
|
3196
|
+
const { tracks, matchedComponents } = matchSlots(footprints, field, {
|
|
3197
|
+
frame,
|
|
3198
|
+
pages,
|
|
3199
|
+
viewport,
|
|
3200
|
+
background,
|
|
3201
|
+
reference,
|
|
3202
|
+
});
|
|
3203
|
+
|
|
3204
|
+
let worstDrift: number | null = null;
|
|
3205
|
+
let worstSlot: string | null = null;
|
|
3206
|
+
let attributed = 0;
|
|
3207
|
+
let drawn = 0;
|
|
3208
|
+
for (const track of tracks) {
|
|
3209
|
+
if (track.candidate !== null) drawn++;
|
|
3210
|
+
if (!isAttributable(track)) continue;
|
|
3211
|
+
attributed++;
|
|
3212
|
+
if (worstDrift === null || (track.drift as number) > worstDrift) {
|
|
3213
|
+
worstDrift = track.drift;
|
|
3214
|
+
worstSlot = track.slot;
|
|
3215
|
+
}
|
|
3216
|
+
}
|
|
3217
|
+
|
|
3218
|
+
const check: FrameCheck = {
|
|
3219
|
+
index,
|
|
3220
|
+
file,
|
|
3221
|
+
mae: union === 0 ? 0 : sum / union,
|
|
3222
|
+
// The same numerator over a denominator the candidate does not control — see
|
|
3223
|
+
// `FrameCheck.maeReference`. Both figures are already in hand here, which is
|
|
3224
|
+
// why the second one costs nothing to publish.
|
|
3225
|
+
maeReference: referencePixels === 0 ? 0 : sum / referencePixels,
|
|
3226
|
+
maeFrame: sumAll / (viewport.width * viewport.height),
|
|
3227
|
+
unionPixels: union,
|
|
3228
|
+
candidatePixels,
|
|
3229
|
+
referencePixels,
|
|
3230
|
+
components: components.length,
|
|
3231
|
+
unmatchedComponents: components.length - matchedComponents,
|
|
3232
|
+
worstSlot,
|
|
3233
|
+
worstDrift,
|
|
3234
|
+
attributed,
|
|
3235
|
+
drawn,
|
|
3236
|
+
slots: tracks,
|
|
3237
|
+
// Filled in by the caller, which is the only place that has the frame before
|
|
3238
|
+
// this one — see `frameChange`.
|
|
3239
|
+
change: null,
|
|
3240
|
+
textureFloor:
|
|
3241
|
+
floorPlate === null
|
|
3242
|
+
? null
|
|
3243
|
+
: {
|
|
3244
|
+
floor: union === 0 ? 0 : floorSum / union,
|
|
3245
|
+
aboveFloor: union === 0 ? 0 : aboveSum / union,
|
|
3246
|
+
floorReference: referencePixels === 0 ? 0 : floorReferenceSum / referencePixels,
|
|
3247
|
+
aboveFloorReference: referencePixels === 0 ? 0 : aboveReferenceSum / referencePixels,
|
|
3248
|
+
},
|
|
3249
|
+
};
|
|
3250
|
+
// The coverage goes back beside the figures because the union it defines is the
|
|
3251
|
+
// one a `--out` difference pane is drawn over — see `CheckPlates`.
|
|
3252
|
+
return { check, coverage };
|
|
3253
|
+
}
|
|
3254
|
+
|
|
3255
|
+
// ---------------------------------------------------------------------------
|
|
3256
|
+
// the contact sheet — see `SheetCheck`
|
|
3257
|
+
// ---------------------------------------------------------------------------
|
|
3258
|
+
|
|
3259
|
+
/** A sheet's grid, in the terms the tiles are cut out with. */
|
|
3260
|
+
interface SheetGeometry {
|
|
3261
|
+
columns: number;
|
|
3262
|
+
rows: number;
|
|
3263
|
+
tileWidth: number;
|
|
3264
|
+
tileHeight: number;
|
|
3265
|
+
/** Tile pixels per frame pixel. */
|
|
3266
|
+
tileScale: number;
|
|
3267
|
+
}
|
|
3268
|
+
|
|
3269
|
+
/**
|
|
3270
|
+
* A sheet's grid, **measured off the sheet** rather than taken on trust.
|
|
3271
|
+
*
|
|
3272
|
+
* `frames.json` records the frame count and the world box; it does not record the
|
|
3273
|
+
* tile size, because `--tile` is a per-run choice and the sheet's own dimensions
|
|
3274
|
+
* state the answer exactly. With `n` tiles in `c` columns the sheet is
|
|
3275
|
+
* `c·(w+1)+1` by `ceil(n/c)·(h+1)+1`, so a column count either divides both
|
|
3276
|
+
* dimensions exactly or is wrong — and the surviving candidate has to agree with
|
|
3277
|
+
* the frames' own aspect ratio as well, since both tile sides came from one scale.
|
|
3278
|
+
*
|
|
3279
|
+
* `SHEET_COLUMNS` is tried first because it is the contract
|
|
3280
|
+
* `bench/render_reference.ts` writes; the search behind it is what keeps a sheet
|
|
3281
|
+
* rendered by something else readable, and what makes a mismatch a **named
|
|
3282
|
+
* refusal** rather than a silent misread of somebody's grid.
|
|
3283
|
+
*/
|
|
3284
|
+
export function sheetGeometry(
|
|
3285
|
+
sheet: Plate,
|
|
3286
|
+
tiles: number,
|
|
3287
|
+
pixelWidth: number,
|
|
3288
|
+
pixelHeight: number,
|
|
3289
|
+
): SheetGeometry | null {
|
|
3290
|
+
if (tiles <= 0 || pixelWidth <= 0 || pixelHeight <= 0) return null;
|
|
3291
|
+
const candidates = [SHEET_COLUMNS, ...Array.from({ length: Math.min(tiles, 64) }, (_, i) => i + 1)];
|
|
3292
|
+
const slack = 1 / Math.max(pixelWidth, pixelHeight);
|
|
3293
|
+
let best: { geometry: SheetGeometry; error: number } | null = null;
|
|
3294
|
+
for (const columns of candidates) {
|
|
3295
|
+
if (columns > tiles) continue;
|
|
3296
|
+
const across = sheet.width - SHEET_GAP;
|
|
3297
|
+
const rows = Math.ceil(tiles / columns);
|
|
3298
|
+
const down = sheet.height - SHEET_GAP;
|
|
3299
|
+
if (across % columns !== 0 || down % rows !== 0) continue;
|
|
3300
|
+
const tileWidth = across / columns - SHEET_GAP;
|
|
3301
|
+
const tileHeight = down / rows - SHEET_GAP;
|
|
3302
|
+
if (tileWidth < 1 || tileHeight < 1) continue;
|
|
3303
|
+
// Both tile sides are one scale, rounded — so the two ratios agree to within
|
|
3304
|
+
// the rounding, and a grid that does not is a different grid.
|
|
3305
|
+
const error = Math.abs(tileWidth / pixelWidth - tileHeight / pixelHeight);
|
|
3306
|
+
if (error > slack) continue;
|
|
3307
|
+
const geometry = { columns, rows, tileWidth, tileHeight, tileScale: tileWidth / pixelWidth };
|
|
3308
|
+
if (best === null || error < best.error) best = { geometry, error };
|
|
3309
|
+
if (columns === SHEET_COLUMNS) break;
|
|
3310
|
+
}
|
|
3311
|
+
return best === null ? null : best.geometry;
|
|
3312
|
+
}
|
|
3313
|
+
|
|
3314
|
+
/**
|
|
3315
|
+
* The label burned into a tile's corner, as a box to leave out of the comparison.
|
|
3316
|
+
*
|
|
3317
|
+
* `bench/render_reference.ts` paints the frame's index at `(2, 2)` in the tile at
|
|
3318
|
+
* scale 1, and the candidate does not draw it. Left in, it would add the same
|
|
3319
|
+
* constant to every tile and a bigger one to four-digit frames than to one-digit
|
|
3320
|
+
* ones — a difference that is a fact about the labeller. The box is derived from
|
|
3321
|
+
* the font's own metrics, with a pixel of margin, rather than measured once and
|
|
3322
|
+
* written down.
|
|
3323
|
+
*/
|
|
3324
|
+
function labelBox(index: number): { width: number; height: number } {
|
|
3325
|
+
return { width: 2 + textWidth(String(index), 1) + 1, height: 2 + GLYPH_H + 1 };
|
|
3326
|
+
}
|
|
3327
|
+
|
|
3328
|
+
/**
|
|
3329
|
+
* One frame set against its contact sheet, tile by tile.
|
|
3330
|
+
*
|
|
3331
|
+
* The candidate is rendered into the SAME world box the set was framed in, at the
|
|
3332
|
+
* sheet's own scale — so a set framed by `frames.json`'s own box is compared here
|
|
3333
|
+
* with no correction at all, and a set framed by a fit carries that fit (which,
|
|
3334
|
+
* for a stills-plus-sheet set, was measured on the stills). The report says which.
|
|
3335
|
+
*/
|
|
3336
|
+
function checkAgainstSheet(
|
|
3337
|
+
root: string,
|
|
3338
|
+
prepared: PreparedSet,
|
|
3339
|
+
posable: Pick<Posable, 'pages'>,
|
|
3340
|
+
viewport: Viewport,
|
|
3341
|
+
background: RGBA,
|
|
3342
|
+
): { sheet: SheetCheck | null; notes: string[] } {
|
|
3343
|
+
const { set } = prepared;
|
|
3344
|
+
const file = join(root, set.dir, SHEET_FILE);
|
|
3345
|
+
if (!existsSync(file)) return { sheet: null, notes: [] };
|
|
3346
|
+
const onDisk = framesOnDisk(root, set.dir).length;
|
|
3347
|
+
// Every sampled frame already has a file of its own: the sheet is the same
|
|
3348
|
+
// pictures again, smaller, and measuring them twice would just report the
|
|
3349
|
+
// resampling.
|
|
3350
|
+
if (onDisk >= set.sampled) return { sheet: null, notes: [] };
|
|
3351
|
+
if (prepared.frames.length === 0) return { sheet: null, notes: [] };
|
|
3352
|
+
|
|
3353
|
+
const plate = readPlateFrom(root, file);
|
|
3354
|
+
const geometry = sheetGeometry(plate, set.sampled, viewport.width, viewport.height);
|
|
3355
|
+
if (geometry === null) {
|
|
3356
|
+
return {
|
|
3357
|
+
sheet: null,
|
|
3358
|
+
notes: [
|
|
3359
|
+
`${file} is ${plate.width}x${plate.height} px, which is not a grid of ${set.sampled} tile(s) at the ` +
|
|
3360
|
+
`${viewport.width}x${viewport.height} aspect of these frames — so the whole shot was NOT compared, only ` +
|
|
3361
|
+
`the ${onDisk} still(s) on disk. Re-render the set with bench/render_reference.ts if the sheet is stale.`,
|
|
3362
|
+
],
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
const { columns, tileWidth, tileHeight, tileScale } = geometry;
|
|
3367
|
+
const tileViewport = viewportOfSize(
|
|
3368
|
+
viewport.minX,
|
|
3369
|
+
viewport.minY,
|
|
3370
|
+
viewport.maxX - viewport.minX,
|
|
3371
|
+
viewport.maxY - viewport.minY,
|
|
3372
|
+
viewport.scale * tileScale,
|
|
3373
|
+
tileWidth,
|
|
3374
|
+
tileHeight,
|
|
3375
|
+
);
|
|
3376
|
+
const compared = Math.min(set.sampled, prepared.frames.length);
|
|
3377
|
+
let maeSum = 0;
|
|
3378
|
+
let maeReferenceSum = 0;
|
|
3379
|
+
let worstMae = 0;
|
|
3380
|
+
let worstTile = -1;
|
|
3381
|
+
const per: Array<{ index: number; mae: number }> = [];
|
|
3382
|
+
for (let index = 0; index < compared; index++) {
|
|
3383
|
+
const frame = prepared.frames[index];
|
|
3384
|
+
const rendered = renderFrame(frame, posable.pages, tileViewport, background);
|
|
3385
|
+
const ox = SHEET_GAP + (index % columns) * (tileWidth + SHEET_GAP);
|
|
3386
|
+
const oy = SHEET_GAP + Math.floor(index / columns) * (tileHeight + SHEET_GAP);
|
|
3387
|
+
const label = labelBox(index);
|
|
3388
|
+
let union = 0;
|
|
3389
|
+
let referencePixels = 0;
|
|
3390
|
+
let sum = 0;
|
|
3391
|
+
for (let y = 0; y < tileHeight; y++) {
|
|
3392
|
+
for (let x = 0; x < tileWidth; x++) {
|
|
3393
|
+
if (y < label.height && x < label.width) continue;
|
|
3394
|
+
const sx = ox + x;
|
|
3395
|
+
const sy = oy + y;
|
|
3396
|
+
if (sx >= plate.width || sy >= plate.height) continue;
|
|
3397
|
+
const inReference = isContent(plate, sx, sy, background);
|
|
3398
|
+
const inCandidate = isContent(rendered, x, y, background);
|
|
3399
|
+
if (inReference) referencePixels++;
|
|
3400
|
+
if (!inReference && !inCandidate) continue;
|
|
3401
|
+
const a = rendered.get(x, y);
|
|
3402
|
+
const b = plate.get(sx, sy);
|
|
3403
|
+
union++;
|
|
3404
|
+
sum += (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
|
|
3405
|
+
}
|
|
3406
|
+
}
|
|
3407
|
+
const mae = union === 0 ? 0 : sum / union;
|
|
3408
|
+
maeSum += mae;
|
|
3409
|
+
maeReferenceSum += referencePixels === 0 ? 0 : sum / referencePixels;
|
|
3410
|
+
per.push({ index, mae });
|
|
3411
|
+
if (mae > worstMae) {
|
|
3412
|
+
worstMae = mae;
|
|
3413
|
+
worstTile = index;
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
per.sort((a, b) => b.mae - a.mae);
|
|
3417
|
+
return {
|
|
3418
|
+
sheet: {
|
|
3419
|
+
file,
|
|
3420
|
+
columns,
|
|
3421
|
+
tileWidth,
|
|
3422
|
+
tileHeight,
|
|
3423
|
+
tileScale,
|
|
3424
|
+
tiles: set.sampled,
|
|
3425
|
+
compared,
|
|
3426
|
+
meanMae: compared === 0 ? 0 : maeSum / compared,
|
|
3427
|
+
meanMaeReference: compared === 0 ? 0 : maeReferenceSum / compared,
|
|
3428
|
+
worstMae,
|
|
3429
|
+
worstTile,
|
|
3430
|
+
worst: per.slice(0, WORST_FRAMES),
|
|
3431
|
+
},
|
|
3432
|
+
notes: [],
|
|
3433
|
+
};
|
|
3434
|
+
}
|
|
3435
|
+
|
|
3436
|
+
/** The sheet block, as the lines an author reads after the frame table. */
|
|
3437
|
+
function sheetLines(sheet: SheetCheck | null): string[] {
|
|
3438
|
+
if (sheet === null) return [];
|
|
3439
|
+
const worst = sheet.worst
|
|
3440
|
+
.map((tile) => `f${String(tile.index).padStart(4, '0')}=${tile.mae.toFixed(1)}`)
|
|
3441
|
+
.join(' ');
|
|
3442
|
+
return [
|
|
3443
|
+
` sheet ${sheet.compared} of ${sheet.tiles} tile(s) of ${basename(sheet.file)} at ` +
|
|
3444
|
+
`${sheet.tileWidth}x${sheet.tileHeight}px in ${sheet.columns} column(s) MAE mean ${f2(sheet.meanMae)} ` +
|
|
3445
|
+
`worst ${f2(sheet.worstMae)} ${worstAt(sheet.worstTile, sheet.compared, 'tile(s)')} ` +
|
|
3446
|
+
`(over the reference's own pixels, mean ${f2(sheet.meanMaeReference)})`,
|
|
3447
|
+
' ⤷ the frames this set does not commit as files. The candidate is sampled at the set\'s own ' +
|
|
3448
|
+
"rate and rendered into the same box the frames above were, at the sheet's scale. Read the " +
|
|
3449
|
+
'series, not the mean: flat is framing or art, a spike is timing at that moment.',
|
|
3450
|
+
` ⤷ worst ${sheet.worst.length}: ${worst}`,
|
|
3451
|
+
];
|
|
3452
|
+
}
|
|
3453
|
+
|
|
3454
|
+
// ---------------------------------------------------------------------------
|
|
3455
|
+
// the report
|
|
3456
|
+
// ---------------------------------------------------------------------------
|
|
3457
|
+
|
|
3458
|
+
/**
|
|
3459
|
+
* How much more than the reference a set may draw before `check` calls it
|
|
3460
|
+
* overdraw, as a ratio of drawn pixels.
|
|
3461
|
+
*
|
|
3462
|
+
* ## Why this direction needs its own warning
|
|
3463
|
+
*
|
|
3464
|
+
* `mae` divides by the pixels **either side drew** — the union — and the
|
|
3465
|
+
* candidate owns half of that denominator. A large, mostly transparent sprite
|
|
3466
|
+
* adds many cheap pixels to it and the *mean falls*, so anything optimising
|
|
3467
|
+
* against `mae` can buy a better score by drawing more, which is the opposite of
|
|
3468
|
+
* fidelity. Issue #119: spineboy-2's muzzle flare walked its own scale to 13x
|
|
3469
|
+
* doing exactly this, and cost every set in that run its framing. Reproduced
|
|
3470
|
+
* here, that candidate's `shoot` reads union MAE **39.65 against the honest
|
|
3471
|
+
* build's 47.20** — the metric calls the flare an improvement — while the same
|
|
3472
|
+
* difference over the reference's own pixels reads **73.06 against 52.54**.
|
|
3473
|
+
*
|
|
3474
|
+
* ⭐ Asymmetric on purpose. A candidate that draws LESS than the reference is
|
|
3475
|
+
* being punished by the MAE, not rewarded, and needs no warning to find out.
|
|
3476
|
+
*
|
|
3477
|
+
* ## Where 1.5 comes from
|
|
3478
|
+
*
|
|
3479
|
+
* Measured over the corpus rather than picked. Across the twelve committed
|
|
3480
|
+
* candidates in `bench/runs/` — 64 compared sets, 1 to 121 frames each — the
|
|
3481
|
+
* ratio spans **0.852 … 1.069** on 62 of them, and the two above that are both
|
|
3482
|
+
* the same shot on the same character: spineboy-1's `shoot@30fps` at 1.154 and
|
|
3483
|
+
* spineboy-2's at **1.274**, two-frame stills sets where the muzzle flare lands
|
|
3484
|
+
* a frame off. Rung 8's ball reads 1.041, rung 3's candidate 0.993.
|
|
3485
|
+
*
|
|
3486
|
+
* The 13x flare reads **1.850** on `shoot` and **3.199** on `shoot@30fps`, and
|
|
3487
|
+
* 0.94–1.01 on the fourteen sets that do not draw it — so the warning names the
|
|
3488
|
+
* shot the overdraw is in rather than colouring the whole run.
|
|
3489
|
+
*
|
|
3490
|
+
* 1.5 is the geometric middle of the gap between the widest honest reading and
|
|
3491
|
+
* the weakest defective one (1.274 · 1.850 ≈ 1.535²): half again as much ink as
|
|
3492
|
+
* the reference put down, which no honest candidate in the corpus approaches and
|
|
3493
|
+
* which the case this was built for clears on both its sets.
|
|
3494
|
+
*
|
|
3495
|
+
* ## What was measured and rejected: the content boxes
|
|
3496
|
+
*
|
|
3497
|
+
* Issue #119 suggests the two content boxes, and `check` has both. Measured, that
|
|
3498
|
+
* test is defeated by the framing it is measured through. `fitFraming` absorbs a
|
|
3499
|
+
* uniform scale on purpose, so a candidate that draws everything too big reads a
|
|
3500
|
+
* box growth of **−3.4 %** while its union MAE falls 137.6 → 36.0 — the fit
|
|
3501
|
+
* simply shrinks it back. It also fires where nothing is overdrawn: the
|
|
3502
|
+
* time-reversed fixture, whose ink is right and whose *timing* is wrong, reads
|
|
3503
|
+
* **+14.1 %** because sampling a reversed shot lands on different poses. Counting
|
|
3504
|
+
* ink is blind to both — that same reversed fixture draws **1.30–1.39x**, under
|
|
3505
|
+
* the bar, and a bloated one draws what it drew whatever the framing does with it
|
|
3506
|
+
* afterwards. C08 and C09 hold both ends of that.
|
|
3507
|
+
*/
|
|
3508
|
+
export const OVERDRAW_RATIO = 1.5;
|
|
3509
|
+
|
|
3510
|
+
/** How many worst frames a set prints when the whole set is too long to list. */
|
|
3511
|
+
export const WORST_FRAMES = 8;
|
|
3512
|
+
/** Sets no longer than this print every frame. */
|
|
3513
|
+
const LIST_EVERY = 24;
|
|
3514
|
+
|
|
3515
|
+
const f2 = (n: number): string => n.toFixed(2);
|
|
3516
|
+
|
|
3517
|
+
export function checkLines(report: CheckReport, opts?: { allFrames?: boolean }): string[] {
|
|
3518
|
+
const lines: string[] = [];
|
|
3519
|
+
lines.push(` candidate ${report.candidate.skeleton}`);
|
|
3520
|
+
lines.push(` atlas ${report.candidate.atlas}`);
|
|
3521
|
+
lines.push(` frames ${report.framesDir}`);
|
|
3522
|
+
// Always printed, on both sides, because the reading a reader has to be able
|
|
3523
|
+
// to make is "which picture of this rig is this" — and a line that appears
|
|
3524
|
+
// only when a skin was named cannot say that the run used none (issue #571).
|
|
3525
|
+
lines.push(
|
|
3526
|
+
` skin candidate ${report.skin === null ? 'no skin set (the default skin alone)' : report.skin} ` +
|
|
3527
|
+
`frames ${
|
|
3528
|
+
report.referenceSkin === null ? `no skin recorded in ${FRAMES_SIDECAR}` : report.referenceSkin
|
|
3529
|
+
}`,
|
|
3530
|
+
);
|
|
3531
|
+
// Always printed, like the skin line above it: which implementation posed the
|
|
3532
|
+
// candidate, and why — `render`'s `poser` line, word for word (issue #968).
|
|
3533
|
+
lines.push(` poser ${report.poser.note}`);
|
|
3534
|
+
lines.push(
|
|
3535
|
+
` scope ${
|
|
3536
|
+
report.framingScope === 'per-shot'
|
|
3537
|
+
? "the framing decided per frame set (--framing shared measures every set in one shared framing)"
|
|
3538
|
+
: "one framing across every frame set (--framing per-shot lets a set take frames.json's own box instead)"
|
|
3539
|
+
}`,
|
|
3540
|
+
);
|
|
3541
|
+
const v = report.viewport;
|
|
3542
|
+
if (v !== null) lines.push(...framedToLines(v, report.framing));
|
|
3543
|
+
const r = report.referenceViewport;
|
|
3544
|
+
if (r) {
|
|
3545
|
+
lines.push(
|
|
3546
|
+
` reference ${r.pixelWidth}x${r.pixelHeight}px ${r.scale.toFixed(6)} px/unit ` +
|
|
3547
|
+
`world x[${r.x.toFixed(1)} .. ${(r.x + r.width).toFixed(1)}] y[${r.y.toFixed(1)} .. ${(r.y + r.height).toFixed(1)}] (${FRAMES_SIDECAR})`,
|
|
3548
|
+
);
|
|
3549
|
+
lines.push(' ⤷ the two world boxes are different coordinate systems and do not compare; the pixel grid does.');
|
|
3550
|
+
}
|
|
3551
|
+
for (const line of framingLines(report.framingFit)) lines.push(line);
|
|
3552
|
+
if (report.framingScope !== 'per-shot' || report.viewport !== null) {
|
|
3553
|
+
for (const line of declaredBoxLines(report.declaredBox)) lines.push(line);
|
|
3554
|
+
}
|
|
3555
|
+
if (report.sharedFraming) {
|
|
3556
|
+
const shared = report.sharedFraming;
|
|
3557
|
+
const f = shared.fit;
|
|
3558
|
+
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
3559
|
+
lines.push(
|
|
3560
|
+
` shared box one box for all ${report.animations.length} set(s) leaves x${f.scale.toFixed(6)} offset ` +
|
|
3561
|
+
`${signed(f.dx)}, ${signed(f.dy)} px rms ${f.rms.toFixed(2)} px over ${f.frames * 4} edge(s) ` +
|
|
3562
|
+
`(${shared.source}; used for every set that could not take the frames' own box)`,
|
|
3563
|
+
);
|
|
3564
|
+
lines.push(
|
|
3565
|
+
" ⤷ how far one shared framing is from serving every set. A set below that took the frames' own " +
|
|
3566
|
+
'box instead is measured with no such correction at all; --framing shared measures every set here.',
|
|
3567
|
+
);
|
|
3568
|
+
}
|
|
3569
|
+
if (report.textureFrom) {
|
|
3570
|
+
const t = report.textureFrom;
|
|
3571
|
+
const scale = (values: number[] | null): string =>
|
|
3572
|
+
values === null ? 'no atlas beside it to read a scale: line from' : values.length === 0 ? 'no scale: line' : `scale: ${[...new Set(values)].join(', ')}`;
|
|
3573
|
+
lines.push(
|
|
3574
|
+
` texture from ${t.atlas} (${scale(t.scales)}) against the candidate's own (${scale(t.candidateScales)})` +
|
|
3575
|
+
`${t.unmatched.length === 0 ? '' : ` ⚠️ ${t.unmatched.length} region(s) not substituted`}`,
|
|
3576
|
+
);
|
|
3577
|
+
lines.push(
|
|
3578
|
+
" ⤷ the candidate's own GEOMETRY through that atlas's texels — every world vertex is the " +
|
|
3579
|
+
'candidate\'s, only the page and the UVs are swapped, and they are swapped through the drawing\'s own ' +
|
|
3580
|
+
'coordinates so a rotated or trimmed pack re-seats nothing. What the two renders differ by is texture.',
|
|
3581
|
+
);
|
|
3582
|
+
if (t.unmatched.length > 0) {
|
|
3583
|
+
lines.push(
|
|
3584
|
+
` ⚠️ not substituted: ${t.unmatched.join(', ')}. Those pieces kept their own texture, so the ` +
|
|
3585
|
+
'floor below is a MIXTURE and is a lower bound on the resampling rather than the whole of it.',
|
|
3586
|
+
);
|
|
3587
|
+
}
|
|
3588
|
+
}
|
|
3589
|
+
for (const note of report.notes) lines.push(` ⚠️ ${note}`);
|
|
3590
|
+
lines.push('');
|
|
3591
|
+
|
|
3592
|
+
for (const anim of report.animations) {
|
|
3593
|
+
const played =
|
|
3594
|
+
anim.candidateAnimation !== null
|
|
3595
|
+
? `candidate animation ${JSON.stringify(anim.candidateAnimation)}`
|
|
3596
|
+
: anim.animation === null
|
|
3597
|
+
? 'setup pose'
|
|
3598
|
+
: 'nothing in the candidate to play against it';
|
|
3599
|
+
lines.push(` ── ${anim.dir} — ${played}, ${anim.fps} fps ──`);
|
|
3600
|
+
lines.push(
|
|
3601
|
+
` frames ${anim.referenceFrames} on disk, candidate samples ${anim.candidateFrames}, ${anim.compared} compared`,
|
|
3602
|
+
);
|
|
3603
|
+
// Only when this set has a framing of its own: under a shared scope, or a pin,
|
|
3604
|
+
// the header already printed the one box every set was measured in, and
|
|
3605
|
+
// repeating it per set would read as though they differed.
|
|
3606
|
+
if (report.framingScope === 'per-shot' && report.viewport === null) {
|
|
3607
|
+
for (const line of framedToLines(anim.viewport, anim.framing, ' ')) lines.push(line);
|
|
3608
|
+
for (const line of framingLines(anim.framingFit, ' ')) lines.push(line);
|
|
3609
|
+
for (const line of declaredBoxLines(anim.declaredBox, ' ')) lines.push(line);
|
|
3610
|
+
}
|
|
3611
|
+
for (const note of anim.notes) lines.push(` ⚠️ ${note}`);
|
|
3612
|
+
if (anim.compared === 0) {
|
|
3613
|
+
lines.push('');
|
|
3614
|
+
continue;
|
|
3615
|
+
}
|
|
3616
|
+
lines.push(
|
|
3617
|
+
` MAE mean ${f2(anim.meanMae)} worst ${f2(anim.worstMae)} ${worstAt(anim.worstMaeFrame, anim.compared, 'frame(s)')}` +
|
|
3618
|
+
` (0..255 over the union alpha; over the whole frame, mean ${f2(anim.meanMaeFrame)})`,
|
|
3619
|
+
);
|
|
3620
|
+
lines.push(
|
|
3621
|
+
` ⤷ over the REFERENCE's own drawn pixels, mean ${f2(anim.meanMaeReference)} — the union figure ` +
|
|
3622
|
+
'compares two builds of the same rig; this one is the one to optimise against, because the union is yours to grow.',
|
|
3623
|
+
);
|
|
3624
|
+
for (const line of textureFloorLines(anim)) lines.push(line);
|
|
3625
|
+
if (anim.drawnRatio > OVERDRAW_RATIO) {
|
|
3626
|
+
const mine = Math.round(anim.frames.reduce((sum, f) => sum + f.candidatePixels, 0) / anim.frames.length);
|
|
3627
|
+
const theirs = Math.round(anim.frames.reduce((sum, f) => sum + f.referencePixels, 0) / anim.frames.length);
|
|
3628
|
+
lines.push(
|
|
3629
|
+
` ⚠️ overdraw: this shot draws ${mine.toLocaleString('en-US')} px a frame where the reference ` +
|
|
3630
|
+
`draws ${theirs.toLocaleString('en-US')} — ${anim.drawnRatio.toFixed(2)}x as much ink, past the ` +
|
|
3631
|
+
`${OVERDRAW_RATIO}x no committed candidate reaches. Most of that excess lands in the MAE's own ` +
|
|
3632
|
+
'denominator and makes the figure above cheaper without moving a pixel closer, so read the one under it. ' +
|
|
3633
|
+
'Something here is drawn that should not be, or is far too big.',
|
|
3634
|
+
);
|
|
3635
|
+
}
|
|
3636
|
+
const blind =
|
|
3637
|
+
anim.framesWithoutDrift === 0
|
|
3638
|
+
? ''
|
|
3639
|
+
: ` (${anim.framesWithoutDrift} of ${anim.compared} frame(s) attributed no slot at all)`;
|
|
3640
|
+
lines.push(
|
|
3641
|
+
anim.worstDriftFrame < 0
|
|
3642
|
+
? ` slot drift no slot could be attributed in any of the ${anim.compared} frame(s) — read the MAE instead`
|
|
3643
|
+
: ` slot drift worst ${anim.worstDrift.toFixed(1)} px ${JSON.stringify(anim.worstDriftSlot)} at ` +
|
|
3644
|
+
`f${String(anim.worstDriftFrame).padStart(4, '0')}${blind}`,
|
|
3645
|
+
);
|
|
3646
|
+
const boundLine = driftBoundLine(anim);
|
|
3647
|
+
if (boundLine !== null) lines.push(boundLine);
|
|
3648
|
+
lines.push(changeSummary(anim));
|
|
3649
|
+
for (const line of sheetLines(anim.sheet)) lines.push(line);
|
|
3650
|
+
for (const line of chainTable(anim)) lines.push(line);
|
|
3651
|
+
lines.push('');
|
|
3652
|
+
|
|
3653
|
+
const listed = framesToList(anim, opts?.allFrames === true);
|
|
3654
|
+
const heading =
|
|
3655
|
+
listed.length === anim.frames.length
|
|
3656
|
+
? 'every frame'
|
|
3657
|
+
: `the ${listed.length} frames worth reading — worst by MAE, plus every frame whose own change disagrees`;
|
|
3658
|
+
lines.push(` ${heading}, in index order`);
|
|
3659
|
+
lines.push(' frame MAE union px Δpx ref Δ worst slot drift how slots note');
|
|
3660
|
+
for (const frame of listed) {
|
|
3661
|
+
const worst = frame.slots.find((s) => s.slot === frame.worstSlot) ?? null;
|
|
3662
|
+
const drift = frame.worstDrift === null ? ' —' : `${frame.worstDrift.toFixed(1).padStart(5)}`;
|
|
3663
|
+
const how =
|
|
3664
|
+
worst === null
|
|
3665
|
+
? '— '
|
|
3666
|
+
: worst.method === 'template'
|
|
3667
|
+
? `tmpl ${(worst.confidence ?? 0).toFixed(2)}`
|
|
3668
|
+
: 'component ';
|
|
3669
|
+
const note = [changeNote(frame.change), frame.unmatchedComponents > 0 ? `${frame.unmatchedComponents} reference component(s) no slot reaches` : '']
|
|
3670
|
+
.filter(Boolean)
|
|
3671
|
+
.join('; ');
|
|
3672
|
+
const change = frame.change
|
|
3673
|
+
? `${String(frame.change.candidate).padStart(7)}${String(frame.change.reference).padStart(7)}`
|
|
3674
|
+
: `${'—'.padStart(7)}${'—'.padStart(7)}`;
|
|
3675
|
+
lines.push(
|
|
3676
|
+
` f${String(frame.index).padStart(4, '0')} ${f2(frame.mae).padStart(8)} ${String(frame.unionPixels).padStart(9)} ${change} ` +
|
|
3677
|
+
`${(frame.worstSlot ?? '—').padEnd(20)} ${drift} ${how.padEnd(9)} ${String(frame.attributed)}/${String(frame.drawn)}` +
|
|
3678
|
+
`${note ? ` ${note}` : ''}`,
|
|
3679
|
+
);
|
|
3680
|
+
}
|
|
3681
|
+
lines.push('');
|
|
3682
|
+
}
|
|
3683
|
+
|
|
3684
|
+
for (const line of chainFoot(report)) lines.push(line);
|
|
3685
|
+
|
|
3686
|
+
lines.push(' MAE is the mean absolute RGB difference over the pixels either side covers, so it is');
|
|
3687
|
+
lines.push(' read against 255 and not against a threshold: there is no pass mark here any more than');
|
|
3688
|
+
lines.push(' there is one in `diff`. The figure under it divides the same difference by the pixels the');
|
|
3689
|
+
lines.push(' REFERENCE drew — a denominator you cannot grow, which is what makes it the one to author');
|
|
3690
|
+
lines.push(' against; it is not bounded by 255. Read the framing line first: it is upstream of every number');
|
|
3691
|
+
lines.push(' below, and a residual much wider than a pixel moves all of them at once.');
|
|
3692
|
+
lines.push(' The slots column is how many of the drawn slots could be attributed at all. A drift');
|
|
3693
|
+
lines.push(' marked `tmpl` was correlated against the slot’s own pixels because the reference');
|
|
3694
|
+
lines.push(' merged it into a neighbour; the number beside it is how much better that match was');
|
|
3695
|
+
lines.push(' than its best rival, and a slot that matched nothing at all is left out of the count.');
|
|
3696
|
+
lines.push(' Only the pixels of a slot that its own composite lets show are correlated — one it');
|
|
3697
|
+
lines.push(' draws over itself matches nothing at any offset, so leaving it in moves the answer');
|
|
3698
|
+
lines.push(' rather than costing it. A slot covered everywhere reports no drift and says so. The');
|
|
3699
|
+
lines.push(' `⤷ bounded by` line under a drift is what that figure could have reached: for a');
|
|
3700
|
+
lines.push(` template match, its whole-pixel winner plus ${SUBPIXEL_CLAMP} px on each axis — so a winner at`);
|
|
3701
|
+
lines.push(
|
|
3702
|
+
` the offset you drew bounds the whole figure at ${Math.hypot(SUBPIXEL_CLAMP, SUBPIXEL_CLAMP).toFixed(2)} px ` +
|
|
3703
|
+
'and says the drift is the',
|
|
3704
|
+
);
|
|
3705
|
+
lines.push(' instrument, not the rig. Read every drift against its own line and not against a page.');
|
|
3706
|
+
lines.push(' A `sheet` line is the frames a set does not commit as files: the candidate sampled at the');
|
|
3707
|
+
lines.push(" set's own rate against the tiles of its contact.png, in the same box the frame table used.");
|
|
3708
|
+
lines.push(' Read it as a series — flat is framing or art, a spike is timing at that moment — and note');
|
|
3709
|
+
lines.push(' that it is MAE only: the change columns below are pixel counts at frame scale.');
|
|
3710
|
+
lines.push(' `Δpx` and `ref Δ` are how many pixels each side moved since ITS OWN previous frame —');
|
|
3711
|
+
lines.push(' not against each other. They are the only columns that can see a held pose that is');
|
|
3712
|
+
lines.push(' not held, or a one-frame event that never fired: both are small in every frame and');
|
|
3713
|
+
lines.push(' wrong only in the relation between two, which is where the MAE cannot look.');
|
|
3714
|
+
return lines;
|
|
3715
|
+
}
|
|
3716
|
+
|
|
3717
|
+
/**
|
|
3718
|
+
* One set's texture decomposition, as the two lines under its MAE — see
|
|
3719
|
+
* `TextureFloor`.
|
|
3720
|
+
*
|
|
3721
|
+
* ⭐ The **bound** is printed as arithmetic and not as prose. `mae − aboveFloor`
|
|
3722
|
+
* is what the texture explained on these frames and `floor` is the most it could
|
|
3723
|
+
* have; a reader who is given only the second is one subtraction away from
|
|
3724
|
+
* treating it as the first, which is the error `TextureFloor`'s own doc exists to
|
|
3725
|
+
* head off.
|
|
3726
|
+
*/
|
|
3727
|
+
function textureFloorLines(anim: AnimationCheck): string[] {
|
|
3728
|
+
const t = anim.textureFloor;
|
|
3729
|
+
if (t === null) return [];
|
|
3730
|
+
const explained = anim.meanMae - t.aboveFloor;
|
|
3731
|
+
const share = anim.meanMae > 0 ? ` (${((explained / anim.meanMae) * 100).toFixed(1)}% of the figure above)` : '';
|
|
3732
|
+
return [
|
|
3733
|
+
` ⭐ texture floor ${f2(t.floor)} above it ${f2(t.aboveFloor)} (over the reference's own ` +
|
|
3734
|
+
`pixels, ${f2(t.floorReference)} and ${f2(t.aboveFloorReference)})`,
|
|
3735
|
+
` ⤷ the same geometry through the other atlas's texels reads ${f2(t.aboveFloor)}, so the ` +
|
|
3736
|
+
`texture accounts for ${f2(explained)} of this set's MAE${share} and cannot account for more than ` +
|
|
3737
|
+
`${f2(t.floor)} — |MAE − above| ≤ floor, because absolute errors bound rather than add. A floor near zero ` +
|
|
3738
|
+
'is proof the texture is not the story here. 🚫 The figure of record is the MAE above, not this.',
|
|
3739
|
+
];
|
|
3740
|
+
}
|
|
3741
|
+
|
|
3742
|
+
/**
|
|
3743
|
+
* Which frame carried the worst MAE — or that none did.
|
|
3744
|
+
*
|
|
3745
|
+
* `AnimationCheck.worstMaeFrame` and `SheetCheck.worstTile` are `-1` when **no
|
|
3746
|
+
* compared frame differs from the reference at all**, because each worst is
|
|
3747
|
+
* tracked with a strict `>` from zero. That is the identity run, which is the run
|
|
3748
|
+
* an author calibrates on, and until issue #678 the sentinel went straight
|
|
3749
|
+
* through `padStart(4, '0')` and printed as `at f00-1` — a frame index that
|
|
3750
|
+
* cannot exist, on the two reports that are supposed to read as *nothing to
|
|
3751
|
+
* report*.
|
|
3752
|
+
*
|
|
3753
|
+
* ⚠️ The same sentinel is carried by `worstDriftFrame` and `worstChangeFrame`,
|
|
3754
|
+
* and both of those were already guarded where they print. Two of the four sites
|
|
3755
|
+
* had the idiom and two did not, which is the whole defect.
|
|
3756
|
+
*/
|
|
3757
|
+
function worstAt(frame: number, compared: number, unit: string): string {
|
|
3758
|
+
if (frame >= 0) return `at f${String(frame).padStart(4, '0')}`;
|
|
3759
|
+
return `(exact: none of the ${compared} compared ${unit} differs from the reference)`;
|
|
3760
|
+
}
|
|
3761
|
+
|
|
3762
|
+
/**
|
|
3763
|
+
* What bounds the drift the summary line just printed.
|
|
3764
|
+
*
|
|
3765
|
+
* ⭐ A drift figure is unreadable on its own, and issue #698 is the bill for that:
|
|
3766
|
+
* §9.2 told an author to read one against `hypot(0.5, 0.5) = 0.71 px`, and four of
|
|
3767
|
+
* the seven shipped examples printed more than that against frames rendered from
|
|
3768
|
+
* themselves. The bound is per match and it is derived: a template match's
|
|
3769
|
+
* whole-pixel winner says how much of the figure is a *displacement*, and the
|
|
3770
|
+
* sub-pixel step can add at most `SUBPIXEL_CLAMP` on each axis to it. So a reader
|
|
3771
|
+
* comparing 0.4 px to a floor now sees the floor the match itself carries rather
|
|
3772
|
+
* than one read off a page about another rig.
|
|
3773
|
+
*
|
|
3774
|
+
* A component match has no such bound — the distance between two centroids is
|
|
3775
|
+
* bounded by nothing but how far this slot was allowed to have moved — and the
|
|
3776
|
+
* line says that instead of inventing one.
|
|
3777
|
+
*/
|
|
3778
|
+
function driftBoundLine(anim: AnimationCheck): string | null {
|
|
3779
|
+
if (anim.worstDriftFrame < 0 || anim.worstDriftSlot === null) return null;
|
|
3780
|
+
const frame = anim.frames.find((f) => f.index === anim.worstDriftFrame);
|
|
3781
|
+
const track = frame?.slots.find((s) => s.slot === anim.worstDriftSlot) ?? null;
|
|
3782
|
+
if (track === null) return null;
|
|
3783
|
+
if (track.method === 'component') {
|
|
3784
|
+
return (
|
|
3785
|
+
' ⤷ a component match: the distance between two centroids, bounded by nothing but the ' +
|
|
3786
|
+
`${String(track.searchRadius)} px this slot could have moved and still be itself`
|
|
3787
|
+
);
|
|
3788
|
+
}
|
|
3789
|
+
const bound = driftBound(track);
|
|
3790
|
+
if (bound === null || track.wholePixel === null) return null;
|
|
3791
|
+
const { dx, dy } = track.wholePixel;
|
|
3792
|
+
return dx === 0 && dy === 0
|
|
3793
|
+
? ` ⤷ bounded by ${bound.toFixed(2)} px — the correlation put this slot where the candidate ` +
|
|
3794
|
+
`drew it, so the whole figure is the sub-pixel step, clamped to ${SUBPIXEL_CLAMP} px on each axis`
|
|
3795
|
+
: ` ⤷ bounded by ${bound.toFixed(2)} px — the correlation moved this slot by (${dx}, ${dy}) ` +
|
|
3796
|
+
`whole pixel(s), plus a sub-pixel step clamped to ${SUBPIXEL_CLAMP} px on each axis`;
|
|
3797
|
+
}
|
|
3798
|
+
|
|
3799
|
+
/** One drift, as the table says it: distance, slot, frame. */
|
|
3800
|
+
function driftPhrase(drift: number, slot: string | null, frame: number): string {
|
|
3801
|
+
if (slot === null) return 'no slot attributable';
|
|
3802
|
+
return `${drift.toFixed(1)} px ${JSON.stringify(slot)} f${String(frame).padStart(4, '0')}`;
|
|
3803
|
+
}
|
|
3804
|
+
|
|
3805
|
+
/** One set, broken down by chain — see `ChainCheck`. */
|
|
3806
|
+
function chainTable(anim: AnimationCheck): string[] {
|
|
3807
|
+
if (anim.chains.length === 0) return [];
|
|
3808
|
+
const out: string[] = [];
|
|
3809
|
+
out.push(
|
|
3810
|
+
` chains ${anim.chains.length} from the candidate's own bone tree — the roster is at the foot of the report`,
|
|
3811
|
+
);
|
|
3812
|
+
out.push(
|
|
3813
|
+
` ${'chain'.padEnd(20)} ${'slots'.padStart(6)} ${'worst slot drift'.padEnd(33)} ` +
|
|
3814
|
+
`${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
|
|
3815
|
+
);
|
|
3816
|
+
// Derivation order rather than worst-first, so the same row is in the same place
|
|
3817
|
+
// in every set's table and a run can be read down a column.
|
|
3818
|
+
for (const chain of anim.chains) {
|
|
3819
|
+
const mean = chain.driftSamples === 0 ? '—' : `${chain.meanDrift.toFixed(1)} px`;
|
|
3820
|
+
out.push(
|
|
3821
|
+
` ${chain.chain.padEnd(20)} ${`${chain.drewSlots}/${chain.slots}`.padStart(6)} ` +
|
|
3822
|
+
`${driftPhrase(chain.worstDrift, chain.worstDriftSlot, chain.worstDriftFrame).padEnd(33)} ` +
|
|
3823
|
+
`${mean.padStart(8)} ${f2(chain.mae).padStart(9)} ${`${(chain.maeShare * 100).toFixed(1)}%`.padStart(6)}`,
|
|
3824
|
+
);
|
|
3825
|
+
}
|
|
3826
|
+
if (anim.unattributedError > 0 && anim.chainDenominator > 0) {
|
|
3827
|
+
const share = (anim.unattributedError / anim.chainDenominator) * 100;
|
|
3828
|
+
out.push(
|
|
3829
|
+
` ${'(unattributed)'.padEnd(20)} ${'—'.padStart(6)} ${'—'.padEnd(33)} ${'—'.padStart(8)} ` +
|
|
3830
|
+
`${'—'.padStart(9)} ${`${share.toFixed(1)}%`.padStart(6)}`,
|
|
3831
|
+
);
|
|
3832
|
+
}
|
|
3833
|
+
out.push(
|
|
3834
|
+
" ⤷ share is of this set's own difference over the REFERENCE's drawn pixels, split by nearest ink; " +
|
|
3835
|
+
'`MAE in it` is that same error per pixel. The rule, the denominator and what a 0 % row means are under ' +
|
|
3836
|
+
'"chains" at the foot of the report.',
|
|
3837
|
+
);
|
|
3838
|
+
return out;
|
|
3839
|
+
}
|
|
3840
|
+
|
|
3841
|
+
/** One line per chain across every set, plus the roster the names refer to. */
|
|
3842
|
+
function chainFoot(report: CheckReport): string[] {
|
|
3843
|
+
if (report.chains.length === 0) return [];
|
|
3844
|
+
const out: string[] = [];
|
|
3845
|
+
out.push(' ── chains ──');
|
|
3846
|
+
out.push(
|
|
3847
|
+
" Cut from the CANDIDATE's own bone tree at every branch point: a chain runs from a root or a fork down to the",
|
|
3848
|
+
);
|
|
3849
|
+
out.push(
|
|
3850
|
+
' next fork, a single-bone chain that is itself a fork folds into its parent, and each is named after the first',
|
|
3851
|
+
);
|
|
3852
|
+
out.push(
|
|
3853
|
+
' bone in it that carries a slot. The reference is still nothing but pixels — this is your figure decomposed,',
|
|
3854
|
+
);
|
|
3855
|
+
out.push(' not the reference’s, which is what keeps it inside the ladder’s honesty rule.');
|
|
3856
|
+
out.push('');
|
|
3857
|
+
out.push(" MAE share divides the difference over the REFERENCE's own drawn pixels — the denominator from the MAE");
|
|
3858
|
+
out.push(' line above, which nothing you draw can grow — and splits it by giving each of those pixels to the chain');
|
|
3859
|
+
out.push(' whose ink is NEAREST it. So the shares are a partition and add to the whole, and no chain can look');
|
|
3860
|
+
out.push(' better by drawing more: growing its ink only pulls more of the reference’s pixels, and their error,');
|
|
3861
|
+
out.push(' into it. `MAE in it` is the same error per pixel it took, and it is the column that separates a chain');
|
|
3862
|
+
out.push(' that is WRONG from one that is merely large — a head and its features cover a lot of a figure and can');
|
|
3863
|
+
out.push(' carry a third of the error at a below-average figure per pixel.');
|
|
3864
|
+
out.push('');
|
|
3865
|
+
out.push(' ⚠️ Two things the split cannot do, both of which show rather than hide. Reference ink further from your');
|
|
3866
|
+
out.push(' ink than the part’s own size is left `(unattributed)` instead of blamed on a neighbour, so a part that');
|
|
3867
|
+
out.push(' has left its place stops being charged to whatever is next to it. And a chain that draws NOTHING seeds');
|
|
3868
|
+
out.push(' nothing and reads 0 % — which is why the slots column is beside the share: 0 % on 0 slots drawn is the');
|
|
3869
|
+
out.push(' loudest row here, not the quietest.');
|
|
3870
|
+
out.push(` ${'chain'.padEnd(20)} ${'bones'.padEnd(57)} slots`);
|
|
3871
|
+
for (const chain of report.chains) {
|
|
3872
|
+
out.push(
|
|
3873
|
+
` ${chain.name.padEnd(20)} ${chain.bones.join(', ').padEnd(57)} ` +
|
|
3874
|
+
`${chain.slots.length === 0 ? '(draws nothing)' : chain.slots.join(', ')}`,
|
|
3875
|
+
);
|
|
3876
|
+
}
|
|
3877
|
+
const rows = chainRollup(report);
|
|
3878
|
+
if (rows.length === 0) return out;
|
|
3879
|
+
out.push('');
|
|
3880
|
+
out.push(
|
|
3881
|
+
` ${'chain'.padEnd(20)} ${'worst slot drift across every set'.padEnd(56)} ` +
|
|
3882
|
+
`${'mean'.padStart(8)} ${'MAE in it'.padStart(9)} ${'share'.padStart(6)}`,
|
|
3883
|
+
);
|
|
3884
|
+
for (const row of rows) {
|
|
3885
|
+
const where = row.set === null ? '' : ` in ${row.set}/f${String(row.frame).padStart(4, '0')}`;
|
|
3886
|
+
const worst =
|
|
3887
|
+
row.slot === null ? 'no slot attributable in any set' : `${row.drift.toFixed(1)} px ${JSON.stringify(row.slot)}${where}`;
|
|
3888
|
+
const mean = row.samples === 0 ? '—' : `${row.mean.toFixed(1)} px`;
|
|
3889
|
+
out.push(
|
|
3890
|
+
` ${row.chain.padEnd(20)} ${worst.padEnd(56)} ${mean.padStart(8)} ` +
|
|
3891
|
+
`${(row.pixels === 0 ? 0 : row.error / row.pixels).toFixed(2).padStart(9)} ` +
|
|
3892
|
+
`${`${(row.share * 100).toFixed(1)}%`.padStart(6)}`,
|
|
3893
|
+
);
|
|
3894
|
+
}
|
|
3895
|
+
out.push('');
|
|
3896
|
+
return out;
|
|
3897
|
+
}
|
|
3898
|
+
|
|
3899
|
+
interface ChainRollup {
|
|
3900
|
+
chain: string;
|
|
3901
|
+
drift: number;
|
|
3902
|
+
slot: string | null;
|
|
3903
|
+
set: string | null;
|
|
3904
|
+
frame: number;
|
|
3905
|
+
mean: number;
|
|
3906
|
+
samples: number;
|
|
3907
|
+
error: number;
|
|
3908
|
+
pixels: number;
|
|
3909
|
+
share: number;
|
|
3910
|
+
}
|
|
3911
|
+
|
|
3912
|
+
/**
|
|
3913
|
+
* Each chain's worst reading anywhere in the run, worst share first.
|
|
3914
|
+
*
|
|
3915
|
+
* Worst-first here and derivation order in the per-set tables, deliberately: this
|
|
3916
|
+
* is the line a run’s README quotes, so it is ranked by what to fix, while a table
|
|
3917
|
+
* printed once per set is ranked so the sets line up.
|
|
3918
|
+
*/
|
|
3919
|
+
function chainRollup(report: CheckReport): ChainRollup[] {
|
|
3920
|
+
const rows = new Map<string, ChainRollup>();
|
|
3921
|
+
let denominator = 0;
|
|
3922
|
+
for (const anim of report.animations) {
|
|
3923
|
+
if (anim.compared === 0) continue;
|
|
3924
|
+
denominator += anim.chainDenominator;
|
|
3925
|
+
for (const chain of anim.chains) {
|
|
3926
|
+
const row = rows.get(chain.chain) ?? {
|
|
3927
|
+
chain: chain.chain,
|
|
3928
|
+
drift: 0,
|
|
3929
|
+
slot: null,
|
|
3930
|
+
set: null,
|
|
3931
|
+
frame: -1,
|
|
3932
|
+
mean: 0,
|
|
3933
|
+
samples: 0,
|
|
3934
|
+
error: 0,
|
|
3935
|
+
pixels: 0,
|
|
3936
|
+
share: 0,
|
|
3937
|
+
};
|
|
3938
|
+
if (chain.worstDriftSlot !== null && chain.worstDrift > row.drift) {
|
|
3939
|
+
row.drift = chain.worstDrift;
|
|
3940
|
+
row.slot = chain.worstDriftSlot;
|
|
3941
|
+
row.set = anim.dir;
|
|
3942
|
+
row.frame = chain.worstDriftFrame;
|
|
3943
|
+
}
|
|
3944
|
+
row.mean = row.mean * row.samples + chain.meanDrift * chain.driftSamples;
|
|
3945
|
+
row.samples += chain.driftSamples;
|
|
3946
|
+
row.mean = row.samples === 0 ? 0 : row.mean / row.samples;
|
|
3947
|
+
row.error += chain.error;
|
|
3948
|
+
row.pixels += chain.referencePixels;
|
|
3949
|
+
rows.set(chain.chain, row);
|
|
3950
|
+
}
|
|
3951
|
+
}
|
|
3952
|
+
const out = [...rows.values()];
|
|
3953
|
+
for (const row of out) row.share = denominator === 0 ? 0 : row.error / denominator;
|
|
3954
|
+
return out.sort((a, b) => b.share - a.share);
|
|
3955
|
+
}
|
|
3956
|
+
|
|
3957
|
+
/**
|
|
3958
|
+
* The frames worth printing: the worst by MAE, plus every change disagreement.
|
|
3959
|
+
*
|
|
3960
|
+
* The union matters rather than being tidy. The defects `FrameChange` exists to
|
|
3961
|
+
* catch are **cheap in MAE by construction** — a plateau sloped through by a
|
|
3962
|
+
* fraction of a pixel, a three-pixel reveal that did not fire — so a listing
|
|
3963
|
+
* ranked by MAE is exactly the listing that leaves them out. Rung 6's f65–f68 sit
|
|
3964
|
+
* near the bottom of that ranking.
|
|
3965
|
+
*/
|
|
3966
|
+
export function framesToList(anim: AnimationCheck, allFrames: boolean): FrameCheck[] {
|
|
3967
|
+
if (allFrames || anim.frames.length <= LIST_EVERY) return anim.frames;
|
|
3968
|
+
const chosen = new Set(
|
|
3969
|
+
[...anim.frames]
|
|
3970
|
+
.sort((a, b) => b.mae - a.mae)
|
|
3971
|
+
.slice(0, WORST_FRAMES)
|
|
3972
|
+
.map((f) => f.index),
|
|
3973
|
+
);
|
|
3974
|
+
for (const frame of anim.frames) if (frame.change && frame.change.verdict !== 'agrees') chosen.add(frame.index);
|
|
3975
|
+
return anim.frames.filter((f) => chosen.has(f.index));
|
|
3976
|
+
}
|
|
3977
|
+
|
|
3978
|
+
/**
|
|
3979
|
+
* The field of a `frames.json` that says the directory is `check --out`'s
|
|
3980
|
+
* pictures and not a frame set — see `src/checkpics.ts`. `check --frames`
|
|
3981
|
+
* refuses a sidecar carrying it, by this name.
|
|
3982
|
+
*/
|
|
3983
|
+
export const COMPARISON_FIELD = 'comparison';
|
|
3984
|
+
|
|
3985
|
+
/** The three rasters one frame's figures were computed on. */
|
|
3986
|
+
export interface ComparedPlates {
|
|
3987
|
+
/** The reference frame, as read from `--frames`. */
|
|
3988
|
+
reference: Plate;
|
|
3989
|
+
/** The candidate, rendered onto the reference's grid over the frames' background. */
|
|
3990
|
+
candidate: Plate;
|
|
3991
|
+
/** Which pixels the candidate's geometry covers, 1 or 0 — half of the union alpha. */
|
|
3992
|
+
coverage: Uint8Array;
|
|
3993
|
+
}
|
|
3994
|
+
|
|
3995
|
+
/**
|
|
3996
|
+
* The rasters behind the frames a report will list, kept at the moment they were
|
|
3997
|
+
* compared — what `check --out` draws its pictures from.
|
|
3998
|
+
*
|
|
3999
|
+
* ## Why they have to be kept, and why not all of them
|
|
4000
|
+
*
|
|
4001
|
+
* `checkOneSet` holds a frame's two plates for exactly one iteration (and the
|
|
4002
|
+
* previous frame's for the change measure); the difference is never a raster at
|
|
4003
|
+
* all, only a running sum. And which frames are *worth reading* is not known
|
|
4004
|
+
* until the set is finished, because it is the worst by MAE over all of them —
|
|
4005
|
+
* `framesToList` decides it at print time. So the choice is between re-rendering
|
|
4006
|
+
* the listed frames afterwards and keeping them now, and re-rendering is rejected:
|
|
4007
|
+
* a second render that agreed with the first would be a claim about the picture,
|
|
4008
|
+
* and this is meant to be the record of it.
|
|
4009
|
+
*
|
|
4010
|
+
* Keeping every frame is the other simple answer and it does not scale: one
|
|
4011
|
+
* frame at 256x116 is 261 KiB of plates and coverage, and a 300-frame set at
|
|
4012
|
+
* 512x512 would hold 675 MiB. So a set longer than the listing threshold keeps a
|
|
4013
|
+
* running top `WORST_FRAMES` by MAE — ties to the earlier index, which is the
|
|
4014
|
+
* order `framesToList`'s stable sort gives them — plus every frame whose change
|
|
4015
|
+
* disagrees, and drops the rest as it goes. `--all-frames` keeps everything,
|
|
4016
|
+
* because then everything is listed.
|
|
4017
|
+
*
|
|
4018
|
+
* 🔒 `framesToList` stays the one derivation of the listing. This only has to
|
|
4019
|
+
* keep a superset of it, and `writeCheckPictures` refuses by name a listed frame
|
|
4020
|
+
* it finds nothing kept for, so the two cannot disagree in silence.
|
|
4021
|
+
*/
|
|
4022
|
+
export class CheckPlates {
|
|
4023
|
+
/** Whether every compared frame is kept — `--all-frames`. */
|
|
4024
|
+
readonly every: boolean;
|
|
4025
|
+
private readonly kept = new Map<string, Map<number, ComparedPlates>>();
|
|
4026
|
+
/** Per set: whether the whole set will be listed, so everything is kept. */
|
|
4027
|
+
private readonly whole = new Map<string, boolean>();
|
|
4028
|
+
/** Per set: the running worst by MAE, worst first, at most `WORST_FRAMES`. */
|
|
4029
|
+
private readonly ranked = new Map<string, Array<{ index: number; mae: number }>>();
|
|
4030
|
+
/** Per set: frames kept because their change disagrees, whatever their MAE. */
|
|
4031
|
+
private readonly disagreeing = new Map<string, Set<number>>();
|
|
4032
|
+
private held = 0;
|
|
4033
|
+
/** The most bytes of raster this held at any one time — the cost `--out` adds. */
|
|
4034
|
+
peakBytes = 0;
|
|
4035
|
+
|
|
4036
|
+
constructor(opts: { allFrames: boolean }) {
|
|
4037
|
+
this.every = opts.allFrames;
|
|
4038
|
+
}
|
|
4039
|
+
|
|
4040
|
+
/** A set is about to be compared, over this many frame pairs. */
|
|
4041
|
+
begin(dir: string, compared: number): void {
|
|
4042
|
+
this.kept.set(dir, new Map());
|
|
4043
|
+
this.whole.set(dir, this.every || compared <= LIST_EVERY);
|
|
4044
|
+
this.ranked.set(dir, []);
|
|
4045
|
+
this.disagreeing.set(dir, new Set());
|
|
4046
|
+
}
|
|
4047
|
+
|
|
4048
|
+
/** One frame has been compared: keep its plates if it can be listed. */
|
|
4049
|
+
offer(dir: string, check: FrameCheck, plates: ComparedPlates): void {
|
|
4050
|
+
const kept = this.kept.get(dir);
|
|
4051
|
+
const ranked = this.ranked.get(dir);
|
|
4052
|
+
const disagreeing = this.disagreeing.get(dir);
|
|
4053
|
+
if (kept === undefined || ranked === undefined || disagreeing === undefined) {
|
|
4054
|
+
throw new Error(`CheckPlates: set ${JSON.stringify(dir)} was offered a frame before it began`);
|
|
4055
|
+
}
|
|
4056
|
+
if (this.whole.get(dir) === true) {
|
|
4057
|
+
this.keep(kept, check.index, plates);
|
|
4058
|
+
return;
|
|
4059
|
+
}
|
|
4060
|
+
const disagrees = check.change !== null && check.change.verdict !== 'agrees';
|
|
4061
|
+
if (disagrees) disagreeing.add(check.index);
|
|
4062
|
+
// Frames arrive in index order, so a later frame that only ties the last
|
|
4063
|
+
// ranked one loses to it — exactly as the stable sort would place them.
|
|
4064
|
+
const enters = ranked.length < WORST_FRAMES || check.mae > ranked[ranked.length - 1].mae;
|
|
4065
|
+
if (enters) {
|
|
4066
|
+
let at = ranked.findIndex((r) => check.mae > r.mae);
|
|
4067
|
+
if (at < 0) at = ranked.length;
|
|
4068
|
+
ranked.splice(at, 0, { index: check.index, mae: check.mae });
|
|
4069
|
+
if (ranked.length > WORST_FRAMES) {
|
|
4070
|
+
const out = ranked.pop() as { index: number; mae: number };
|
|
4071
|
+
if (!disagreeing.has(out.index)) this.drop(kept, out.index);
|
|
4072
|
+
}
|
|
4073
|
+
}
|
|
4074
|
+
if (enters || disagrees) this.keep(kept, check.index, plates);
|
|
4075
|
+
}
|
|
4076
|
+
|
|
4077
|
+
/** The plates kept for one frame of one set, if any. */
|
|
4078
|
+
of(dir: string, index: number): ComparedPlates | undefined {
|
|
4079
|
+
return this.kept.get(dir)?.get(index);
|
|
4080
|
+
}
|
|
4081
|
+
|
|
4082
|
+
private keep(kept: Map<number, ComparedPlates>, index: number, plates: ComparedPlates): void {
|
|
4083
|
+
if (kept.has(index)) return;
|
|
4084
|
+
kept.set(index, plates);
|
|
4085
|
+
this.held += bytesOf(plates);
|
|
4086
|
+
if (this.held > this.peakBytes) this.peakBytes = this.held;
|
|
4087
|
+
}
|
|
4088
|
+
|
|
4089
|
+
private drop(kept: Map<number, ComparedPlates>, index: number): void {
|
|
4090
|
+
const plates = kept.get(index);
|
|
4091
|
+
if (plates === undefined) return;
|
|
4092
|
+
kept.delete(index);
|
|
4093
|
+
this.held -= bytesOf(plates);
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
|
|
4097
|
+
function bytesOf(plates: ComparedPlates): number {
|
|
4098
|
+
return plates.reference.data.length + plates.candidate.data.length + plates.coverage.length;
|
|
4099
|
+
}
|
|
4100
|
+
|
|
4101
|
+
/** The per-frame change measure, as the animation's own summary line. */
|
|
4102
|
+
function changeSummary(anim: AnimationCheck): string {
|
|
4103
|
+
if (anim.changePairs === 0) {
|
|
4104
|
+
return (
|
|
4105
|
+
' per-frame no two compared frames are adjacent, so nothing was measured about how much this shot ' +
|
|
4106
|
+
'changes from frame to frame'
|
|
4107
|
+
);
|
|
4108
|
+
}
|
|
4109
|
+
if (anim.changeDisagreements === 0) {
|
|
4110
|
+
return ` per-frame all ${anim.changePairs} adjacent pair(s) change by as much as the reference's own frames do`;
|
|
4111
|
+
}
|
|
4112
|
+
const worst = anim.frames.find((f) => f.index === anim.worstChangeFrame);
|
|
4113
|
+
const at =
|
|
4114
|
+
worst && worst.change
|
|
4115
|
+
? `; worst f${String(worst.index).padStart(4, '0')}, yours moved ${worst.change.candidate} px where the ` +
|
|
4116
|
+
`reference moved ${worst.change.reference}`
|
|
4117
|
+
: '';
|
|
4118
|
+
return (
|
|
4119
|
+
` per-frame ${anim.changeDisagreements} of ${anim.changePairs} adjacent pair(s) change by a different ` +
|
|
4120
|
+
`amount than the reference does${at}`
|
|
4121
|
+
);
|
|
4122
|
+
}
|
|
4123
|
+
|
|
4124
|
+
/** What one frame's change disagreement says, in the words that name the defect. */
|
|
4125
|
+
function changeNote(change: FrameChange | null): string {
|
|
4126
|
+
if (!change || change.verdict === 'agrees') return '';
|
|
4127
|
+
if (change.verdict === 'moves') {
|
|
4128
|
+
return change.reference === 0
|
|
4129
|
+
? 'the reference holds still here and yours does not'
|
|
4130
|
+
: `yours moves ${(change.candidate / Math.max(1, change.reference)).toFixed(0)}x the reference`;
|
|
4131
|
+
}
|
|
4132
|
+
return change.candidate === 0
|
|
4133
|
+
? 'the reference moves here and yours holds still'
|
|
4134
|
+
: `yours moves ${(change.reference / Math.max(1, change.candidate)).toFixed(0)}x less than the reference`;
|
|
4135
|
+
}
|
|
4136
|
+
|
|
4137
|
+
/**
|
|
4138
|
+
* What the fit did, in one word.
|
|
4139
|
+
*
|
|
4140
|
+
* The declared box is never "unsettled" — it was not being iterated towards, it
|
|
4141
|
+
* was measured and kept, and `coincident` says the measurement that kept it.
|
|
4142
|
+
*/
|
|
4143
|
+
function convergence(framing: FramingReport): string {
|
|
4144
|
+
if (framing.settled) return 'settled';
|
|
4145
|
+
// `extent-spread` cannot collide with `settled` above: it is only reached when
|
|
4146
|
+
// the correction is over `COINCIDENT_PIXELS`, and settled is under a tenth of one.
|
|
4147
|
+
if (framing.source === 'declared') return framing.agrees ? 'coincident' : 'extent-spread';
|
|
4148
|
+
return framing.cycled ? 'cycling' : 'unsettled';
|
|
4149
|
+
}
|
|
4150
|
+
|
|
4151
|
+
/**
|
|
4152
|
+
* The declared-box probe, as the one line that says which clause decided and by
|
|
4153
|
+
* how much — see `DeclaredBoxProbe`.
|
|
4154
|
+
*
|
|
4155
|
+
* Printed whether the box was taken or refused. A refusal used to be legible only
|
|
4156
|
+
* as the *absence* of `frames.json's own box` from the line above it, which is
|
|
4157
|
+
* exactly the shape of report a reader cannot check: rung 7 was refused on twelve
|
|
4158
|
+
* sets and the run had to reconstruct why from a diagnostic re-run (issue #194).
|
|
4159
|
+
*/
|
|
4160
|
+
function declaredBoxLines(probe: DeclaredBoxProbe | null, indent = ''): string[] {
|
|
4161
|
+
if (probe === null) return [];
|
|
4162
|
+
if (probe.clause === 'no-pixels') {
|
|
4163
|
+
return [
|
|
4164
|
+
`${indent} declared ${FRAMES_SIDECAR}'s own box: REFUSED — the candidate draws no pixel at all in it, ` +
|
|
4165
|
+
'which is the loudest possible "not these coordinates".',
|
|
4166
|
+
];
|
|
4167
|
+
}
|
|
4168
|
+
const asks = `a fit there asks for ${probe.distance.toFixed(2)} px`;
|
|
4169
|
+
const said =
|
|
4170
|
+
probe.clause === 'coincident'
|
|
4171
|
+
? `TAKEN, coincident — ${asks}, under the ${COINCIDENT_PIXELS} px that separates a candidate in the frames' ` +
|
|
4172
|
+
'coordinates from one in its own'
|
|
4173
|
+
: probe.clause === 'extent-spread'
|
|
4174
|
+
? `TAKEN, extent-spread — ${asks}, within the ${probe.reach.toFixed(2)} px this tolerance reaches ` +
|
|
4175
|
+
`(${(EXTENT_SPREAD_REACH * 100).toFixed(0)}% of the reference's box), and leaves ${probe.rms.toFixed(2)} ` +
|
|
4176
|
+
'px rms no similarity can absorb, which is a silhouette and not a transform'
|
|
4177
|
+
: `REFUSED, coordinates — ${asks}, ` +
|
|
4178
|
+
(probe.distance > probe.reach
|
|
4179
|
+
? `past the ${probe.reach.toFixed(2)} px the extent-spread tolerance reaches`
|
|
4180
|
+
: `and explains all but ${probe.rms.toFixed(2)} px rms of it`) +
|
|
4181
|
+
' — a different origin or a different unit';
|
|
4182
|
+
return [`${indent} declared ${FRAMES_SIDECAR}'s own box: ${said}, over ${probe.frames} frame(s).`];
|
|
4183
|
+
}
|
|
4184
|
+
|
|
4185
|
+
/** The framing, as the line an author reads before anything else. */
|
|
4186
|
+
/** The `framed to` line: the box that was rendered into, and how it was chosen. */
|
|
4187
|
+
function framedToLines(v: Framing, how: FramingHow | null, indent = ''): string[] {
|
|
4188
|
+
const said =
|
|
4189
|
+
how === 'candidate-pixels'
|
|
4190
|
+
? "fitted to the candidate's own drawn pixels"
|
|
4191
|
+
: how === 'frames-viewport'
|
|
4192
|
+
? `${FRAMES_SIDECAR}'s own box — the candidate measured into it`
|
|
4193
|
+
: '--viewport';
|
|
4194
|
+
return [
|
|
4195
|
+
`${indent} framed to ${v.pixelWidth}x${v.pixelHeight}px ${v.scale.toFixed(6)} px/unit ` +
|
|
4196
|
+
`world x[${v.x.toFixed(1)} .. ${(v.x + v.width).toFixed(1)}] y[${v.y.toFixed(1)} .. ${(v.y + v.height).toFixed(1)}] (${said})`,
|
|
4197
|
+
];
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
/**
|
|
4201
|
+
* What the MAE-refined pass found, in the words that separate its four answers.
|
|
4202
|
+
*
|
|
4203
|
+
* All four are printed, the identity included, because "the framing is already
|
|
4204
|
+
* where the picture is" is a measurement this pass makes and not a default it
|
|
4205
|
+
* falls back to — the same reason an assertion with nothing to measure reports
|
|
4206
|
+
* SKIP here rather than a pass. The two that decline a real offset are the loud
|
|
4207
|
+
* ones: they say the constant pixel is in the candidate rather than in the box.
|
|
4208
|
+
*/
|
|
4209
|
+
function refinementLines(refinement: FramingRefinement | null, indent = ''): string[] {
|
|
4210
|
+
if (refinement === null) return [];
|
|
4211
|
+
const { dx, dy, before, after, radius, frames } = refinement;
|
|
4212
|
+
const at = `${dx >= 0 ? '+' : ''}${dx}, ${dy >= 0 ? '+' : ''}${dy} px`;
|
|
4213
|
+
const worth =
|
|
4214
|
+
`${f2(before)} → ${f2(after)} over the reference's own pixels` +
|
|
4215
|
+
(before > 0 ? ` (${(((before - after) / before) * 100).toFixed(1)}% of the figure)` : '');
|
|
4216
|
+
const searched = `±${radius} px over ${frames} frame(s)`;
|
|
4217
|
+
if (refinement.declined === 'identity') {
|
|
4218
|
+
return [
|
|
4219
|
+
`${indent} ⤷ MAE-refined pass: searched ${searched} and the identity won, so no part of this set's ` +
|
|
4220
|
+
'figure is a constant offset.',
|
|
4221
|
+
];
|
|
4222
|
+
}
|
|
4223
|
+
if (refinement.declined === 'below-threshold') {
|
|
4224
|
+
return [
|
|
4225
|
+
`${indent} ⤷ MAE-refined pass: the best offset in ${searched} was ${at}, worth ${worth} — under the ` +
|
|
4226
|
+
`${(REFINE_MIN_GAIN * 100).toFixed(0)}% / ${REFINE_MIN_GAIN_MAE.toFixed(2)} MAE this pass moves a box for, so ` +
|
|
4227
|
+
'the box was left alone.',
|
|
4228
|
+
];
|
|
4229
|
+
}
|
|
4230
|
+
if (refinement.declined === 'box-is-exact') {
|
|
4231
|
+
return [
|
|
4232
|
+
`${indent} ⚠️ a constant ${at} would take this set ${worth} — and it was NOT applied, because this ` +
|
|
4233
|
+
`box is ${FRAMES_SIDECAR}'s own and is not an estimate of anything. A constant pixel inside the box the ` +
|
|
4234
|
+
'frames were drawn at is your own figure sitting a pixel off, which is a thing to fix rather than to frame ' +
|
|
4235
|
+
'away. Read it beside the drift below.',
|
|
4236
|
+
];
|
|
4237
|
+
}
|
|
4238
|
+
if (refinement.declined === 'pinned') {
|
|
4239
|
+
return [
|
|
4240
|
+
`${indent} ⤷ a constant ${at} would take this set ${worth} — measured, NOT applied, because ` +
|
|
4241
|
+
'--viewport pinned the box.',
|
|
4242
|
+
];
|
|
4243
|
+
}
|
|
4244
|
+
return [
|
|
4245
|
+
`${indent} ⭐ MAE-refined by ${at}: ${worth}. The fit above registers the two extents, and the best ` +
|
|
4246
|
+
'fit of two extents is not the best alignment of two pictures — this pass takes that difference out, so the ' +
|
|
4247
|
+
'figures below are what is left after it rather than a constant offset read as motion.',
|
|
4248
|
+
];
|
|
4249
|
+
}
|
|
4250
|
+
|
|
4251
|
+
function framingLines(framing: FramingReport | null, indent = ''): string[] {
|
|
4252
|
+
if (!framing) return [];
|
|
4253
|
+
const { fit } = framing;
|
|
4254
|
+
const c = fit.candidate;
|
|
4255
|
+
const r = fit.reference;
|
|
4256
|
+
const percent = (n: number): string => `${n >= 0 ? '+' : ''}${(n * 100).toFixed(2)}%`;
|
|
4257
|
+
const box = (b: ContentBox): string =>
|
|
4258
|
+
`${boxWidth(b).toFixed(1)}x${boxHeight(b).toFixed(1)}px at (${b.left.toFixed(1)}, ${b.top.toFixed(1)})`;
|
|
4259
|
+
const signed = (n: number): string => `${n >= 0 ? '+' : ''}${n.toFixed(2)}`;
|
|
4260
|
+
const out = [
|
|
4261
|
+
`${indent} content candidate ${box(c)} reference ${box(r)} (union over ${fit.frames} frame(s))`,
|
|
4262
|
+
`${indent} ⤷ fit x${fit.scale.toFixed(6)} offset ${signed(fit.dx)}, ${signed(fit.dy)} px ` +
|
|
4263
|
+
`rms ${fit.rms.toFixed(2)} px over ${fit.frames * 4} edge(s) ` +
|
|
4264
|
+
`union residual ${signed(fit.residualWidth)} x ${signed(fit.residualHeight)} px ` +
|
|
4265
|
+
`aspect ${percent(fit.aspectError)}` +
|
|
4266
|
+
(framing.applied
|
|
4267
|
+
? ` (${framing.source}, ${framing.passes} pass(es), ${convergence(framing)})`
|
|
4268
|
+
: ' (measured, NOT applied — --viewport pinned)'),
|
|
4269
|
+
];
|
|
4270
|
+
out.push(...refinementLines(framing.refinement, indent));
|
|
4271
|
+
const spread = Math.max(Math.abs(fit.residualWidth), Math.abs(fit.residualHeight));
|
|
4272
|
+
if (spread > 1) {
|
|
4273
|
+
const axis = fit.residualWidth > 0 ? 'wider' : 'narrower';
|
|
4274
|
+
out.push(
|
|
4275
|
+
`${indent} ⚠️ after the fit your shot still covers ${Math.abs(fit.residualWidth).toFixed(1)} px ` +
|
|
4276
|
+
`${axis} and ${Math.abs(fit.residualHeight).toFixed(1)} px ` +
|
|
4277
|
+
`${fit.residualHeight > 0 ? 'taller' : 'shorter'} than the reference's. One uniform scale cannot absorb ` +
|
|
4278
|
+
'that: something reaches somewhere nothing in the frames does, or is a different size. Read it before ' +
|
|
4279
|
+
'reading a drift.',
|
|
4280
|
+
);
|
|
4281
|
+
}
|
|
4282
|
+
if (fit.rms > 1) {
|
|
4283
|
+
out.push(
|
|
4284
|
+
`${indent} ⚠️ the fit leaves ${fit.rms.toFixed(2)} px rms across the frames' edges, so no single ` +
|
|
4285
|
+
'scale and offset puts the two shots on each other — they are different shapes, not the same shape ' +
|
|
4286
|
+
'misframed.',
|
|
4287
|
+
);
|
|
4288
|
+
}
|
|
4289
|
+
const units = framing.units;
|
|
4290
|
+
if (units) {
|
|
4291
|
+
out.push(
|
|
4292
|
+
`${indent} in units candidate ${units.candidate.width.toFixed(1)} x ${units.candidate.height.toFixed(1)} ` +
|
|
4293
|
+
`reference ${units.reference.width.toFixed(1)} x ${units.reference.height.toFixed(1)} ` +
|
|
4294
|
+
`x${units.ratio.toFixed(4)}`,
|
|
4295
|
+
);
|
|
4296
|
+
out.push(
|
|
4297
|
+
`${indent} ⤷ the same two boxes in world units. The framing absorbs a difference of pure scale on ` +
|
|
4298
|
+
'purpose — a rig is authored in its own coordinates — so this is the only place one shows. It compares ' +
|
|
4299
|
+
'only if you measured the shot in the frames’ own units.',
|
|
4300
|
+
);
|
|
4301
|
+
}
|
|
4302
|
+
return out;
|
|
4303
|
+
}
|