spine-rigc 0.3.0 → 0.5.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/README.md +41 -3
- package/cli.ts +19 -2
- package/docs/AUTHORING.md +894 -47
- package/docs/SPEC_COVERAGE.md +5 -4
- package/package.json +1 -1
- package/src/check.ts +560 -24
- package/src/framing.ts +280 -0
- package/src/render.ts +21 -0
- package/src/slots.ts +102 -4
package/src/framing.ts
CHANGED
|
@@ -32,6 +32,19 @@
|
|
|
32
32
|
* measurement error in this file cancels. What is left is a procedure whose noise
|
|
33
33
|
* shrinks as the candidate improves, which is the only shape of noise an authoring
|
|
34
34
|
* loop can work against.
|
|
35
|
+
*
|
|
36
|
+
* ## And one pass after that, on the MAE itself
|
|
37
|
+
*
|
|
38
|
+
* The extent fit has a floor it cannot see past, because the best fit of two
|
|
39
|
+
* extents is not the best alignment of two pictures. On a hard shot that floor is
|
|
40
|
+
* a **constant** pixel worth a tenth of the headline figure (issue #146), which a
|
|
41
|
+
* loop reads as motion. So a fitted box gets one final pass — `OffsetScan` — that
|
|
42
|
+
* searches whole-pixel translations in a ±`REFINE_RADIUS` window for the lowest
|
|
43
|
+
* *reference-denominator MAE* and moves the box when the gain clears
|
|
44
|
+
* `REFINE_MIN_GAIN`. It optimises the reported figure directly rather than a proxy
|
|
45
|
+
* for it, which is what separates it from the extent refinement measured and
|
|
46
|
+
* rejected in `frameByDeclaredBox`: that one walked off the answer because the
|
|
47
|
+
* answer was not what it was minimising.
|
|
35
48
|
*/
|
|
36
49
|
import { Plate, type RGBA } from '../tools/plate.ts';
|
|
37
50
|
import {
|
|
@@ -515,6 +528,273 @@ function cornerSpread(box: ContentBox, displace: (x: number, y: number) => [numb
|
|
|
515
528
|
*/
|
|
516
529
|
export const CYCLE_PIXELS = SETTLED_PIXELS / 2;
|
|
517
530
|
|
|
531
|
+
// ---------------------------------------------------------------------------
|
|
532
|
+
// the MAE-refined final pass
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* How far the MAE-refined pass may move a fitted box, in frame pixels.
|
|
537
|
+
*
|
|
538
|
+
* Two, because what it is there to recover is *one* pixel. The extent fit lands
|
|
539
|
+
* within a fraction of a pixel of its own optimum and its optimum is the wrong
|
|
540
|
+
* one by about that much (`fitFraming`, "the floor, stated plainly"), so the
|
|
541
|
+
* distance between "where the extents agree" and "where the pictures agree" is a
|
|
542
|
+
* pixel or two and never more — measured across the committed corpus, every
|
|
543
|
+
* offset the pass applies is `(0, ±1)`, `(±1, 0)`, `(±1, ±1)`, `(−1, 2)` or
|
|
544
|
+
* `(−2, ≤1)`, and none of the 86 sets wants a corner of the window. A wider
|
|
545
|
+
* window would start being able to absorb a real displacement, which is the one
|
|
546
|
+
* thing the framing must not do.
|
|
547
|
+
*/
|
|
548
|
+
export const REFINE_RADIUS = 2;
|
|
549
|
+
|
|
550
|
+
/**
|
|
551
|
+
* How much of the figure a shift must buy before it is allowed to move the box,
|
|
552
|
+
* as a fraction of the set's own reference-denominator MAE...
|
|
553
|
+
*
|
|
554
|
+
* ...and `REFINE_MIN_GAIN_MAE` beside it, absolutely, because a ratio alone means
|
|
555
|
+
* nothing on a shot whose MAE is already 3.
|
|
556
|
+
*
|
|
557
|
+
* ## What the corpus says, and what the threshold is therefore for
|
|
558
|
+
*
|
|
559
|
+
* Measured over the 86 compared sets of the committed runs: the best offset in
|
|
560
|
+
* ±2 px is the **exact identity** on 52 of them — every set framed by
|
|
561
|
+
* `frames.json`'s own box among them, which is the `idle`-class control issue #146
|
|
562
|
+
* asked for — and on the other 34 the gain runs **0.9 % … 30.9 %** (0.40 … 14.34
|
|
563
|
+
* MAE), clustering at 3 % and above with two lone readings at 1.0 % and 0.9 %.
|
|
564
|
+
*
|
|
565
|
+
* ⚠️ So this is **not** a threshold separating two measured populations, and it
|
|
566
|
+
* must not be quoted as one: it is a floor under a continuum. What makes a low
|
|
567
|
+
* floor the right shape here is that the pass minimises the reported figure
|
|
568
|
+
* *itself*, so the cost of applying a marginal offset is bounded by the threshold
|
|
569
|
+
* — a hundredth of the figure — while the cost of refusing one is a constant pixel
|
|
570
|
+
* left inside a number an author reads as motion. Erring towards applying is the
|
|
571
|
+
* cheap direction, and the report prints what was applied and what it was worth
|
|
572
|
+
* either way.
|
|
573
|
+
*/
|
|
574
|
+
export const REFINE_MIN_GAIN = 0.01;
|
|
575
|
+
/** ...and how much of the figure that is, in MAE points, whatever the ratio says. */
|
|
576
|
+
export const REFINE_MIN_GAIN_MAE = 0.1;
|
|
577
|
+
|
|
578
|
+
/** What the best whole-pixel offset in a window is worth, over a set's frames. */
|
|
579
|
+
export interface OffsetGain {
|
|
580
|
+
dx: number;
|
|
581
|
+
dy: number;
|
|
582
|
+
/** Mean reference-denominator MAE at the identity — the figure as it stands. */
|
|
583
|
+
identity: number;
|
|
584
|
+
/** ...and at `dx, dy`, which is the same figure with one constant taken out. */
|
|
585
|
+
best: number;
|
|
586
|
+
/** How far the search looked, and how many frames it pooled. */
|
|
587
|
+
radius: number;
|
|
588
|
+
frames: number;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/**
|
|
592
|
+
* The set's reference-denominator MAE at every whole-pixel offset in a window,
|
|
593
|
+
* accumulated one frame at a time.
|
|
594
|
+
*
|
|
595
|
+
* ## What this is for: the constant pixel a settled fit still leaves
|
|
596
|
+
*
|
|
597
|
+
* `fitFraming` registers two shots by their **extent**, and a shot whose
|
|
598
|
+
* silhouette genuinely differs has its best extent fit about a third of a pixel
|
|
599
|
+
* from its best alignment. Measured on the spineboy candidates (issue #146), that
|
|
600
|
+
* floor is not a rounding detail: a **constant** translation of one or two pixels
|
|
601
|
+
* is worth 12 % of `death`'s headline MAE and up to 30 % of a fitted set's,
|
|
602
|
+
* while the genuinely per-frame remainder is a tenth of it. A loop reading those
|
|
603
|
+
* numbers as motion is reading a framing offset.
|
|
604
|
+
*
|
|
605
|
+
* ## Why the objective is the reference denominator
|
|
606
|
+
*
|
|
607
|
+
* Because it is the figure the report tells an author to optimise against
|
|
608
|
+
* (`FrameCheck.maeReference`), and because it is the one the candidate cannot
|
|
609
|
+
* grow: minimising the union MAE would let a shift that drags more cheap pixels
|
|
610
|
+
* into the denominator win, which is issue #119 arriving by another door.
|
|
611
|
+
*
|
|
612
|
+
* ## Why whole pixels, and why a plate shift rather than a re-render
|
|
613
|
+
*
|
|
614
|
+
* The projector is `px = (wx − minX)·k`, so moving the box by exactly `dx/k`
|
|
615
|
+
* moves every sample point by exactly one pixel and samples the same texels —
|
|
616
|
+
* the render at the shifted box **is** the render shifted, but for content
|
|
617
|
+
* outside the old frame. That makes a 25-offset search cost one render per frame
|
|
618
|
+
* instead of 25, and it is why the window is whole pixels: a sub-pixel offset
|
|
619
|
+
* changes the resampling, so nothing could be searched without re-rendering it.
|
|
620
|
+
* The offset that wins is then applied to the viewport and everything the report
|
|
621
|
+
* prints is measured on a real render at a real box.
|
|
622
|
+
*/
|
|
623
|
+
export class OffsetScan {
|
|
624
|
+
readonly radius: number;
|
|
625
|
+
private readonly span: number;
|
|
626
|
+
/** Σ over frames of the per-frame reference-denominator MAE, per offset. */
|
|
627
|
+
private readonly sums: Float64Array;
|
|
628
|
+
private counted = 0;
|
|
629
|
+
|
|
630
|
+
constructor(radius: number = REFINE_RADIUS) {
|
|
631
|
+
this.radius = Math.max(0, Math.round(radius));
|
|
632
|
+
this.span = this.radius * 2 + 1;
|
|
633
|
+
this.sums = new Float64Array(this.span * this.span);
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* One frame: the candidate as it was rendered, its own coverage mask, and the
|
|
638
|
+
* reference as it is on disk.
|
|
639
|
+
*
|
|
640
|
+
* ⭐ The figure accumulated here is `FrameCheck.maeReference` **exactly** —
|
|
641
|
+
* the difference summed over the pixels either side covers, over the count of
|
|
642
|
+
* the ones the *reference* covers — because the line the report prints and the
|
|
643
|
+
* line this pass minimises have to be the same line. Taking the numerator over
|
|
644
|
+
* the reference's pixels alone would be a near neighbour of it and a different
|
|
645
|
+
* number, and a "54.31 → 48.47" that did not match the MAE line under it would
|
|
646
|
+
* be two measurements wearing one name.
|
|
647
|
+
*
|
|
648
|
+
* A frame the reference drew nothing in counts as zero rather than being
|
|
649
|
+
* skipped, which is again what `checkOneFrame` does with it: an empty
|
|
650
|
+
* denominator is not a measurement, and dropping the frame instead would divide
|
|
651
|
+
* this pass's mean by a different frame count than the report's.
|
|
652
|
+
*/
|
|
653
|
+
add(candidate: Plate, coverage: Uint8Array, reference: Plate, background: RGBA): void {
|
|
654
|
+
const { width, height } = reference;
|
|
655
|
+
this.counted++;
|
|
656
|
+
// The reference's own drawn pixels, by the predicate `checkOneFrame` uses.
|
|
657
|
+
const drawn = new Uint8Array(width * height);
|
|
658
|
+
const drawnAt: number[] = [];
|
|
659
|
+
for (let y = 0; y < height; y++) {
|
|
660
|
+
for (let x = 0; x < width; x++) {
|
|
661
|
+
if (!isContent(reference, x, y, background)) continue;
|
|
662
|
+
const at = y * width + x;
|
|
663
|
+
drawn[at] = 1;
|
|
664
|
+
drawnAt.push(at);
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
if (drawnAt.length === 0) return;
|
|
668
|
+
// ...and the candidate's, which move with the offset. Held as a list because
|
|
669
|
+
// the second sum below walks them in candidate coordinates.
|
|
670
|
+
const inkAt: number[] = [];
|
|
671
|
+
for (let at = 0; at < coverage.length; at++) if (coverage[at] === 1) inkAt.push(at);
|
|
672
|
+
|
|
673
|
+
const a = candidate.data;
|
|
674
|
+
const b = reference.data;
|
|
675
|
+
const bg = background;
|
|
676
|
+
/** |candidate at `from` − reference at `to`|, mean over RGB, either off-grid. */
|
|
677
|
+
const delta = (from: number, to: number): number => {
|
|
678
|
+
const j = to * 4;
|
|
679
|
+
const i = from * 4;
|
|
680
|
+
const ar = from < 0 ? bg[0] : a[i];
|
|
681
|
+
const ag = from < 0 ? bg[1] : a[i + 1];
|
|
682
|
+
const ab = from < 0 ? bg[2] : a[i + 2];
|
|
683
|
+
const br = to < 0 ? bg[0] : b[j];
|
|
684
|
+
const bgc = to < 0 ? bg[1] : b[j + 1];
|
|
685
|
+
const bb = to < 0 ? bg[2] : b[j + 2];
|
|
686
|
+
return (Math.abs(ar - br) + Math.abs(ag - bgc) + Math.abs(ab - bb)) / 3;
|
|
687
|
+
};
|
|
688
|
+
for (let dy = -this.radius; dy <= this.radius; dy++) {
|
|
689
|
+
for (let dx = -this.radius; dx <= this.radius; dx++) {
|
|
690
|
+
let sum = 0;
|
|
691
|
+
// Every reference-drawn pixel, against whatever the shifted candidate puts
|
|
692
|
+
// there — background where the shift pulls it off its own grid, which is
|
|
693
|
+
// what the render at the shifted box would draw.
|
|
694
|
+
for (let i = 0; i < drawnAt.length; i++) {
|
|
695
|
+
const at = drawnAt[i];
|
|
696
|
+
const x = at % width;
|
|
697
|
+
const y = (at - x) / width;
|
|
698
|
+
const sx = x - dx;
|
|
699
|
+
const sy = y - dy;
|
|
700
|
+
sum += delta(sx < 0 || sy < 0 || sx >= width || sy >= height ? -1 : sy * width + sx, at);
|
|
701
|
+
}
|
|
702
|
+
// ...and every pixel the candidate covers that the reference does not,
|
|
703
|
+
// which is the other half of the union. A pixel the shift carries out of
|
|
704
|
+
// the frame is clipped there, so it leaves the sum entirely.
|
|
705
|
+
for (let i = 0; i < inkAt.length; i++) {
|
|
706
|
+
const at = inkAt[i];
|
|
707
|
+
const x = at % width;
|
|
708
|
+
const y = (at - x) / width;
|
|
709
|
+
const tx = x + dx;
|
|
710
|
+
const ty = y + dy;
|
|
711
|
+
if (tx < 0 || ty < 0 || tx >= width || ty >= height) continue;
|
|
712
|
+
const to = ty * width + tx;
|
|
713
|
+
if (drawn[to] === 1) continue;
|
|
714
|
+
sum += delta(at, to);
|
|
715
|
+
}
|
|
716
|
+
this.sums[this.index(dx, dy)] += sum / drawnAt.length;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* The offset with the lowest figure, or `null` when no frame carried reference
|
|
723
|
+
* ink.
|
|
724
|
+
*
|
|
725
|
+
* Ties go to the smaller displacement and then to the lower `dy`, `dx`, so the
|
|
726
|
+
* answer is a function of the pixels and not of the iteration order —
|
|
727
|
+
* `A18_DETERMINISTIC_EMIT`'s discipline applied to a measurement. The identity
|
|
728
|
+
* therefore wins any tie it is in, which is what makes "no constant offset
|
|
729
|
+
* here" a reachable answer rather than an arbitrary one.
|
|
730
|
+
*/
|
|
731
|
+
best(): OffsetGain | null {
|
|
732
|
+
if (this.counted === 0) return null;
|
|
733
|
+
let bestDx = 0;
|
|
734
|
+
let bestDy = 0;
|
|
735
|
+
let bestSum = this.sums[this.index(0, 0)];
|
|
736
|
+
for (let dy = -this.radius; dy <= this.radius; dy++) {
|
|
737
|
+
for (let dx = -this.radius; dx <= this.radius; dx++) {
|
|
738
|
+
const sum = this.sums[this.index(dx, dy)];
|
|
739
|
+
if (sum > bestSum) continue;
|
|
740
|
+
if (sum === bestSum && !closerToHome(dx, dy, bestDx, bestDy)) continue;
|
|
741
|
+
bestSum = sum;
|
|
742
|
+
bestDx = dx;
|
|
743
|
+
bestDy = dy;
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
return {
|
|
747
|
+
dx: bestDx,
|
|
748
|
+
dy: bestDy,
|
|
749
|
+
identity: this.sums[this.index(0, 0)] / this.counted,
|
|
750
|
+
best: bestSum / this.counted,
|
|
751
|
+
radius: this.radius,
|
|
752
|
+
frames: this.counted,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
private index(dx: number, dy: number): number {
|
|
757
|
+
return (dy + this.radius) * this.span + (dx + this.radius);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
/** Is `(dx, dy)` the smaller displacement, ties broken by `dy` then `dx`? */
|
|
762
|
+
function closerToHome(dx: number, dy: number, atX: number, atY: number): boolean {
|
|
763
|
+
const mine = dx * dx + dy * dy;
|
|
764
|
+
const theirs = atX * atX + atY * atY;
|
|
765
|
+
if (mine !== theirs) return mine < theirs;
|
|
766
|
+
if (dy !== atY) return dy < atY;
|
|
767
|
+
return dx < atX;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Is this offset worth moving a box for? See `REFINE_MIN_GAIN`. */
|
|
771
|
+
export function offsetIsWorthApplying(gain: OffsetGain): boolean {
|
|
772
|
+
if (gain.dx === 0 && gain.dy === 0) return false;
|
|
773
|
+
const won = gain.identity - gain.best;
|
|
774
|
+
return won >= REFINE_MIN_GAIN_MAE && gain.identity > 0 && won / gain.identity >= REFINE_MIN_GAIN;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* The same box moved by whole frame pixels, at the same scale.
|
|
779
|
+
*
|
|
780
|
+
* `applyFit` with a scale of exactly 1, written out rather than routed through
|
|
781
|
+
* it, because the refined pass changes no scale at all and a fit-shaped argument
|
|
782
|
+
* with `scale: 1` in it would invite one.
|
|
783
|
+
*/
|
|
784
|
+
export function shiftViewport(
|
|
785
|
+
viewport: Viewport,
|
|
786
|
+
dx: number,
|
|
787
|
+
dy: number,
|
|
788
|
+
pixelWidth: number,
|
|
789
|
+
pixelHeight: number,
|
|
790
|
+
): Viewport {
|
|
791
|
+
const minX = viewport.minX - dx / viewport.scale;
|
|
792
|
+
const maxY = viewport.maxY + dy / viewport.scale;
|
|
793
|
+
const width = pixelWidth / viewport.scale;
|
|
794
|
+
const height = pixelHeight / viewport.scale;
|
|
795
|
+
return viewportOfSize(minX, maxY - height, width, height, viewport.scale, pixelWidth, pixelHeight);
|
|
796
|
+
}
|
|
797
|
+
|
|
518
798
|
/**
|
|
519
799
|
* The viewport that renders the candidate where the fit says it belongs.
|
|
520
800
|
*
|
package/src/render.ts
CHANGED
|
@@ -124,6 +124,27 @@ export const FRAMING_FPS = 60;
|
|
|
124
124
|
export const FRAMES_SIDECAR = 'frames.json';
|
|
125
125
|
export const FRAMES_SPEC = 'rigc-frames/1';
|
|
126
126
|
|
|
127
|
+
/**
|
|
128
|
+
* The contact sheet beside a frame set, and the one number its layout needs.
|
|
129
|
+
*
|
|
130
|
+
* ⭐ A sheet is **part of the frame set**, not an illustration of it: a long shot
|
|
131
|
+
* commits a couple of stills and folds every sampled frame into one PNG, so for
|
|
132
|
+
* such a set the sheet is the only picture of the 309 frames in between, and
|
|
133
|
+
* `check` compares against its tiles (issue #36). That makes the layout a
|
|
134
|
+
* contract between two programs — `bench/render_reference.ts` writes the grid and
|
|
135
|
+
* `src/check.ts` reads it — so the column count lives here rather than in either.
|
|
136
|
+
*
|
|
137
|
+
* The tile SIZE is deliberately not here. It is a `--tile` choice per run, and a
|
|
138
|
+
* reader can measure it exactly off the sheet's own dimensions given the frame
|
|
139
|
+
* count and the column count (`check`'s `sheetGeometry` does), so recording it
|
|
140
|
+
* would be a second definition of something already written down in pixels.
|
|
141
|
+
*/
|
|
142
|
+
export const SHEET_COLUMNS = 8;
|
|
143
|
+
/** The sheet's file name inside a frame directory. */
|
|
144
|
+
export const SHEET_FILE = 'contact.png';
|
|
145
|
+
/** One pixel of rule between tiles, and one around the outside. */
|
|
146
|
+
export const SHEET_GAP = 1;
|
|
147
|
+
|
|
127
148
|
/** One rendered frame directory: which animation, at what rate, and what is on disk. */
|
|
128
149
|
export interface FrameSet {
|
|
129
150
|
/** Directory name under the skeleton root — `heavy`, or `heavy@24fps`. */
|
package/src/slots.ts
CHANGED
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
*
|
|
6
6
|
* The cheap matcher labels the reference frame's connected components and asks
|
|
7
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
|
|
9
|
-
*
|
|
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:
|
|
10
11
|
*
|
|
11
12
|
* - **Parts that touch label as one component.** Rung 4 is a disc with five chain
|
|
12
13
|
* links hanging off it; they touch in every frame of every animation, so every
|
|
@@ -15,6 +16,13 @@
|
|
|
15
16
|
* 4 px ball, on the frames where it rests against the course and has no component
|
|
16
17
|
* of its own, matched the floating girder 47 px away — and the summary line
|
|
17
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.
|
|
18
26
|
*
|
|
19
27
|
* So: components first, and when a component cannot be attributed to one slot, the
|
|
20
28
|
* slot's own rendered quad is **template-matched** against the reference in a
|
|
@@ -93,6 +101,21 @@ export interface Component {
|
|
|
93
101
|
maxY: number;
|
|
94
102
|
}
|
|
95
103
|
|
|
104
|
+
/**
|
|
105
|
+
* The reference frame's components, and which one each pixel belongs to.
|
|
106
|
+
*
|
|
107
|
+
* The label map is what makes "is anything ELSE inside this blob?" a measurement
|
|
108
|
+
* rather than a guess from bounding boxes — see `occupantsOf`. It is indexed
|
|
109
|
+
* row-major on the frame's own grid, and `-1` is background or a component too
|
|
110
|
+
* small to be a part.
|
|
111
|
+
*/
|
|
112
|
+
export interface ComponentField {
|
|
113
|
+
components: Component[];
|
|
114
|
+
labels: Int32Array;
|
|
115
|
+
width: number;
|
|
116
|
+
height: number;
|
|
117
|
+
}
|
|
118
|
+
|
|
96
119
|
/**
|
|
97
120
|
* Connected components of "not the background colour", 8-connected.
|
|
98
121
|
*
|
|
@@ -101,6 +124,11 @@ export interface Component {
|
|
|
101
124
|
* twenty and every match is ambiguous for a reason that is about the labeller.
|
|
102
125
|
*/
|
|
103
126
|
export function componentsOf(plate: Plate, background: RGBA): Component[] {
|
|
127
|
+
return componentField(plate, background).components;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** The same labelling, with the map kept — see `ComponentField`. */
|
|
131
|
+
export function componentField(plate: Plate, background: RGBA): ComponentField {
|
|
104
132
|
const { width, height } = plate;
|
|
105
133
|
const label = new Int32Array(width * height).fill(-1);
|
|
106
134
|
const out: Component[] = [];
|
|
@@ -145,7 +173,54 @@ export function componentsOf(plate: Plate, background: RGBA): Component[] {
|
|
|
145
173
|
out.push({ pixels, cx: sx / pixels, cy: sy / pixels, minX, minY, maxX: maxX + 1, maxY: maxY + 1 });
|
|
146
174
|
}
|
|
147
175
|
}
|
|
148
|
-
|
|
176
|
+
// Crumbs out, biggest first — and the label map carried through the reorder, so
|
|
177
|
+
// a label is always an index into the array the caller is handed. Renumbering
|
|
178
|
+
// rather than sorting the map is what keeps the two from drifting apart.
|
|
179
|
+
const keep = out.map((c, id) => ({ c, id })).filter(({ c }) => c.pixels >= MIN_COMPONENT_PIXELS);
|
|
180
|
+
keep.sort((a, b) => b.c.pixels - a.c.pixels);
|
|
181
|
+
const renumbered = new Int32Array(out.length).fill(-1);
|
|
182
|
+
keep.forEach(({ id }, index) => {
|
|
183
|
+
renumbered[id] = index;
|
|
184
|
+
});
|
|
185
|
+
for (let at = 0; at < label.length; at++) label[at] = label[at] < 0 ? -1 : renumbered[label[at]];
|
|
186
|
+
return { components: keep.map(({ c }) => c), labels: label, width, height };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Which drawn slots' ink sits inside each component, by centroid.
|
|
191
|
+
*
|
|
192
|
+
* ## Why this exists: a blob one part dominates is still a blob
|
|
193
|
+
*
|
|
194
|
+
* The size and bounding-box tests below catch a merge when the merged neighbour is
|
|
195
|
+
* a material fraction of the blob. They cannot catch the case issue #37 filed:
|
|
196
|
+
* rung 2's reference merges the course, the water, the panel and both rings into
|
|
197
|
+
* one component, and the **course is 81 % of it**, so the blob is only 1.24x the
|
|
198
|
+
* course's own ink and barely wider than the course's own box. It passed every
|
|
199
|
+
* test, and the summary line reported *"course drift 11.2 px"* — the distance from
|
|
200
|
+
* the course's centroid to the centroid of a blob holding four other parts, which
|
|
201
|
+
* is not a measurement of the course at all.
|
|
202
|
+
*
|
|
203
|
+
* One label lookup per drawn slot answers it exactly: if anything else the
|
|
204
|
+
* candidate drew lands on this component's own pixels, the component is more than
|
|
205
|
+
* one part and its centroid is nobody's position. That is the same judgement the
|
|
206
|
+
* two-claimants rule below already makes; what was missing is that a slot which
|
|
207
|
+
* never got as far as *claiming* the blob — because it was refused for being 13x
|
|
208
|
+
* too small, or diverted to the template matcher — still proves the blob is shared.
|
|
209
|
+
*/
|
|
210
|
+
function occupantsOf(field: ComponentField, footprints: Map<string, Footprint>): Map<number, string[]> {
|
|
211
|
+
const out = new Map<number, string[]>();
|
|
212
|
+
for (const [slot, foot] of footprints) {
|
|
213
|
+
if (foot.pixels === 0) continue;
|
|
214
|
+
const x = Math.floor(foot.cx);
|
|
215
|
+
const y = Math.floor(foot.cy);
|
|
216
|
+
if (x < 0 || y < 0 || x >= field.width || y >= field.height) continue;
|
|
217
|
+
const label = field.labels[y * field.width + x];
|
|
218
|
+
if (label < 0) continue;
|
|
219
|
+
const seen = out.get(label) ?? [];
|
|
220
|
+
seen.push(slot);
|
|
221
|
+
out.set(label, seen);
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
149
224
|
}
|
|
150
225
|
|
|
151
226
|
/** How the drift beside a slot was arrived at. `none` means it could not be. */
|
|
@@ -213,11 +288,16 @@ interface Pending {
|
|
|
213
288
|
*/
|
|
214
289
|
export function matchSlots(
|
|
215
290
|
footprints: Map<string, Footprint>,
|
|
216
|
-
|
|
291
|
+
field: ComponentField,
|
|
217
292
|
source: SlotSource | null,
|
|
218
293
|
): { tracks: SlotTrack[]; matchedComponents: number } {
|
|
294
|
+
const components = field.components;
|
|
219
295
|
const pending: Pending[] = [];
|
|
220
296
|
const takenBy = new Map<Component, string[]>();
|
|
297
|
+
const occupants = occupantsOf(field, footprints);
|
|
298
|
+
/** Which component each one is, so an occupancy list can be looked up by it. */
|
|
299
|
+
const idOf = new Map<Component, number>();
|
|
300
|
+
components.forEach((component, id) => idOf.set(component, id));
|
|
221
301
|
|
|
222
302
|
for (const [slot, foot] of [...footprints].sort((a, b) => a[0].localeCompare(b[0]))) {
|
|
223
303
|
const track = blankTrack(slot);
|
|
@@ -320,6 +400,24 @@ export function matchSlots(
|
|
|
320
400
|
}
|
|
321
401
|
}
|
|
322
402
|
|
|
403
|
+
// ...and a component ONE slot claimed while other ink of the candidate's sits
|
|
404
|
+
// inside it is the same blob by the other route — the one that reported a
|
|
405
|
+
// dominant part's distance to a five-part blob as that part's drift (#37). The
|
|
406
|
+
// claim goes to the template matcher for the same reason: a centroid shared by
|
|
407
|
+
// several parts is not this part's position.
|
|
408
|
+
for (const entry of pending) {
|
|
409
|
+
if (entry.claimed === null || entry.track.ambiguity !== null) continue;
|
|
410
|
+
const id = idOf.get(entry.claimed);
|
|
411
|
+
if (id === undefined) continue;
|
|
412
|
+
const others = (occupants.get(id) ?? []).filter((slot) => slot !== entry.track.slot);
|
|
413
|
+
if (others.length === 0) continue;
|
|
414
|
+
entry.track.ambiguity =
|
|
415
|
+
`this slot's reference component also holds ${others.map((s) => JSON.stringify(s)).join(', ')} — ` +
|
|
416
|
+
`${entry.claimed.pixels} px of blob against this slot's own ${Math.round(entry.foot.pixels)} px, so its ` +
|
|
417
|
+
"centroid is the merged shape's and not this part's";
|
|
418
|
+
clearMatch(entry.track);
|
|
419
|
+
}
|
|
420
|
+
|
|
323
421
|
// The fallback: anything the components could not attribute, correlated against
|
|
324
422
|
// its own rendered pixels.
|
|
325
423
|
if (source) {
|