rig-c 0.0.0-stage → 2.20.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1188 -0
- package/src/meshquality.ts +2042 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1425 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/slots.ts
ADDED
|
@@ -0,0 +1,892 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Slot tracking — which part landed where, and when that cannot be said.
|
|
3
|
+
*
|
|
4
|
+
* ## Two matchers, in order, and a cap on both
|
|
5
|
+
*
|
|
6
|
+
* The cheap matcher labels the reference frame's connected components and asks
|
|
7
|
+
* which one each of the candidate's slots landed on. It is right whenever the
|
|
8
|
+
* parts of a shot are separate blobs, and it has three failure modes that honest
|
|
9
|
+
* ladder runs hit head-on (issues #34 and #37) — all three the same mistake, which
|
|
10
|
+
* is treating a blob as a part:
|
|
11
|
+
*
|
|
12
|
+
* - **Parts that touch label as one component.** Rung 4 is a disc with five chain
|
|
13
|
+
* links hanging off it; they touch in every frame of every animation, so every
|
|
14
|
+
* slot came back "ambiguous" and the run produced **no drift table at all**.
|
|
15
|
+
* - **Nearest-centroid has no notion of "too far to be the same thing".** Rung 5's
|
|
16
|
+
* 4 px ball, on the frames where it rests against the course and has no component
|
|
17
|
+
* of its own, matched the floating girder 47 px away — and the summary line
|
|
18
|
+
* reported **48.3 px of drift for a 4 px ball**, unflagged.
|
|
19
|
+
* - **A blob one part dominates passes for that part.** Rung 2's reference merges
|
|
20
|
+
* the course, the water, the panel and both rings into one component in which the
|
|
21
|
+
* course is 81 % of the ink, so it is 1.24x the course's own and no wider than its
|
|
22
|
+
* box — both merge tests see nothing, and the run reported *"course drift
|
|
23
|
+
* 11.2 px"*, the distance to a five-part centroid (issue #37). `occupantsOf`
|
|
24
|
+
* answers that one with the label map: anything else the candidate drew inside the
|
|
25
|
+
* blob makes the blob's centroid nobody's position.
|
|
26
|
+
*
|
|
27
|
+
* So: components first, and when a component cannot be attributed to one slot, the
|
|
28
|
+
* slot's own rendered quad is **template-matched** against the reference in a
|
|
29
|
+
* window around where the candidate drew it. Touching parts stop being fatal —
|
|
30
|
+
* a link that overlaps its neighbour still correlates against its own pixels.
|
|
31
|
+
*
|
|
32
|
+
* ⛔ **Its own pixels means the ones you can see.** The template is the slot drawn
|
|
33
|
+
* alone and the reference is a composite, so a template pixel the candidate itself
|
|
34
|
+
* draws something over matches nothing at the true offset — and a residual that is
|
|
35
|
+
* there at every offset is not evidence about position. Left in, it *moves the
|
|
36
|
+
* answer*: issue #698 measured four of the seven shipped examples reporting
|
|
37
|
+
* 0.8–2.2 px against frames rendered from themselves, and on every one the score
|
|
38
|
+
* field's own whole-pixel minimum sat off the identity offset because the covered
|
|
39
|
+
* samples' gradient outvoted a shallow basin. `visibleField` drops them, and the
|
|
40
|
+
* minimum comes back to the origin on all seven.
|
|
41
|
+
*
|
|
42
|
+
* ⛔ And both matchers are capped by `searchRadius`: a part may be displaced by
|
|
43
|
+
* about its own size and still be the same part in the picture, and past that the
|
|
44
|
+
* honest report is **no match**. A number that is not a measurement of the slot it
|
|
45
|
+
* is printed beside is worse than a blank, because it is actionable and wrong.
|
|
46
|
+
*/
|
|
47
|
+
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
48
|
+
import { backgroundDistance, isContent } from './framing.ts';
|
|
49
|
+
import {
|
|
50
|
+
frameGeometry,
|
|
51
|
+
pageFor,
|
|
52
|
+
projector,
|
|
53
|
+
rasterisePiece,
|
|
54
|
+
type Frame,
|
|
55
|
+
type Footprint,
|
|
56
|
+
type Viewport,
|
|
57
|
+
} from './render_shared.ts';
|
|
58
|
+
|
|
59
|
+
/** Components smaller than this are antialiasing crumbs, not parts. */
|
|
60
|
+
const MIN_COMPONENT_PIXELS = 4;
|
|
61
|
+
/** A second component this close to the nearest makes a component match a guess. */
|
|
62
|
+
const AMBIGUITY_RATIO = 1.25;
|
|
63
|
+
/**
|
|
64
|
+
* How much bigger than the slot a component may be and still be *that slot's*.
|
|
65
|
+
*
|
|
66
|
+
* Above it, the slot is inside something larger — the reference merged it with a
|
|
67
|
+
* neighbour, or drew it behind one — and the component's centroid is the merged
|
|
68
|
+
* blob's, not the part's.
|
|
69
|
+
*
|
|
70
|
+
* ⚠️ Pixel count alone is not enough, and rung 3's transcription is the proof: at
|
|
71
|
+
* `heavy/f0009` the pendulum touches the block, the two label as one 1227 px blob,
|
|
72
|
+
* and the pendulum's own 836 px makes that only 1.47x — under any ratio loose
|
|
73
|
+
* enough to tolerate antialiasing. The blob's **bounding box** gives it away (60 px
|
|
74
|
+
* wide against the slot's 39), so both tests have to pass. Erring towards "merged"
|
|
75
|
+
* is the safe direction: a slot wrongly called merged still gets a drift from the
|
|
76
|
+
* template matcher, where a merged blob wrongly called the slot's own reports the
|
|
77
|
+
* blob's centroid as the part's position.
|
|
78
|
+
*/
|
|
79
|
+
const MERGE_RATIO = 1.6;
|
|
80
|
+
/** How much wider or taller than the slot a component may be, as a fraction. */
|
|
81
|
+
const MERGE_MARGIN = 0.1;
|
|
82
|
+
/** ...and never less than this, so antialiasing alone cannot trip it. */
|
|
83
|
+
const MERGE_MARGIN_PIXELS = 2;
|
|
84
|
+
/** Displacement bounds, in frame pixels: never search less, never search more. */
|
|
85
|
+
const MIN_SEARCH_RADIUS = 4;
|
|
86
|
+
const MAX_SEARCH_RADIUS = 32;
|
|
87
|
+
/** Search radius as a fraction of the slot's own long side. */
|
|
88
|
+
const SEARCH_SPAN = 0.75;
|
|
89
|
+
/** How many template pixels a correlation samples, at most. */
|
|
90
|
+
const MAX_SAMPLES = 256;
|
|
91
|
+
/** A rival peak must be at least this far from the winner to count as a rival. */
|
|
92
|
+
const RIVAL_GAP = 3;
|
|
93
|
+
/**
|
|
94
|
+
* How distinctive a correlation peak must be before its offset is reported.
|
|
95
|
+
*
|
|
96
|
+
* ⭐ It rises with the displacement being claimed, and that is the whole point. A
|
|
97
|
+
* peak sitting where the candidate already drew the slot is only confirming a
|
|
98
|
+
* position, so a weak peak is enough; a peak claiming the part is most of a search
|
|
99
|
+
* radius away is claiming something big, and the bar for it is correspondingly
|
|
100
|
+
* high. Rung 4 is the case that fixed the constant: `chain1` — a 9x27 sliver in a
|
|
101
|
+
* chain of near-identical links — correlated 26 px away at confidence 0.16, and
|
|
102
|
+
* that number went straight into the summary line as the run's worst drift. Under
|
|
103
|
+
* this rule it needs 0.60 and comes back as **no match**, which is the honest
|
|
104
|
+
* answer for a repetitive structure.
|
|
105
|
+
*/
|
|
106
|
+
const MIN_CONFIDENCE = 0.15;
|
|
107
|
+
/** How much more distinctive a peak must be at a full radius out than at zero. */
|
|
108
|
+
const CONFIDENCE_SLOPE = 0.45;
|
|
109
|
+
/** The residual must be under this fraction of the slot's own contrast. */
|
|
110
|
+
const MAX_RESIDUAL_FRACTION = 0.5;
|
|
111
|
+
|
|
112
|
+
export interface Component {
|
|
113
|
+
pixels: number;
|
|
114
|
+
cx: number;
|
|
115
|
+
cy: number;
|
|
116
|
+
minX: number;
|
|
117
|
+
minY: number;
|
|
118
|
+
maxX: number;
|
|
119
|
+
maxY: number;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* The reference frame's components, and which one each pixel belongs to.
|
|
124
|
+
*
|
|
125
|
+
* The label map is what makes "is anything ELSE inside this blob?" a measurement
|
|
126
|
+
* rather than a guess from bounding boxes — see `occupantsOf`. It is indexed
|
|
127
|
+
* row-major on the frame's own grid, and `-1` is background or a component too
|
|
128
|
+
* small to be a part.
|
|
129
|
+
*/
|
|
130
|
+
export interface ComponentField {
|
|
131
|
+
components: Component[];
|
|
132
|
+
labels: Int32Array;
|
|
133
|
+
width: number;
|
|
134
|
+
height: number;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Connected components of "not the background colour", 8-connected.
|
|
139
|
+
*
|
|
140
|
+
* 8-connected rather than 4: a thin diagonal — a bar, a stick, a shadow's edge —
|
|
141
|
+
* breaks into a dotted line under 4-connectivity, and then one part reads as
|
|
142
|
+
* twenty and every match is ambiguous for a reason that is about the labeller.
|
|
143
|
+
*/
|
|
144
|
+
export function componentsOf(plate: Plate, background: RGBA): Component[] {
|
|
145
|
+
return componentField(plate, background).components;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** The same labelling, with the map kept — see `ComponentField`. */
|
|
149
|
+
export function componentField(plate: Plate, background: RGBA): ComponentField {
|
|
150
|
+
const { width, height } = plate;
|
|
151
|
+
const label = new Int32Array(width * height).fill(-1);
|
|
152
|
+
const out: Component[] = [];
|
|
153
|
+
const stack: number[] = [];
|
|
154
|
+
for (let y0 = 0; y0 < height; y0++) {
|
|
155
|
+
for (let x0 = 0; x0 < width; x0++) {
|
|
156
|
+
const seed = y0 * width + x0;
|
|
157
|
+
if (label[seed] !== -1 || !isContent(plate, x0, y0, background)) continue;
|
|
158
|
+
const id = out.length;
|
|
159
|
+
label[seed] = id;
|
|
160
|
+
stack.push(seed);
|
|
161
|
+
let pixels = 0;
|
|
162
|
+
let sx = 0;
|
|
163
|
+
let sy = 0;
|
|
164
|
+
let minX = width;
|
|
165
|
+
let minY = height;
|
|
166
|
+
let maxX = -1;
|
|
167
|
+
let maxY = -1;
|
|
168
|
+
while (stack.length > 0) {
|
|
169
|
+
const at = stack.pop() as number;
|
|
170
|
+
const x = at % width;
|
|
171
|
+
const y = (at - x) / width;
|
|
172
|
+
pixels++;
|
|
173
|
+
sx += x + 0.5;
|
|
174
|
+
sy += y + 0.5;
|
|
175
|
+
if (x < minX) minX = x;
|
|
176
|
+
if (x > maxX) maxX = x;
|
|
177
|
+
if (y < minY) minY = y;
|
|
178
|
+
if (y > maxY) maxY = y;
|
|
179
|
+
for (let dy = -1; dy <= 1; dy++) {
|
|
180
|
+
for (let dx = -1; dx <= 1; dx++) {
|
|
181
|
+
const nx = x + dx;
|
|
182
|
+
const ny = y + dy;
|
|
183
|
+
if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
|
|
184
|
+
const n = ny * width + nx;
|
|
185
|
+
if (label[n] !== -1 || !isContent(plate, nx, ny, background)) continue;
|
|
186
|
+
label[n] = id;
|
|
187
|
+
stack.push(n);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
out.push({ pixels, cx: sx / pixels, cy: sy / pixels, minX, minY, maxX: maxX + 1, maxY: maxY + 1 });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
// Crumbs out, biggest first — and the label map carried through the reorder, so
|
|
195
|
+
// a label is always an index into the array the caller is handed. Renumbering
|
|
196
|
+
// rather than sorting the map is what keeps the two from drifting apart.
|
|
197
|
+
const keep = out.map((c, id) => ({ c, id })).filter(({ c }) => c.pixels >= MIN_COMPONENT_PIXELS);
|
|
198
|
+
keep.sort((a, b) => b.c.pixels - a.c.pixels);
|
|
199
|
+
const renumbered = new Int32Array(out.length).fill(-1);
|
|
200
|
+
keep.forEach(({ id }, index) => {
|
|
201
|
+
renumbered[id] = index;
|
|
202
|
+
});
|
|
203
|
+
for (let at = 0; at < label.length; at++) label[at] = label[at] < 0 ? -1 : renumbered[label[at]];
|
|
204
|
+
return { components: keep.map(({ c }) => c), labels: label, width, height };
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Which drawn slots' ink sits inside each component, by centroid.
|
|
209
|
+
*
|
|
210
|
+
* ## Why this exists: a blob one part dominates is still a blob
|
|
211
|
+
*
|
|
212
|
+
* The size and bounding-box tests below catch a merge when the merged neighbour is
|
|
213
|
+
* a material fraction of the blob. They cannot catch the case issue #37 filed:
|
|
214
|
+
* rung 2's reference merges the course, the water, the panel and both rings into
|
|
215
|
+
* one component, and the **course is 81 % of it**, so the blob is only 1.24x the
|
|
216
|
+
* course's own ink and barely wider than the course's own box. It passed every
|
|
217
|
+
* test, and the summary line reported *"course drift 11.2 px"* — the distance from
|
|
218
|
+
* the course's centroid to the centroid of a blob holding four other parts, which
|
|
219
|
+
* is not a measurement of the course at all.
|
|
220
|
+
*
|
|
221
|
+
* One label lookup per drawn slot answers it exactly: if anything else the
|
|
222
|
+
* candidate drew lands on this component's own pixels, the component is more than
|
|
223
|
+
* one part and its centroid is nobody's position. That is the same judgement the
|
|
224
|
+
* two-claimants rule below already makes; what was missing is that a slot which
|
|
225
|
+
* never got as far as *claiming* the blob — because it was refused for being 13x
|
|
226
|
+
* too small, or diverted to the template matcher — still proves the blob is shared.
|
|
227
|
+
*/
|
|
228
|
+
function occupantsOf(field: ComponentField, footprints: Map<string, Footprint>): Map<number, string[]> {
|
|
229
|
+
const out = new Map<number, string[]>();
|
|
230
|
+
for (const [slot, foot] of footprints) {
|
|
231
|
+
if (foot.pixels === 0) continue;
|
|
232
|
+
const x = Math.floor(foot.cx);
|
|
233
|
+
const y = Math.floor(foot.cy);
|
|
234
|
+
if (x < 0 || y < 0 || x >= field.width || y >= field.height) continue;
|
|
235
|
+
const label = field.labels[y * field.width + x];
|
|
236
|
+
if (label < 0) continue;
|
|
237
|
+
const seen = out.get(label) ?? [];
|
|
238
|
+
seen.push(slot);
|
|
239
|
+
out.set(label, seen);
|
|
240
|
+
}
|
|
241
|
+
return out;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** How the drift beside a slot was arrived at. `none` means it could not be. */
|
|
245
|
+
export type MatchMethod = 'component' | 'template' | 'none';
|
|
246
|
+
|
|
247
|
+
export interface SlotTrack {
|
|
248
|
+
slot: string;
|
|
249
|
+
candidate: { cx: number; cy: number; width: number; height: number; pixels: number } | null;
|
|
250
|
+
method: MatchMethod;
|
|
251
|
+
/** The reference component this slot was matched to — component matches only. */
|
|
252
|
+
reference: { cx: number; cy: number; width: number; height: number; pixels: number } | null;
|
|
253
|
+
/** Centroid distance in frame pixels, or the correlation offset's length. */
|
|
254
|
+
drift: number | null;
|
|
255
|
+
/** The same displacement with its direction, reference minus candidate. */
|
|
256
|
+
driftX: number | null;
|
|
257
|
+
driftY: number | null;
|
|
258
|
+
/** Bounding-box differences — component matches only, where a bbox is known. */
|
|
259
|
+
widthDrift: number | null;
|
|
260
|
+
heightDrift: number | null;
|
|
261
|
+
/** 0..1 for a template match: how much better the winner is than its best rival. */
|
|
262
|
+
confidence: number | null;
|
|
263
|
+
/** How far the match was allowed to look, in frame pixels. */
|
|
264
|
+
searchRadius: number | null;
|
|
265
|
+
/**
|
|
266
|
+
* The whole-pixel offset the sub-pixel step refined — template matches only.
|
|
267
|
+
*
|
|
268
|
+
* It is the half of the answer that is a *displacement*: `(0, 0)` says the
|
|
269
|
+
* correlation put the part where the candidate drew it and everything in
|
|
270
|
+
* `drift` is the parabola's own residue.
|
|
271
|
+
*/
|
|
272
|
+
wholePixel: { dx: number; dy: number } | null;
|
|
273
|
+
/** Set when the drift is not a measurement of this slot, saying why. */
|
|
274
|
+
ambiguity: string | null;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* The most this match could have read, given the winner it found.
|
|
279
|
+
*
|
|
280
|
+
* `hypot(|dx| + SUBPIXEL_CLAMP, |dy| + SUBPIXEL_CLAMP)`, because the sub-pixel
|
|
281
|
+
* step is clamped to half a pixel on each axis. A reader comparing a drift to a
|
|
282
|
+
* floor needs it beside the figure: 0.44 px under a bound of 0.71 px is the
|
|
283
|
+
* instrument at rest, and 0.44 px under a bound of 1.58 px is a part that moved a
|
|
284
|
+
* whole pixel and came most of the way back. `null` for a component match — a
|
|
285
|
+
* distance between two centroids is bounded by nothing but the search radius.
|
|
286
|
+
*
|
|
287
|
+
* 🔒 **A function and not a field**, and the red-first run is why. It was stored
|
|
288
|
+
* on the track first, and `C29`'s plant — one track's whole-pixel winner forced a
|
|
289
|
+
* pixel off the identity — printed *"bounded by 0.71 px — the correlation moved
|
|
290
|
+
* this slot by (1, 0) whole pixel(s)"*: the sentence and the figure beside it came
|
|
291
|
+
* from two fields that could disagree, which is this repository's own antipattern
|
|
292
|
+
* with a drift figure attached to it. One derivation, one place.
|
|
293
|
+
*/
|
|
294
|
+
export function driftBound(track: SlotTrack): number | null {
|
|
295
|
+
if (track.method !== 'template' || track.wholePixel === null) return null;
|
|
296
|
+
return Math.hypot(Math.abs(track.wholePixel.dx) + SUBPIXEL_CLAMP, Math.abs(track.wholePixel.dy) + SUBPIXEL_CLAMP);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* How far this slot may be displaced and still be the same thing in the picture.
|
|
301
|
+
*
|
|
302
|
+
* Tied to the slot's own size, because that is what makes the bound mean
|
|
303
|
+
* something: past about its own long side a part no longer overlaps where it was,
|
|
304
|
+
* and a correlation peak out there is another object, not this one moved.
|
|
305
|
+
*/
|
|
306
|
+
export function searchRadius(width: number, height: number): number {
|
|
307
|
+
const span = Math.round(Math.max(width, height) * SEARCH_SPAN);
|
|
308
|
+
return Math.max(MIN_SEARCH_RADIUS, Math.min(MAX_SEARCH_RADIUS, span));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** What the template matcher needs to draw one slot on its own. */
|
|
312
|
+
export interface SlotSource {
|
|
313
|
+
frame: Frame;
|
|
314
|
+
pages: Map<string, Plate>;
|
|
315
|
+
viewport: Viewport;
|
|
316
|
+
background: RGBA;
|
|
317
|
+
reference: Plate;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// ---------------------------------------------------------------------------
|
|
321
|
+
// the component pass
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
|
|
324
|
+
interface Pending {
|
|
325
|
+
track: SlotTrack;
|
|
326
|
+
foot: Footprint;
|
|
327
|
+
claimed: Component | null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Match each drawn slot to a reference component, then template-match the rest.
|
|
332
|
+
*
|
|
333
|
+
* Returns the tracks in slot-name order, plus how many reference components the
|
|
334
|
+
* candidate accounted for — a component nothing overlaps is something in the shot
|
|
335
|
+
* the candidate has not drawn.
|
|
336
|
+
*/
|
|
337
|
+
export function matchSlots(
|
|
338
|
+
footprints: Map<string, Footprint>,
|
|
339
|
+
field: ComponentField,
|
|
340
|
+
source: SlotSource | null,
|
|
341
|
+
): { tracks: SlotTrack[]; matchedComponents: number } {
|
|
342
|
+
const components = field.components;
|
|
343
|
+
const pending: Pending[] = [];
|
|
344
|
+
const takenBy = new Map<Component, string[]>();
|
|
345
|
+
const occupants = occupantsOf(field, footprints);
|
|
346
|
+
/** Which component each one is, so an occupancy list can be looked up by it. */
|
|
347
|
+
const idOf = new Map<Component, number>();
|
|
348
|
+
components.forEach((component, id) => idOf.set(component, id));
|
|
349
|
+
|
|
350
|
+
for (const [slot, foot] of [...footprints].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
351
|
+
const track = blankTrack(slot);
|
|
352
|
+
if (foot.pixels === 0) {
|
|
353
|
+
track.ambiguity = 'the candidate draws nothing here — the slot is empty or entirely outside the frame';
|
|
354
|
+
pending.push({ track, foot, claimed: null });
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
track.candidate = {
|
|
358
|
+
cx: foot.cx,
|
|
359
|
+
cy: foot.cy,
|
|
360
|
+
width: foot.maxX - foot.minX,
|
|
361
|
+
height: foot.maxY - foot.minY,
|
|
362
|
+
pixels: Math.round(foot.pixels),
|
|
363
|
+
};
|
|
364
|
+
const radius = searchRadius(track.candidate.width, track.candidate.height);
|
|
365
|
+
track.searchRadius = radius;
|
|
366
|
+
if (components.length === 0) {
|
|
367
|
+
track.ambiguity = 'the reference frame is empty — nothing to match against';
|
|
368
|
+
pending.push({ track, foot, claimed: null });
|
|
369
|
+
continue;
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
// A component whose box holds this slot's centroid and is not much bigger than
|
|
373
|
+
// the slot is that slot's own blob. One much bigger than the slot is a merge:
|
|
374
|
+
// its centroid is the merged shape's and says nothing about this part.
|
|
375
|
+
const covering = components.filter(
|
|
376
|
+
(c) => foot.cx >= c.minX - 1 && foot.cx <= c.maxX + 1 && foot.cy >= c.minY - 1 && foot.cy <= c.maxY + 1,
|
|
377
|
+
);
|
|
378
|
+
const width = track.candidate.width;
|
|
379
|
+
const height = track.candidate.height;
|
|
380
|
+
const margin = Math.max(MERGE_MARGIN_PIXELS, MERGE_MARGIN * Math.max(width, height));
|
|
381
|
+
const own = covering
|
|
382
|
+
.filter(
|
|
383
|
+
(c) =>
|
|
384
|
+
c.pixels <= foot.pixels * MERGE_RATIO &&
|
|
385
|
+
c.maxX - c.minX <= width + margin &&
|
|
386
|
+
c.maxY - c.minY <= height + margin,
|
|
387
|
+
)
|
|
388
|
+
.sort((a, b) => Math.abs(a.pixels - foot.pixels) - Math.abs(b.pixels - foot.pixels))[0];
|
|
389
|
+
|
|
390
|
+
// Containment plus a compatible size is a far stronger claim than nearest
|
|
391
|
+
// centroid, so it is taken at face value and the runner-up test below — which
|
|
392
|
+
// exists to catch a *guess* — does not apply to it.
|
|
393
|
+
if (own) {
|
|
394
|
+
fillComponentMatch(track, foot, own);
|
|
395
|
+
claim(takenBy, own, slot);
|
|
396
|
+
pending.push({ track, foot, claimed: own });
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
if (covering.length > 0) {
|
|
401
|
+
const biggest = covering.sort((a, b) => b.pixels - a.pixels)[0];
|
|
402
|
+
track.ambiguity =
|
|
403
|
+
`this slot is inside a reference component ${(biggest.pixels / Math.max(1, foot.pixels)).toFixed(1)}x its ` +
|
|
404
|
+
`size and ${biggest.maxX - biggest.minX}x${biggest.maxY - biggest.minY} px against its ${width}x${height} — ` +
|
|
405
|
+
'the reference merged it with something it touches or is drawn behind';
|
|
406
|
+
pending.push({ track, foot, claimed: null });
|
|
407
|
+
continue;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
// Nothing contains it: the slot landed in open background. Nearest centroid is
|
|
411
|
+
// a guess, so it is bounded by what this slot could plausibly have moved, and
|
|
412
|
+
// a rival about as near makes it a guess between two things.
|
|
413
|
+
const ranked = components
|
|
414
|
+
.map((c) => ({ c, d: Math.hypot(c.cx - foot.cx, c.cy - foot.cy) }))
|
|
415
|
+
.sort((a, b) => a.d - b.d);
|
|
416
|
+
const nearest = ranked[0];
|
|
417
|
+
if (nearest.d > radius) {
|
|
418
|
+
track.ambiguity =
|
|
419
|
+
`the nearest reference component is ${nearest.d.toFixed(1)} px away, past the ${radius} px this ` +
|
|
420
|
+
`${Math.round(Math.max(track.candidate.width, track.candidate.height))} px slot could have moved and still ` +
|
|
421
|
+
'be itself';
|
|
422
|
+
pending.push({ track, foot, claimed: null });
|
|
423
|
+
continue;
|
|
424
|
+
}
|
|
425
|
+
if (ranked[1] && ranked[1].d <= nearest.d * AMBIGUITY_RATIO) {
|
|
426
|
+
track.ambiguity =
|
|
427
|
+
`two reference components are about equally near (${nearest.d.toFixed(1)} px and ${ranked[1].d.toFixed(1)} px)`;
|
|
428
|
+
pending.push({ track, foot, claimed: null });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
fillComponentMatch(track, foot, nearest.c);
|
|
432
|
+
claim(takenBy, nearest.c, slot);
|
|
433
|
+
pending.push({ track, foot, claimed: nearest.c });
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// A component two slots both claim is one blob the reference merged. Neither
|
|
437
|
+
// claim is a measurement of its slot, so both drop to the template matcher.
|
|
438
|
+
for (const [component, claimants] of takenBy) {
|
|
439
|
+
if (claimants.length < 2) continue;
|
|
440
|
+
for (const entry of pending) {
|
|
441
|
+
if (!claimants.includes(entry.track.slot)) continue;
|
|
442
|
+
const others = claimants.filter((s) => s !== entry.track.slot);
|
|
443
|
+
entry.track.ambiguity =
|
|
444
|
+
`shares one reference component with ${others.map((s) => JSON.stringify(s)).join(', ')} — they touch or ` +
|
|
445
|
+
'overlap in this frame';
|
|
446
|
+
entry.claimed = component;
|
|
447
|
+
clearMatch(entry.track);
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// ...and a component ONE slot claimed while other ink of the candidate's sits
|
|
452
|
+
// inside it is the same blob by the other route — the one that reported a
|
|
453
|
+
// dominant part's distance to a five-part blob as that part's drift (#37). The
|
|
454
|
+
// claim goes to the template matcher for the same reason: a centroid shared by
|
|
455
|
+
// several parts is not this part's position.
|
|
456
|
+
for (const entry of pending) {
|
|
457
|
+
if (entry.claimed === null || entry.track.ambiguity !== null) continue;
|
|
458
|
+
const id = idOf.get(entry.claimed);
|
|
459
|
+
if (id === undefined) continue;
|
|
460
|
+
const others = (occupants.get(id) ?? []).filter((slot) => slot !== entry.track.slot);
|
|
461
|
+
if (others.length === 0) continue;
|
|
462
|
+
entry.track.ambiguity =
|
|
463
|
+
`this slot's reference component also holds ${others.map((s) => JSON.stringify(s)).join(', ')} — ` +
|
|
464
|
+
`${entry.claimed.pixels} px of blob against this slot's own ${Math.round(entry.foot.pixels)} px, so its ` +
|
|
465
|
+
"centroid is the merged shape's and not this part's";
|
|
466
|
+
clearMatch(entry.track);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// The fallback: anything the components could not attribute, correlated against
|
|
470
|
+
// the pixels of its own that the candidate's composite lets show. The owner mask
|
|
471
|
+
// is a property of the frame rather than of the slot, so it is taken once and
|
|
472
|
+
// only when something is actually going to correlate against it.
|
|
473
|
+
if (source) {
|
|
474
|
+
const wanted = pending.filter(
|
|
475
|
+
(entry) => entry.track.ambiguity !== null && entry.track.candidate !== null && entry.foot.pixels > 0,
|
|
476
|
+
);
|
|
477
|
+
if (wanted.length > 0) {
|
|
478
|
+
const visible = visibleField(source);
|
|
479
|
+
for (const entry of wanted) applyTemplateMatch(entry.track, entry.foot, source, visible);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
const tracks = pending.map((p) => p.track);
|
|
484
|
+
return { tracks, matchedComponents: countExplained(footprints, components) };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
function claim(takenBy: Map<Component, string[]>, component: Component, slot: string): void {
|
|
488
|
+
const claimants = takenBy.get(component) ?? [];
|
|
489
|
+
claimants.push(slot);
|
|
490
|
+
takenBy.set(component, claimants);
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
function blankTrack(slot: string): SlotTrack {
|
|
494
|
+
return {
|
|
495
|
+
slot,
|
|
496
|
+
candidate: null,
|
|
497
|
+
method: 'none',
|
|
498
|
+
reference: null,
|
|
499
|
+
drift: null,
|
|
500
|
+
driftX: null,
|
|
501
|
+
driftY: null,
|
|
502
|
+
widthDrift: null,
|
|
503
|
+
heightDrift: null,
|
|
504
|
+
confidence: null,
|
|
505
|
+
searchRadius: null,
|
|
506
|
+
wholePixel: null,
|
|
507
|
+
ambiguity: null,
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function fillComponentMatch(track: SlotTrack, foot: Footprint, component: Component): void {
|
|
512
|
+
track.method = 'component';
|
|
513
|
+
track.reference = {
|
|
514
|
+
cx: component.cx,
|
|
515
|
+
cy: component.cy,
|
|
516
|
+
width: component.maxX - component.minX,
|
|
517
|
+
height: component.maxY - component.minY,
|
|
518
|
+
pixels: component.pixels,
|
|
519
|
+
};
|
|
520
|
+
track.driftX = component.cx - foot.cx;
|
|
521
|
+
track.driftY = component.cy - foot.cy;
|
|
522
|
+
track.drift = Math.hypot(track.driftX, track.driftY);
|
|
523
|
+
track.widthDrift = foot.maxX - foot.minX - track.reference.width;
|
|
524
|
+
track.heightDrift = foot.maxY - foot.minY - track.reference.height;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function clearMatch(track: SlotTrack): void {
|
|
528
|
+
track.method = 'none';
|
|
529
|
+
track.reference = null;
|
|
530
|
+
track.drift = null;
|
|
531
|
+
track.driftX = null;
|
|
532
|
+
track.driftY = null;
|
|
533
|
+
track.widthDrift = null;
|
|
534
|
+
track.heightDrift = null;
|
|
535
|
+
track.wholePixel = null;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* How many reference components the candidate accounts for.
|
|
540
|
+
*
|
|
541
|
+
* Overlap rather than the drift match, and deliberately so: a slot whose drift
|
|
542
|
+
* could not be measured has still *drawn* over that blob, and counting it as
|
|
543
|
+
* unaccounted would report "something in the shot you have not drawn" about a part
|
|
544
|
+
* that is right there. What this number is for is the opposite case — a component
|
|
545
|
+
* no slot reaches at all.
|
|
546
|
+
*/
|
|
547
|
+
function countExplained(footprints: Map<string, Footprint>, components: Component[]): number {
|
|
548
|
+
let explained = 0;
|
|
549
|
+
for (const component of components) {
|
|
550
|
+
for (const foot of footprints.values()) {
|
|
551
|
+
if (foot.pixels === 0) continue;
|
|
552
|
+
if (foot.minX >= component.maxX || component.minX >= foot.maxX) continue;
|
|
553
|
+
if (foot.minY >= component.maxY || component.minY >= foot.maxY) continue;
|
|
554
|
+
explained++;
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return explained;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// the template pass
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
interface Template {
|
|
566
|
+
/** Patch origin in frame pixels. */
|
|
567
|
+
ox: number;
|
|
568
|
+
oy: number;
|
|
569
|
+
width: number;
|
|
570
|
+
height: number;
|
|
571
|
+
/** The slot alone, composited over the background. */
|
|
572
|
+
patch: Plate;
|
|
573
|
+
/** Offsets into the patch that carry the slot's own VISIBLE pixels. */
|
|
574
|
+
samples: Int32Array;
|
|
575
|
+
/** Mean distance from the background over those samples: how visible it is. */
|
|
576
|
+
contrast: number;
|
|
577
|
+
/** How much of the slot's own ink reaches the picture, and how much does not. */
|
|
578
|
+
ink: number;
|
|
579
|
+
covered: number;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* A template to correlate, or why this slot has none.
|
|
584
|
+
*
|
|
585
|
+
* `covered` is a *reported* outcome and not a quiet miss: a slot every pixel of
|
|
586
|
+
* which the candidate draws over has no position to measure, and saying so is a
|
|
587
|
+
* different fact from a correlation that searched and found nothing.
|
|
588
|
+
*/
|
|
589
|
+
type TemplateOutcome =
|
|
590
|
+
| { kind: 'template'; template: Template }
|
|
591
|
+
| { kind: 'undrawn' }
|
|
592
|
+
| { kind: 'covered'; ink: number; width: number; height: number };
|
|
593
|
+
|
|
594
|
+
/**
|
|
595
|
+
* Which slot you would SEE at each pixel of the CANDIDATE's own frame.
|
|
596
|
+
*
|
|
597
|
+
* The composite's own rule, last writer wins, over the candidate's pieces in draw
|
|
598
|
+
* order — `frameGeometry`'s owner mask, which already computes exactly this for
|
|
599
|
+
* the chain split. It is entirely candidate-side: what it answers is *which of my
|
|
600
|
+
* template's pixels do I myself cover*, which is a fact about the thing being
|
|
601
|
+
* looked for and never a reading of the reference.
|
|
602
|
+
*
|
|
603
|
+
* ⚠️ So it is one-sided by construction, and that is the honest half to have. A
|
|
604
|
+
* pixel the candidate leaves visible may be covered in the reference, and nothing
|
|
605
|
+
* here can know it; what it removes is the half that is knowable, which is the
|
|
606
|
+
* half that was moving the answer.
|
|
607
|
+
*/
|
|
608
|
+
interface VisibleField {
|
|
609
|
+
owner: Int32Array;
|
|
610
|
+
idOf: Map<string, number>;
|
|
611
|
+
width: number;
|
|
612
|
+
height: number;
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
function visibleField(source: SlotSource): VisibleField {
|
|
616
|
+
const idOf = new Map<string, number>();
|
|
617
|
+
for (const piece of source.frame.pieces) if (!idOf.has(piece.slot)) idOf.set(piece.slot, idOf.size);
|
|
618
|
+
const { owner } = frameGeometry(source.frame, source.pages, source.viewport, idOf);
|
|
619
|
+
return {
|
|
620
|
+
owner: owner ?? new Int32Array(source.viewport.width * source.viewport.height).fill(-1),
|
|
621
|
+
idOf,
|
|
622
|
+
width: source.viewport.width,
|
|
623
|
+
height: source.viewport.height,
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* The slot on its own, over the background, at the size it drew — sampled only
|
|
629
|
+
* where the candidate's own composite lets it show.
|
|
630
|
+
*
|
|
631
|
+
* Its own pieces and nothing else — the point of the fallback is that the
|
|
632
|
+
* reference merged this part with its neighbours, so the thing being looked for
|
|
633
|
+
* has to be the part rather than the blob.
|
|
634
|
+
*
|
|
635
|
+
* 🚨 And only the pixels of it that reach the picture. A template pixel the
|
|
636
|
+
* candidate draws something over cannot match the reference at the true offset,
|
|
637
|
+
* so it contributes a residual at *every* offset and the correlation is reading
|
|
638
|
+
* the occluder rather than the part — issue #698's whole finding. ⚠️ It is not a
|
|
639
|
+
* flat penalty that cancels: sliding the template moves those samples onto other
|
|
640
|
+
* pixels, and where the visible basin is shallow their gradient is what decides
|
|
641
|
+
* the winner. `contrast` is taken over the same retained samples, so the residual
|
|
642
|
+
* test below compares two figures measured on one population.
|
|
643
|
+
*/
|
|
644
|
+
function templateFor(slot: string, foot: Footprint, source: SlotSource, visible: VisibleField): TemplateOutcome {
|
|
645
|
+
const ox = Math.floor(foot.minX);
|
|
646
|
+
const oy = Math.floor(foot.minY);
|
|
647
|
+
const width = Math.ceil(foot.maxX) - ox;
|
|
648
|
+
const height = Math.ceil(foot.maxY) - oy;
|
|
649
|
+
if (width <= 0 || height <= 0) return { kind: 'undrawn' };
|
|
650
|
+
const patch = new Plate(width, height);
|
|
651
|
+
const bg = source.background;
|
|
652
|
+
for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) patch.set(x, y, bg);
|
|
653
|
+
const project = projector(source.viewport);
|
|
654
|
+
const shifted = (wx: number, wy: number): [number, number] => {
|
|
655
|
+
const [px, py] = project(wx, wy);
|
|
656
|
+
return [px - ox, py - oy];
|
|
657
|
+
};
|
|
658
|
+
let drew = false;
|
|
659
|
+
for (const piece of source.frame.pieces) {
|
|
660
|
+
if (piece.slot !== slot) continue;
|
|
661
|
+
rasterisePiece(pageFor(source.pages, piece), piece, shifted, { width, height }, (px, py, r, g, b, a) => {
|
|
662
|
+
patch.blend(px, py, [r, g, b, a]);
|
|
663
|
+
drew = true;
|
|
664
|
+
});
|
|
665
|
+
}
|
|
666
|
+
if (!drew) return { kind: 'undrawn' };
|
|
667
|
+
|
|
668
|
+
const own = visible.idOf.get(slot) ?? -1;
|
|
669
|
+
const hits: number[] = [];
|
|
670
|
+
let ink = 0;
|
|
671
|
+
let sum = 0;
|
|
672
|
+
for (let y = 0; y < height; y++) {
|
|
673
|
+
for (let x = 0; x < width; x++) {
|
|
674
|
+
const d = backgroundDistance(patch, x, y, bg);
|
|
675
|
+
if (d <= 0) continue;
|
|
676
|
+
ink++;
|
|
677
|
+
const fx = ox + x;
|
|
678
|
+
const fy = oy + y;
|
|
679
|
+
// Off the frame counts as covered: a pixel outside the reference compares
|
|
680
|
+
// against the background at every offset, which is the same constant.
|
|
681
|
+
if (fx < 0 || fy < 0 || fx >= visible.width || fy >= visible.height) continue;
|
|
682
|
+
if (visible.owner[fy * visible.width + fx] !== own) continue;
|
|
683
|
+
hits.push(y * width + x);
|
|
684
|
+
sum += d;
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
if (ink === 0) return { kind: 'undrawn' };
|
|
688
|
+
if (hits.length === 0) return { kind: 'covered', ink, width, height };
|
|
689
|
+
const stride = Math.max(1, Math.ceil(hits.length / MAX_SAMPLES));
|
|
690
|
+
const samples: number[] = [];
|
|
691
|
+
for (let i = 0; i < hits.length; i += stride) samples.push(hits[i]);
|
|
692
|
+
return {
|
|
693
|
+
kind: 'template',
|
|
694
|
+
template: {
|
|
695
|
+
ox,
|
|
696
|
+
oy,
|
|
697
|
+
width,
|
|
698
|
+
height,
|
|
699
|
+
patch,
|
|
700
|
+
samples: Int32Array.from(samples),
|
|
701
|
+
contrast: sum / hits.length,
|
|
702
|
+
ink,
|
|
703
|
+
covered: ink - hits.length,
|
|
704
|
+
},
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** Mean absolute RGB difference between the template and the reference at an offset. */
|
|
709
|
+
function scoreAt(template: Template, source: SlotSource, dx: number, dy: number): number {
|
|
710
|
+
const { patch, samples, width, ox, oy } = template;
|
|
711
|
+
const reference = source.reference;
|
|
712
|
+
const bg = source.background;
|
|
713
|
+
let sum = 0;
|
|
714
|
+
for (let i = 0; i < samples.length; i++) {
|
|
715
|
+
const at = samples[i];
|
|
716
|
+
const x = at % width;
|
|
717
|
+
const y = (at - x) / width;
|
|
718
|
+
const a = patch.get(x, y);
|
|
719
|
+
const rx = ox + x + dx;
|
|
720
|
+
const ry = oy + y + dy;
|
|
721
|
+
const b =
|
|
722
|
+
rx < 0 || ry < 0 || rx >= reference.width || ry >= reference.height ? bg : reference.get(rx, ry);
|
|
723
|
+
sum += (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
|
|
724
|
+
}
|
|
725
|
+
return sum / samples.length;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
/**
|
|
729
|
+
* The offsets one axis of the coarse sweep visits, in ascending order.
|
|
730
|
+
*
|
|
731
|
+
* ⭐ Anchored on **zero** and symmetric about it, and both halves of that are
|
|
732
|
+
* correctness rather than taste. Zero is the offset that says *the part landed
|
|
733
|
+
* where the candidate drew it*, which is the answer on every frame of a correct
|
|
734
|
+
* rig — so a lattice that does not carry it cannot report a correct rig as
|
|
735
|
+
* correct. And the window this searches is `±radius`, so a lattice reaching one
|
|
736
|
+
* stride further one way than the other is searching a different window from the
|
|
737
|
+
* one the radius names.
|
|
738
|
+
*
|
|
739
|
+
* 🚨 The sweep used to run `for (dx = -radius; dx <= radius; dx += coarse)`,
|
|
740
|
+
* which puts the origin on the lattice only when `coarse` divides `radius`.
|
|
741
|
+
* Issue #678: `gallery/squash` checked against frames rendered from **itself**
|
|
742
|
+
* reported `slot drift worst 3.7 px "ear_r"`. The ear's radius is 26 and its
|
|
743
|
+
* stride 3, so the lattice ran `-26, -23, … -2, 1, 4 …` — the origin missing,
|
|
744
|
+
* and the nearest lattice point below it two whole pixels out. The coarse winner
|
|
745
|
+
* landed at `(1, -2)` scoring 37.33, the halving refinement walked it to
|
|
746
|
+
* `(2, -3)` at 37.28, and the exhaustive field over the same radius has its
|
|
747
|
+
* minimum at `(0, 0)` scoring **35.88**. The refinement cannot recover it: it
|
|
748
|
+
* searches `±1` around the coarse winner, and `dy = -2` is two away from zero.
|
|
749
|
+
*/
|
|
750
|
+
function sweepOffsets(radius: number, coarse: number): number[] {
|
|
751
|
+
const out = [0];
|
|
752
|
+
for (let d = coarse; d <= radius; d += coarse) {
|
|
753
|
+
out.unshift(-d);
|
|
754
|
+
out.push(d);
|
|
755
|
+
}
|
|
756
|
+
return out;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Correlate one slot against the reference inside its own search radius.
|
|
761
|
+
*
|
|
762
|
+
* Multi-resolution: a full sweep at a coarse stride, then halving steps around the
|
|
763
|
+
* winner, then a parabolic refinement for the sub-pixel part. That keeps the cost
|
|
764
|
+
* near-constant in the radius — a big part gets a big window without paying its
|
|
765
|
+
* square — and the coarse sweep doubles as the rival field the confidence is read
|
|
766
|
+
* from.
|
|
767
|
+
*
|
|
768
|
+
* ⚠️ What is left over on an identity run is the **sub-pixel step**, and it
|
|
769
|
+
* cannot be zero: the parabola is fitted through three whole-pixel residuals that
|
|
770
|
+
* antialiasing alone makes slightly uneven, so its vertex sits a fraction off the
|
|
771
|
+
* winner. `parabolic` clamps that to `SUBPIXEL_CLAMP` on each axis, which bounds
|
|
772
|
+
* the identity floor **provided the whole-pixel winner is the identity offset** —
|
|
773
|
+
* and `templateFor` masking the occluded samples is what makes that second half
|
|
774
|
+
* true. `track.wholePixel` publishes the winner and `driftBound` turns it into the
|
|
775
|
+
* figure, so a reader never has to assume it. See `docs/AUTHORING.md` §9.2.
|
|
776
|
+
*/
|
|
777
|
+
function applyTemplateMatch(track: SlotTrack, foot: Footprint, source: SlotSource, visible: VisibleField): void {
|
|
778
|
+
if (!track.candidate || track.searchRadius === null) return;
|
|
779
|
+
const outcome = templateFor(track.slot, foot, source, visible);
|
|
780
|
+
if (outcome.kind === 'undrawn') return;
|
|
781
|
+
if (outcome.kind === 'covered') {
|
|
782
|
+
track.method = 'none';
|
|
783
|
+
track.ambiguity =
|
|
784
|
+
`${track.ambiguity ?? 'no component of its own'}; its drift is not measurable — every one of the ` +
|
|
785
|
+
`${outcome.ink} px this ${outcome.width}x${outcome.height} px slot draws is covered by something the ` +
|
|
786
|
+
'candidate draws over it, so none of its own ink reaches the picture to be correlated against';
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
const template = outcome.template;
|
|
790
|
+
if (template.contrast <= 0) return;
|
|
791
|
+
const radius = track.searchRadius;
|
|
792
|
+
const cache = new Map<number, number>();
|
|
793
|
+
const score = (dx: number, dy: number): number => {
|
|
794
|
+
const key = (dy + MAX_SEARCH_RADIUS * 2) * 1024 + (dx + MAX_SEARCH_RADIUS * 2);
|
|
795
|
+
const seen = cache.get(key);
|
|
796
|
+
if (seen !== undefined) return seen;
|
|
797
|
+
const value = scoreAt(template, source, dx, dy);
|
|
798
|
+
cache.set(key, value);
|
|
799
|
+
return value;
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const coarse = Math.max(1, Math.round(radius / 8));
|
|
803
|
+
let bestX = 0;
|
|
804
|
+
let bestY = 0;
|
|
805
|
+
let best = Infinity;
|
|
806
|
+
const sweep: Array<{ dx: number; dy: number; s: number }> = [];
|
|
807
|
+
const axis = sweepOffsets(radius, coarse);
|
|
808
|
+
for (const dy of axis) {
|
|
809
|
+
for (const dx of axis) {
|
|
810
|
+
const s = score(dx, dy);
|
|
811
|
+
sweep.push({ dx, dy, s });
|
|
812
|
+
if (s < best) {
|
|
813
|
+
best = s;
|
|
814
|
+
bestX = dx;
|
|
815
|
+
bestY = dy;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
for (let step = coarse; step > 1; ) {
|
|
820
|
+
step = Math.max(1, Math.floor(step / 2));
|
|
821
|
+
for (let dy = bestY - step; dy <= bestY + step; dy += step) {
|
|
822
|
+
for (let dx = bestX - step; dx <= bestX + step; dx += step) {
|
|
823
|
+
if (Math.abs(dx) > radius || Math.abs(dy) > radius) continue;
|
|
824
|
+
const s = score(dx, dy);
|
|
825
|
+
if (s < best) {
|
|
826
|
+
best = s;
|
|
827
|
+
bestX = dx;
|
|
828
|
+
bestY = dy;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
// A winner nobody else came close to is a located part. A winner its neighbours
|
|
835
|
+
// match just as well is a featureless blob, and no offset is evidence.
|
|
836
|
+
const gap = Math.max(RIVAL_GAP, coarse);
|
|
837
|
+
let rival = Infinity;
|
|
838
|
+
for (const { dx, dy, s } of sweep) {
|
|
839
|
+
if (Math.hypot(dx - bestX, dy - bestY) < gap) continue;
|
|
840
|
+
if (s < rival) rival = s;
|
|
841
|
+
}
|
|
842
|
+
const confidence = Number.isFinite(rival) && rival > 0 ? Math.max(0, Math.min(1, 1 - best / rival)) : 0;
|
|
843
|
+
|
|
844
|
+
const reach = Math.min(1, Math.hypot(bestX, bestY) / radius);
|
|
845
|
+
const required = MIN_CONFIDENCE + reach * CONFIDENCE_SLOPE;
|
|
846
|
+
if (best > template.contrast * MAX_RESIDUAL_FRACTION || confidence < required) {
|
|
847
|
+
track.method = 'none';
|
|
848
|
+
track.confidence = Number.isFinite(confidence) ? confidence : 0;
|
|
849
|
+
track.ambiguity =
|
|
850
|
+
`${track.ambiguity ?? 'no component of its own'}; correlating the slot's own pixels found no match within ` +
|
|
851
|
+
`${radius} px either (best residual ${best.toFixed(1)} against its own ${template.contrast.toFixed(1)} of ` +
|
|
852
|
+
`contrast over the ${template.ink - template.covered} of its ${template.ink} px that reach the picture; ` +
|
|
853
|
+
`confidence ${(Number.isFinite(confidence) ? confidence : 0).toFixed(2)} where ` +
|
|
854
|
+
`${required.toFixed(2)} is needed ${Math.hypot(bestX, bestY).toFixed(1)} px out)`;
|
|
855
|
+
return;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
const dx = bestX + parabolic(score(bestX - 1, bestY), best, score(bestX + 1, bestY));
|
|
859
|
+
const dy = bestY + parabolic(score(bestX, bestY - 1), best, score(bestX, bestY + 1));
|
|
860
|
+
track.method = 'template';
|
|
861
|
+
track.reference = null;
|
|
862
|
+
track.driftX = dx;
|
|
863
|
+
track.driftY = dy;
|
|
864
|
+
track.drift = Math.hypot(dx, dy);
|
|
865
|
+
track.widthDrift = null;
|
|
866
|
+
track.heightDrift = null;
|
|
867
|
+
track.confidence = confidence;
|
|
868
|
+
track.wholePixel = { dx: bestX, dy: bestY };
|
|
869
|
+
track.ambiguity = null;
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* How far off the whole-pixel winner the sub-pixel step may place the answer.
|
|
874
|
+
*
|
|
875
|
+
* Half a pixel on each axis, because past that the neighbouring whole pixel is
|
|
876
|
+
* the better winner and the search would have found it. It is what bounds the
|
|
877
|
+
* instrument's identity floor at `hypot(0.5, 0.5)` px, which `docs/AUTHORING.md`
|
|
878
|
+
* §9.2 states as the floor an author reads a drift against.
|
|
879
|
+
*/
|
|
880
|
+
export const SUBPIXEL_CLAMP = 0.5;
|
|
881
|
+
|
|
882
|
+
/** Sub-pixel minimum of the parabola through three samples one pixel apart. */
|
|
883
|
+
function parabolic(before: number, at: number, after: number): number {
|
|
884
|
+
const denominator = before - 2 * at + after;
|
|
885
|
+
if (!Number.isFinite(denominator) || Math.abs(denominator) < 1e-9) return 0;
|
|
886
|
+
return Math.max(-SUBPIXEL_CLAMP, Math.min(SUBPIXEL_CLAMP, (before - after) / (2 * denominator)));
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/** Is this track's drift a measurement of this slot? */
|
|
890
|
+
export function isAttributable(track: SlotTrack): boolean {
|
|
891
|
+
return track.drift !== null && track.ambiguity === null;
|
|
892
|
+
}
|