spine-rigc 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/slots.ts ADDED
@@ -0,0 +1,603 @@
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 two failure modes that two
9
+ * honest ladder runs hit head-on (issue #34):
10
+ *
11
+ * - **Parts that touch label as one component.** Rung 4 is a disc with five chain
12
+ * links hanging off it; they touch in every frame of every animation, so every
13
+ * slot came back "ambiguous" and the run produced **no drift table at all**.
14
+ * - **Nearest-centroid has no notion of "too far to be the same thing".** Rung 5's
15
+ * 4 px ball, on the frames where it rests against the course and has no component
16
+ * of its own, matched the floating girder 47 px away — and the summary line
17
+ * reported **48.3 px of drift for a 4 px ball**, unflagged.
18
+ *
19
+ * So: components first, and when a component cannot be attributed to one slot, the
20
+ * slot's own rendered quad is **template-matched** against the reference in a
21
+ * window around where the candidate drew it. Touching parts stop being fatal —
22
+ * a link that overlaps its neighbour still correlates against its own pixels.
23
+ *
24
+ * ⛔ And both matchers are capped by `searchRadius`: a part may be displaced by
25
+ * about its own size and still be the same part in the picture, and past that the
26
+ * honest report is **no match**. A number that is not a measurement of the slot it
27
+ * is printed beside is worse than a blank, because it is actionable and wrong.
28
+ */
29
+ import { Plate, type RGBA } from '../tools/plate.ts';
30
+ import { backgroundDistance, isContent } from './framing.ts';
31
+ import { pageFor, projector, rasterisePiece, type Frame, type Footprint, type Viewport } from './render.ts';
32
+
33
+ /** Components smaller than this are antialiasing crumbs, not parts. */
34
+ const MIN_COMPONENT_PIXELS = 4;
35
+ /** A second component this close to the nearest makes a component match a guess. */
36
+ const AMBIGUITY_RATIO = 1.25;
37
+ /**
38
+ * How much bigger than the slot a component may be and still be *that slot's*.
39
+ *
40
+ * Above it, the slot is inside something larger — the reference merged it with a
41
+ * neighbour, or drew it behind one — and the component's centroid is the merged
42
+ * blob's, not the part's.
43
+ *
44
+ * ⚠️ Pixel count alone is not enough, and rung 3's transcription is the proof: at
45
+ * `heavy/f0009` the pendulum touches the block, the two label as one 1227 px blob,
46
+ * and the pendulum's own 836 px makes that only 1.47x — under any ratio loose
47
+ * enough to tolerate antialiasing. The blob's **bounding box** gives it away (60 px
48
+ * wide against the slot's 39), so both tests have to pass. Erring towards "merged"
49
+ * is the safe direction: a slot wrongly called merged still gets a drift from the
50
+ * template matcher, where a merged blob wrongly called the slot's own reports the
51
+ * blob's centroid as the part's position.
52
+ */
53
+ const MERGE_RATIO = 1.6;
54
+ /** How much wider or taller than the slot a component may be, as a fraction. */
55
+ const MERGE_MARGIN = 0.1;
56
+ /** ...and never less than this, so antialiasing alone cannot trip it. */
57
+ const MERGE_MARGIN_PIXELS = 2;
58
+ /** Displacement bounds, in frame pixels: never search less, never search more. */
59
+ const MIN_SEARCH_RADIUS = 4;
60
+ const MAX_SEARCH_RADIUS = 32;
61
+ /** Search radius as a fraction of the slot's own long side. */
62
+ const SEARCH_SPAN = 0.75;
63
+ /** How many template pixels a correlation samples, at most. */
64
+ const MAX_SAMPLES = 256;
65
+ /** A rival peak must be at least this far from the winner to count as a rival. */
66
+ const RIVAL_GAP = 3;
67
+ /**
68
+ * How distinctive a correlation peak must be before its offset is reported.
69
+ *
70
+ * ⭐ It rises with the displacement being claimed, and that is the whole point. A
71
+ * peak sitting where the candidate already drew the slot is only confirming a
72
+ * position, so a weak peak is enough; a peak claiming the part is most of a search
73
+ * radius away is claiming something big, and the bar for it is correspondingly
74
+ * high. Rung 4 is the case that fixed the constant: `chain1` — a 9x27 sliver in a
75
+ * chain of near-identical links — correlated 26 px away at confidence 0.16, and
76
+ * that number went straight into the summary line as the run's worst drift. Under
77
+ * this rule it needs 0.60 and comes back as **no match**, which is the honest
78
+ * answer for a repetitive structure.
79
+ */
80
+ const MIN_CONFIDENCE = 0.15;
81
+ /** How much more distinctive a peak must be at a full radius out than at zero. */
82
+ const CONFIDENCE_SLOPE = 0.45;
83
+ /** The residual must be under this fraction of the slot's own contrast. */
84
+ const MAX_RESIDUAL_FRACTION = 0.5;
85
+
86
+ export interface Component {
87
+ pixels: number;
88
+ cx: number;
89
+ cy: number;
90
+ minX: number;
91
+ minY: number;
92
+ maxX: number;
93
+ maxY: number;
94
+ }
95
+
96
+ /**
97
+ * Connected components of "not the background colour", 8-connected.
98
+ *
99
+ * 8-connected rather than 4: a thin diagonal — a bar, a stick, a shadow's edge —
100
+ * breaks into a dotted line under 4-connectivity, and then one part reads as
101
+ * twenty and every match is ambiguous for a reason that is about the labeller.
102
+ */
103
+ export function componentsOf(plate: Plate, background: RGBA): Component[] {
104
+ const { width, height } = plate;
105
+ const label = new Int32Array(width * height).fill(-1);
106
+ const out: Component[] = [];
107
+ const stack: number[] = [];
108
+ for (let y0 = 0; y0 < height; y0++) {
109
+ for (let x0 = 0; x0 < width; x0++) {
110
+ const seed = y0 * width + x0;
111
+ if (label[seed] !== -1 || !isContent(plate, x0, y0, background)) continue;
112
+ const id = out.length;
113
+ label[seed] = id;
114
+ stack.push(seed);
115
+ let pixels = 0;
116
+ let sx = 0;
117
+ let sy = 0;
118
+ let minX = width;
119
+ let minY = height;
120
+ let maxX = -1;
121
+ let maxY = -1;
122
+ while (stack.length > 0) {
123
+ const at = stack.pop() as number;
124
+ const x = at % width;
125
+ const y = (at - x) / width;
126
+ pixels++;
127
+ sx += x + 0.5;
128
+ sy += y + 0.5;
129
+ if (x < minX) minX = x;
130
+ if (x > maxX) maxX = x;
131
+ if (y < minY) minY = y;
132
+ if (y > maxY) maxY = y;
133
+ for (let dy = -1; dy <= 1; dy++) {
134
+ for (let dx = -1; dx <= 1; dx++) {
135
+ const nx = x + dx;
136
+ const ny = y + dy;
137
+ if (nx < 0 || ny < 0 || nx >= width || ny >= height) continue;
138
+ const n = ny * width + nx;
139
+ if (label[n] !== -1 || !isContent(plate, nx, ny, background)) continue;
140
+ label[n] = id;
141
+ stack.push(n);
142
+ }
143
+ }
144
+ }
145
+ out.push({ pixels, cx: sx / pixels, cy: sy / pixels, minX, minY, maxX: maxX + 1, maxY: maxY + 1 });
146
+ }
147
+ }
148
+ return out.filter((c) => c.pixels >= MIN_COMPONENT_PIXELS).sort((a, b) => b.pixels - a.pixels);
149
+ }
150
+
151
+ /** How the drift beside a slot was arrived at. `none` means it could not be. */
152
+ export type MatchMethod = 'component' | 'template' | 'none';
153
+
154
+ export interface SlotTrack {
155
+ slot: string;
156
+ candidate: { cx: number; cy: number; width: number; height: number; pixels: number } | null;
157
+ method: MatchMethod;
158
+ /** The reference component this slot was matched to — component matches only. */
159
+ reference: { cx: number; cy: number; width: number; height: number; pixels: number } | null;
160
+ /** Centroid distance in frame pixels, or the correlation offset's length. */
161
+ drift: number | null;
162
+ /** The same displacement with its direction, reference minus candidate. */
163
+ driftX: number | null;
164
+ driftY: number | null;
165
+ /** Bounding-box differences — component matches only, where a bbox is known. */
166
+ widthDrift: number | null;
167
+ heightDrift: number | null;
168
+ /** 0..1 for a template match: how much better the winner is than its best rival. */
169
+ confidence: number | null;
170
+ /** How far the match was allowed to look, in frame pixels. */
171
+ searchRadius: number | null;
172
+ /** Set when the drift is not a measurement of this slot, saying why. */
173
+ ambiguity: string | null;
174
+ }
175
+
176
+ /**
177
+ * How far this slot may be displaced and still be the same thing in the picture.
178
+ *
179
+ * Tied to the slot's own size, because that is what makes the bound mean
180
+ * something: past about its own long side a part no longer overlaps where it was,
181
+ * and a correlation peak out there is another object, not this one moved.
182
+ */
183
+ export function searchRadius(width: number, height: number): number {
184
+ const span = Math.round(Math.max(width, height) * SEARCH_SPAN);
185
+ return Math.max(MIN_SEARCH_RADIUS, Math.min(MAX_SEARCH_RADIUS, span));
186
+ }
187
+
188
+ /** What the template matcher needs to draw one slot on its own. */
189
+ export interface SlotSource {
190
+ frame: Frame;
191
+ pages: Map<string, Plate>;
192
+ viewport: Viewport;
193
+ background: RGBA;
194
+ reference: Plate;
195
+ }
196
+
197
+ // ---------------------------------------------------------------------------
198
+ // the component pass
199
+ // ---------------------------------------------------------------------------
200
+
201
+ interface Pending {
202
+ track: SlotTrack;
203
+ foot: Footprint;
204
+ claimed: Component | null;
205
+ }
206
+
207
+ /**
208
+ * Match each drawn slot to a reference component, then template-match the rest.
209
+ *
210
+ * Returns the tracks in slot-name order, plus how many reference components the
211
+ * candidate accounted for — a component nothing overlaps is something in the shot
212
+ * the candidate has not drawn.
213
+ */
214
+ export function matchSlots(
215
+ footprints: Map<string, Footprint>,
216
+ components: Component[],
217
+ source: SlotSource | null,
218
+ ): { tracks: SlotTrack[]; matchedComponents: number } {
219
+ const pending: Pending[] = [];
220
+ const takenBy = new Map<Component, string[]>();
221
+
222
+ for (const [slot, foot] of [...footprints].sort((a, b) => a[0].localeCompare(b[0]))) {
223
+ const track = blankTrack(slot);
224
+ if (foot.pixels === 0) {
225
+ track.ambiguity = 'the candidate draws nothing here — the slot is empty or entirely outside the frame';
226
+ pending.push({ track, foot, claimed: null });
227
+ continue;
228
+ }
229
+ track.candidate = {
230
+ cx: foot.cx,
231
+ cy: foot.cy,
232
+ width: foot.maxX - foot.minX,
233
+ height: foot.maxY - foot.minY,
234
+ pixels: Math.round(foot.pixels),
235
+ };
236
+ const radius = searchRadius(track.candidate.width, track.candidate.height);
237
+ track.searchRadius = radius;
238
+ if (components.length === 0) {
239
+ track.ambiguity = 'the reference frame is empty — nothing to match against';
240
+ pending.push({ track, foot, claimed: null });
241
+ continue;
242
+ }
243
+
244
+ // A component whose box holds this slot's centroid and is not much bigger than
245
+ // the slot is that slot's own blob. One much bigger than the slot is a merge:
246
+ // its centroid is the merged shape's and says nothing about this part.
247
+ const covering = components.filter(
248
+ (c) => foot.cx >= c.minX - 1 && foot.cx <= c.maxX + 1 && foot.cy >= c.minY - 1 && foot.cy <= c.maxY + 1,
249
+ );
250
+ const width = track.candidate.width;
251
+ const height = track.candidate.height;
252
+ const margin = Math.max(MERGE_MARGIN_PIXELS, MERGE_MARGIN * Math.max(width, height));
253
+ const own = covering
254
+ .filter(
255
+ (c) =>
256
+ c.pixels <= foot.pixels * MERGE_RATIO &&
257
+ c.maxX - c.minX <= width + margin &&
258
+ c.maxY - c.minY <= height + margin,
259
+ )
260
+ .sort((a, b) => Math.abs(a.pixels - foot.pixels) - Math.abs(b.pixels - foot.pixels))[0];
261
+
262
+ // Containment plus a compatible size is a far stronger claim than nearest
263
+ // centroid, so it is taken at face value and the runner-up test below — which
264
+ // exists to catch a *guess* — does not apply to it.
265
+ if (own) {
266
+ fillComponentMatch(track, foot, own);
267
+ claim(takenBy, own, slot);
268
+ pending.push({ track, foot, claimed: own });
269
+ continue;
270
+ }
271
+
272
+ if (covering.length > 0) {
273
+ const biggest = covering.sort((a, b) => b.pixels - a.pixels)[0];
274
+ track.ambiguity =
275
+ `this slot is inside a reference component ${(biggest.pixels / Math.max(1, foot.pixels)).toFixed(1)}x its ` +
276
+ `size and ${biggest.maxX - biggest.minX}x${biggest.maxY - biggest.minY} px against its ${width}x${height} — ` +
277
+ 'the reference merged it with something it touches or is drawn behind';
278
+ pending.push({ track, foot, claimed: null });
279
+ continue;
280
+ }
281
+
282
+ // Nothing contains it: the slot landed in open background. Nearest centroid is
283
+ // a guess, so it is bounded by what this slot could plausibly have moved, and
284
+ // a rival about as near makes it a guess between two things.
285
+ const ranked = components
286
+ .map((c) => ({ c, d: Math.hypot(c.cx - foot.cx, c.cy - foot.cy) }))
287
+ .sort((a, b) => a.d - b.d);
288
+ const nearest = ranked[0];
289
+ if (nearest.d > radius) {
290
+ track.ambiguity =
291
+ `the nearest reference component is ${nearest.d.toFixed(1)} px away, past the ${radius} px this ` +
292
+ `${Math.round(Math.max(track.candidate.width, track.candidate.height))} px slot could have moved and still ` +
293
+ 'be itself';
294
+ pending.push({ track, foot, claimed: null });
295
+ continue;
296
+ }
297
+ if (ranked[1] && ranked[1].d <= nearest.d * AMBIGUITY_RATIO) {
298
+ track.ambiguity =
299
+ `two reference components are about equally near (${nearest.d.toFixed(1)} px and ${ranked[1].d.toFixed(1)} px)`;
300
+ pending.push({ track, foot, claimed: null });
301
+ continue;
302
+ }
303
+ fillComponentMatch(track, foot, nearest.c);
304
+ claim(takenBy, nearest.c, slot);
305
+ pending.push({ track, foot, claimed: nearest.c });
306
+ }
307
+
308
+ // A component two slots both claim is one blob the reference merged. Neither
309
+ // claim is a measurement of its slot, so both drop to the template matcher.
310
+ for (const [component, claimants] of takenBy) {
311
+ if (claimants.length < 2) continue;
312
+ for (const entry of pending) {
313
+ if (!claimants.includes(entry.track.slot)) continue;
314
+ const others = claimants.filter((s) => s !== entry.track.slot);
315
+ entry.track.ambiguity =
316
+ `shares one reference component with ${others.map((s) => JSON.stringify(s)).join(', ')} — they touch or ` +
317
+ 'overlap in this frame';
318
+ entry.claimed = component;
319
+ clearMatch(entry.track);
320
+ }
321
+ }
322
+
323
+ // The fallback: anything the components could not attribute, correlated against
324
+ // its own rendered pixels.
325
+ if (source) {
326
+ for (const entry of pending) {
327
+ if (entry.track.ambiguity === null || entry.track.candidate === null || entry.foot.pixels === 0) continue;
328
+ applyTemplateMatch(entry.track, entry.foot, source);
329
+ }
330
+ }
331
+
332
+ const tracks = pending.map((p) => p.track);
333
+ return { tracks, matchedComponents: countExplained(footprints, components) };
334
+ }
335
+
336
+ function claim(takenBy: Map<Component, string[]>, component: Component, slot: string): void {
337
+ const claimants = takenBy.get(component) ?? [];
338
+ claimants.push(slot);
339
+ takenBy.set(component, claimants);
340
+ }
341
+
342
+ function blankTrack(slot: string): SlotTrack {
343
+ return {
344
+ slot,
345
+ candidate: null,
346
+ method: 'none',
347
+ reference: null,
348
+ drift: null,
349
+ driftX: null,
350
+ driftY: null,
351
+ widthDrift: null,
352
+ heightDrift: null,
353
+ confidence: null,
354
+ searchRadius: null,
355
+ ambiguity: null,
356
+ };
357
+ }
358
+
359
+ function fillComponentMatch(track: SlotTrack, foot: Footprint, component: Component): void {
360
+ track.method = 'component';
361
+ track.reference = {
362
+ cx: component.cx,
363
+ cy: component.cy,
364
+ width: component.maxX - component.minX,
365
+ height: component.maxY - component.minY,
366
+ pixels: component.pixels,
367
+ };
368
+ track.driftX = component.cx - foot.cx;
369
+ track.driftY = component.cy - foot.cy;
370
+ track.drift = Math.hypot(track.driftX, track.driftY);
371
+ track.widthDrift = foot.maxX - foot.minX - track.reference.width;
372
+ track.heightDrift = foot.maxY - foot.minY - track.reference.height;
373
+ }
374
+
375
+ function clearMatch(track: SlotTrack): void {
376
+ track.method = 'none';
377
+ track.reference = null;
378
+ track.drift = null;
379
+ track.driftX = null;
380
+ track.driftY = null;
381
+ track.widthDrift = null;
382
+ track.heightDrift = null;
383
+ }
384
+
385
+ /**
386
+ * How many reference components the candidate accounts for.
387
+ *
388
+ * Overlap rather than the drift match, and deliberately so: a slot whose drift
389
+ * could not be measured has still *drawn* over that blob, and counting it as
390
+ * unaccounted would report "something in the shot you have not drawn" about a part
391
+ * that is right there. What this number is for is the opposite case — a component
392
+ * no slot reaches at all.
393
+ */
394
+ function countExplained(footprints: Map<string, Footprint>, components: Component[]): number {
395
+ let explained = 0;
396
+ for (const component of components) {
397
+ for (const foot of footprints.values()) {
398
+ if (foot.pixels === 0) continue;
399
+ if (foot.minX >= component.maxX || component.minX >= foot.maxX) continue;
400
+ if (foot.minY >= component.maxY || component.minY >= foot.maxY) continue;
401
+ explained++;
402
+ break;
403
+ }
404
+ }
405
+ return explained;
406
+ }
407
+
408
+ // ---------------------------------------------------------------------------
409
+ // the template pass
410
+ // ---------------------------------------------------------------------------
411
+
412
+ interface Template {
413
+ /** Patch origin in frame pixels. */
414
+ ox: number;
415
+ oy: number;
416
+ width: number;
417
+ height: number;
418
+ /** The slot alone, composited over the background. */
419
+ patch: Plate;
420
+ /** Offsets into the patch that carry the slot's own pixels. */
421
+ samples: Int32Array;
422
+ /** Mean distance from the background over those samples: how visible it is. */
423
+ contrast: number;
424
+ }
425
+
426
+ /**
427
+ * The slot on its own, over the background, at the size it drew.
428
+ *
429
+ * Its own pieces and nothing else — the point of the fallback is that the
430
+ * reference merged this part with its neighbours, so the thing being looked for
431
+ * has to be the part rather than the blob.
432
+ */
433
+ function templateFor(slot: string, foot: Footprint, source: SlotSource): Template | null {
434
+ const ox = Math.floor(foot.minX);
435
+ const oy = Math.floor(foot.minY);
436
+ const width = Math.ceil(foot.maxX) - ox;
437
+ const height = Math.ceil(foot.maxY) - oy;
438
+ if (width <= 0 || height <= 0) return null;
439
+ const patch = new Plate(width, height);
440
+ const bg = source.background;
441
+ for (let y = 0; y < height; y++) for (let x = 0; x < width; x++) patch.set(x, y, bg);
442
+ const project = projector(source.viewport);
443
+ const shifted = (wx: number, wy: number): [number, number] => {
444
+ const [px, py] = project(wx, wy);
445
+ return [px - ox, py - oy];
446
+ };
447
+ let drew = false;
448
+ for (const piece of source.frame.pieces) {
449
+ if (piece.slot !== slot) continue;
450
+ rasterisePiece(pageFor(source.pages, piece), piece, shifted, { width, height }, (px, py, r, g, b, a) => {
451
+ patch.blend(px, py, [r, g, b, a]);
452
+ drew = true;
453
+ });
454
+ }
455
+ if (!drew) return null;
456
+
457
+ const hits: number[] = [];
458
+ let sum = 0;
459
+ for (let y = 0; y < height; y++) {
460
+ for (let x = 0; x < width; x++) {
461
+ const d = backgroundDistance(patch, x, y, bg);
462
+ if (d <= 0) continue;
463
+ hits.push(y * width + x);
464
+ sum += d;
465
+ }
466
+ }
467
+ if (hits.length === 0) return null;
468
+ const stride = Math.max(1, Math.ceil(hits.length / MAX_SAMPLES));
469
+ const samples: number[] = [];
470
+ for (let i = 0; i < hits.length; i += stride) samples.push(hits[i]);
471
+ return {
472
+ ox,
473
+ oy,
474
+ width,
475
+ height,
476
+ patch,
477
+ samples: Int32Array.from(samples),
478
+ contrast: sum / hits.length,
479
+ };
480
+ }
481
+
482
+ /** Mean absolute RGB difference between the template and the reference at an offset. */
483
+ function scoreAt(template: Template, source: SlotSource, dx: number, dy: number): number {
484
+ const { patch, samples, width, ox, oy } = template;
485
+ const reference = source.reference;
486
+ const bg = source.background;
487
+ let sum = 0;
488
+ for (let i = 0; i < samples.length; i++) {
489
+ const at = samples[i];
490
+ const x = at % width;
491
+ const y = (at - x) / width;
492
+ const a = patch.get(x, y);
493
+ const rx = ox + x + dx;
494
+ const ry = oy + y + dy;
495
+ const b =
496
+ rx < 0 || ry < 0 || rx >= reference.width || ry >= reference.height ? bg : reference.get(rx, ry);
497
+ sum += (Math.abs(a[0] - b[0]) + Math.abs(a[1] - b[1]) + Math.abs(a[2] - b[2])) / 3;
498
+ }
499
+ return sum / samples.length;
500
+ }
501
+
502
+ /**
503
+ * Correlate one slot against the reference inside its own search radius.
504
+ *
505
+ * Multi-resolution: a full sweep at a coarse stride, then halving steps around the
506
+ * winner, then a parabolic refinement for the sub-pixel part. That keeps the cost
507
+ * near-constant in the radius — a big part gets a big window without paying its
508
+ * square — and the coarse sweep doubles as the rival field the confidence is read
509
+ * from.
510
+ */
511
+ function applyTemplateMatch(track: SlotTrack, foot: Footprint, source: SlotSource): void {
512
+ if (!track.candidate || track.searchRadius === null) return;
513
+ const template = templateFor(track.slot, foot, source);
514
+ if (!template || template.contrast <= 0) return;
515
+ const radius = track.searchRadius;
516
+ const cache = new Map<number, number>();
517
+ const score = (dx: number, dy: number): number => {
518
+ const key = (dy + MAX_SEARCH_RADIUS * 2) * 1024 + (dx + MAX_SEARCH_RADIUS * 2);
519
+ const seen = cache.get(key);
520
+ if (seen !== undefined) return seen;
521
+ const value = scoreAt(template, source, dx, dy);
522
+ cache.set(key, value);
523
+ return value;
524
+ };
525
+
526
+ const coarse = Math.max(1, Math.round(radius / 8));
527
+ let bestX = 0;
528
+ let bestY = 0;
529
+ let best = Infinity;
530
+ const sweep: Array<{ dx: number; dy: number; s: number }> = [];
531
+ for (let dy = -radius; dy <= radius; dy += coarse) {
532
+ for (let dx = -radius; dx <= radius; dx += coarse) {
533
+ const s = score(dx, dy);
534
+ sweep.push({ dx, dy, s });
535
+ if (s < best) {
536
+ best = s;
537
+ bestX = dx;
538
+ bestY = dy;
539
+ }
540
+ }
541
+ }
542
+ for (let step = coarse; step > 1; ) {
543
+ step = Math.max(1, Math.floor(step / 2));
544
+ for (let dy = bestY - step; dy <= bestY + step; dy += step) {
545
+ for (let dx = bestX - step; dx <= bestX + step; dx += step) {
546
+ if (Math.abs(dx) > radius || Math.abs(dy) > radius) continue;
547
+ const s = score(dx, dy);
548
+ if (s < best) {
549
+ best = s;
550
+ bestX = dx;
551
+ bestY = dy;
552
+ }
553
+ }
554
+ }
555
+ }
556
+
557
+ // A winner nobody else came close to is a located part. A winner its neighbours
558
+ // match just as well is a featureless blob, and no offset is evidence.
559
+ const gap = Math.max(RIVAL_GAP, coarse);
560
+ let rival = Infinity;
561
+ for (const { dx, dy, s } of sweep) {
562
+ if (Math.hypot(dx - bestX, dy - bestY) < gap) continue;
563
+ if (s < rival) rival = s;
564
+ }
565
+ const confidence = Number.isFinite(rival) && rival > 0 ? Math.max(0, Math.min(1, 1 - best / rival)) : 0;
566
+
567
+ const reach = Math.min(1, Math.hypot(bestX, bestY) / radius);
568
+ const required = MIN_CONFIDENCE + reach * CONFIDENCE_SLOPE;
569
+ if (best > template.contrast * MAX_RESIDUAL_FRACTION || confidence < required) {
570
+ track.method = 'none';
571
+ track.confidence = Number.isFinite(confidence) ? confidence : 0;
572
+ track.ambiguity =
573
+ `${track.ambiguity ?? 'no component of its own'}; correlating the slot's own pixels found no match within ` +
574
+ `${radius} px either (best residual ${best.toFixed(1)} against its own ${template.contrast.toFixed(1)} of ` +
575
+ `contrast; confidence ${(Number.isFinite(confidence) ? confidence : 0).toFixed(2)} where ` +
576
+ `${required.toFixed(2)} is needed ${Math.hypot(bestX, bestY).toFixed(1)} px out)`;
577
+ return;
578
+ }
579
+
580
+ const dx = bestX + parabolic(score(bestX - 1, bestY), best, score(bestX + 1, bestY));
581
+ const dy = bestY + parabolic(score(bestX, bestY - 1), best, score(bestX, bestY + 1));
582
+ track.method = 'template';
583
+ track.reference = null;
584
+ track.driftX = dx;
585
+ track.driftY = dy;
586
+ track.drift = Math.hypot(dx, dy);
587
+ track.widthDrift = null;
588
+ track.heightDrift = null;
589
+ track.confidence = confidence;
590
+ track.ambiguity = null;
591
+ }
592
+
593
+ /** Sub-pixel minimum of the parabola through three samples one pixel apart. */
594
+ function parabolic(before: number, at: number, after: number): number {
595
+ const denominator = before - 2 * at + after;
596
+ if (!Number.isFinite(denominator) || Math.abs(denominator) < 1e-9) return 0;
597
+ return Math.max(-0.5, Math.min(0.5, (before - after) / (2 * denominator)));
598
+ }
599
+
600
+ /** Is this track's drift a measurement of this slot? */
601
+ export function isAttributable(track: SlotTrack): boolean {
602
+ return track.drift !== null && track.ambiguity === null;
603
+ }