@rig-c/core 2.82.1 → 2.83.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 +45 -43
- package/docs/AUTHORING.md +100 -55
- package/docs/INGEST.md +3 -3
- package/docs/MOTION.md +21 -11
- package/package.json +1 -1
- package/src/cli/shared.ts +4 -4
- package/src/pose.ts +209 -147
- package/src/seam.ts +154 -57
package/docs/MOTION.md
CHANGED
|
@@ -818,20 +818,22 @@ rigc pose
|
|
|
818
818
|
found on a 20x25 anchor grid, step 4 at 2x reduction
|
|
819
819
|
PLACE flag.png x= 104.7 y= 156.1 rot= 84.3° scale=0.955 residual=0.0135 unexplained= 0%
|
|
820
820
|
found on a 40x50 anchor grid, step 2 at 2x reduction
|
|
821
|
-
AMBIG post.png x=
|
|
821
|
+
AMBIG post.png x= 80.0 y= 148.6 rot= -0.1° scale=0.982 residual=0.0587 unexplained= 16%
|
|
822
822
|
found on a 14x17 anchor grid, step 6 at 2x reduction
|
|
823
|
-
alt 2: x=
|
|
824
|
-
|
|
823
|
+
alt 2: x= 78.7 y= 164.0 rot= -0.6° scale=0.573 residual=0.0612 unexplained= 19%
|
|
824
|
+
alt 3: x= 79.0 y= 141.7 rot= -0.5° scale=0.691 residual=0.0687 unexplained= 25%
|
|
825
|
+
alt 4: x= 78.4 y= 135.5 rot= -0.5° scale=0.500 residual=0.0702 unexplained= 23%
|
|
826
|
+
ambiguous: part is 14x96 px (span 94.3 frame px, opaque 1) with texture 0.0146, detail 1.406; 50 candidate(s), best 0.0587, next 0.0612, spread 0.0025 — above the measured floor (AUTHORING §11.5: detail 0.5, measured from 24 px up), so size and texture do not explain this; either the frame holds more than one place this part fits, or every candidate missed the true one — a narrower --scale or --rotation window around what you know of the part tells the two apart
|
|
825
827
|
```
|
|
826
828
|
|
|
827
|
-
⚠️ **The post came back `AMBIG`: a best at scale 0.
|
|
828
|
-
|
|
829
|
+
⚠️ **The post came back `AMBIG`: a best at scale 0.982 and its next alternate at 0.573,
|
|
830
|
+
15.4 px lower, trailing it by a spread of 0.0025.** That is §2.2's caveat in the open. The post
|
|
829
831
|
is a long part with most of its area in one colour, so a shrunken copy sitting inside the
|
|
830
832
|
real post explains those pixels nearly as well, and the report's own `ambiguous:` line
|
|
831
833
|
says size and texture do not account for how close the two come. The report cannot say
|
|
832
|
-
which one is true, and it says so: it names
|
|
834
|
+
which one is true, and it says so: it names the alternatives and asks for a narrower window. The
|
|
833
835
|
picture can. The arm and the flag read 0.970 and 0.955, so the picture is at the art's
|
|
834
|
-
own resolution, and a post at 0.
|
|
836
|
+
own resolution, and a post at 0.573 would be drawn at about 0.6 of the size of everything
|
|
835
837
|
bolted to it. ⇒ Narrow, and say so:
|
|
836
838
|
|
|
837
839
|
```bash
|
|
@@ -861,12 +863,19 @@ rigc pose
|
|
|
861
863
|
.. search scale 0.85–1.2 in 2 step(s) · rotation -180°–180° in 24 step(s) of 15° · refuse above residual 0.25
|
|
862
864
|
PLACE arm.png x= 102.5 y= 94.8 rot= -18.3° scale=0.974 residual=0.0305 unexplained= 4%
|
|
863
865
|
found on a 20x25 anchor grid, step 4 at 2x reduction
|
|
864
|
-
PLACE flag.png x= 137.4 y= 86.0 rot= -6.
|
|
866
|
+
PLACE flag.png x= 137.4 y= 86.0 rot= -6.4° scale=0.958 residual=0.0134 unexplained= 0%
|
|
865
867
|
found on a 40x50 anchor grid, step 2 at 2x reduction
|
|
866
868
|
PLACE post.png x= 80.0 y= 148.0 rot= 0.0° scale=0.995 residual=0.0394 unexplained= 9%
|
|
867
869
|
found on a 14x17 anchor grid, step 6 at 2x reduction
|
|
868
870
|
```
|
|
869
871
|
|
|
872
|
+
The worked derivation and recipe below keep their recorded rounded authoring inputs:
|
|
873
|
+
pose A as printed here, and pose B with the flag at screen rotation −6.6° and scale
|
|
874
|
+
0.956. The current rerun above reads −6.4° and 0.958 for that flag. The 0.2° rotation
|
|
875
|
+
and 0.002 scale differences are changes in the estimator's reading; the fixed inputs
|
|
876
|
+
used to derive this worked rig and its motion are stated here so that the recipe does
|
|
877
|
+
not silently change with a rerun.
|
|
878
|
+
|
|
870
879
|
Three things to read out of that pair, none of which is a score:
|
|
871
880
|
|
|
872
881
|
- **Scale.** All six readings sit in 0.956–0.995, a spread of about 4 %. The one part
|
|
@@ -887,7 +896,8 @@ Three things to read out of that pair, none of which is a score:
|
|
|
887
896
|
|
|
888
897
|
### 3. Convert, and derive the rig
|
|
889
898
|
|
|
890
|
-
`cropToSpineY(y, 200) = 200 − y` and
|
|
899
|
+
For those recorded authoring inputs, `cropToSpineY(y, 200) = 200 − y` and
|
|
900
|
+
`screenToSpineDegrees(d) = −d`, both from
|
|
891
901
|
[`src/transform.ts`](../src/transform.ts) (§2.2 — do not open-code either):
|
|
892
902
|
|
|
893
903
|
| | pose A, Spine world | pose B, Spine world |
|
|
@@ -1042,8 +1052,8 @@ which is which is worth being able to point at:
|
|
|
1042
1052
|
|
|
1043
1053
|
| Key | Where it came from |
|
|
1044
1054
|
| --- | --- |
|
|
1045
|
-
| arm `t: 0` = −61.9, flag `t: 0` = −22.2 | **given** — pose A, converted. Untouched, per §3.6 |
|
|
1046
|
-
| arm `t: 0.55` = 18.3, flag `t: 0.55` = −11.7 | **given** — pose B. The flag's is `6.6 − 18.3`: both world rotations converted first, then differenced, because a child's track carries a **local** rotation under a rotated parent |
|
|
1055
|
+
| arm `t: 0` = −61.9, flag `t: 0` = −22.2 | **given** — the recorded pose A reading, converted. Untouched, per §3.6 |
|
|
1056
|
+
| arm `t: 0.55` = 18.3, flag `t: 0.55` = −11.7 | **given** — the recorded pose B reading. The flag's is `6.6 − 18.3`: both world rotations converted first, then differenced, because a child's track carries a **local** rotation under a rotated parent |
|
|
1047
1057
|
| arm `t: 0.07` = −66.4 | §3.6 — 4.5° against an 80° excursion (5.6 %), at 13 % of the duration |
|
|
1048
1058
|
| arm `t: 0.32` = 24.7 | §3.8 — 6.4° past the end value (8 %), at 58 % of the duration |
|
|
1049
1059
|
| flag `t: 0.09`, `t: 0.47` | §3.7 — the flag's extreme lands at 85 % of the duration against the arm's 58 %, an offset of **+27 %**, the middle of its row's band, and it drags the other way first. Not the top of the band: at 35 % (`t: 0.51`) the crossing and the settle below would share 0.04 s, and the 24 fps render would still show the flag at its extreme one frame before the last |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rig-c/core",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.83.0",
|
|
4
4
|
"description": "AI-authored 2D rigging and animation, verified before it is written. rigc compiles a rig spec into skeleton data in the Spine 4.3 format, gates it with named assertions held to a spine-core round trip's verdicts, and emits nothing that fails. Ships as an agent skill.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/cli/shared.ts
CHANGED
|
@@ -1070,10 +1070,10 @@ const FLAG_MEANINGS: Record<string, string> = {
|
|
|
1070
1070
|
extremes: 'also write extremes.json and a native six-panel extremes.png per set: setup, geometry-pair min/max and premultiplied RGBA diffs, with a legend. Per-channel extrema are recorded separately and valid channels survive geometry/image SKIPs; channel refusals are named separately. Still geometry reports SKIP; a reading, not a gate. Attachment identity/count changes record a named SKIP without changing render exit; supports --slot/--hide on both posers',
|
|
1071
1071
|
seam:
|
|
1072
1072
|
`also read, for each <slot>:<slot> pair, where the two drawings stop meeting, and write ${SEAM_FILE} into each frame directory: ` +
|
|
1073
|
-
'per pair, the frames with a gap — background inside the
|
|
1074
|
-
'and the frames with a ghost — the band of one drawing showing where, from its partner\'s point of view, nothing was drawn at the setup pose — each with ' +
|
|
1073
|
+
'per pair, the frames with a gap — background inside the carried overlap hull and a local sweep of the same setup band cell through both drawings — ' +
|
|
1074
|
+
'and the frames with a visible ghost after draw order — the band of one drawing showing where, from its partner\'s point of view, nothing was drawn at the setup pose — each with ' +
|
|
1075
1075
|
`its cell count, area and world box. Cells are one texel of the coarser drawing, drawn at ${DRAWN_ALPHA} of 255 or more. One line per pair; a pair whose ` +
|
|
1076
|
-
'drawings share no cell at the setup pose is a SKIP. A reading, not a gate: the exit status never moves. An unknown slot, a slot with itself, ' +
|
|
1076
|
+
'drawings share no cell at the setup pose is a SKIP. exposureA/B count each named slot’s positive-alpha cells on otherwise empty skeleton background, even without a band or partner. A reading, not a gate: the exit status never moves. An unknown slot, a slot with itself, ' +
|
|
1077
1077
|
'a slot that draws on no frame, a malformed list and a pair named twice in either order are refused by name. Not with --slot/--hide',
|
|
1078
1078
|
'angle-range':
|
|
1079
1079
|
'also read, for each <bone>:<relative-to> pair, the range of the first bone\'s world direction against the second\'s over each set\'s ' +
|
|
@@ -1762,7 +1762,7 @@ export const COMMANDS: CommandDoc[] = [
|
|
|
1762
1762
|
'rigc render … --slot <name[,name…]> | --hide <name[,name…]> (a subset of the slots, on the whole rig\'s grid)',
|
|
1763
1763
|
'rigc render … --environment <schedule.json> (the wind and gravity the physics answers, in time)',
|
|
1764
1764
|
'rigc render … --pass-through <cloth>:<leg>[,…] (the frames where a cloth drawn over a leg passes through it)',
|
|
1765
|
-
'rigc render … --seam <slot>:<slot>[,…] (
|
|
1765
|
+
'rigc render … --seam <slot>:<slot>[,…] (joint gaps, visible ghosts and each named slot’s background exposure)',
|
|
1766
1766
|
'rigc render … --overlaps (which slot each frame drew over which, by name, with an outline plate)',
|
|
1767
1767
|
'rigc render … --angle-range <bone>:<relative-to>[,…] (how far a bone turns against another, per animation)',
|
|
1768
1768
|
'rigc render … --viewport <x,y,w,h> | --viewport-from <frames.json> (a box given, not fitted: two renders on one pixel grid)',
|
package/src/pose.ts
CHANGED
|
@@ -835,6 +835,8 @@ export interface PoseOptions {
|
|
|
835
835
|
* stride fraction in force in `search.coarse.strideFraction`.
|
|
836
836
|
*/
|
|
837
837
|
coarseGrid?: PoseCoarseGrid;
|
|
838
|
+
/** Historical-search control for instruments; the CLI uses short-support coverage. */
|
|
839
|
+
coarsePolicy?: 'legacy-only' | 'short-support';
|
|
838
840
|
}
|
|
839
841
|
|
|
840
842
|
/** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
|
|
@@ -900,6 +902,8 @@ export interface PoseTrace {
|
|
|
900
902
|
levels: PoseTraceLevel[];
|
|
901
903
|
/** Per part, every candidate that reached the report, with its every-pixel residual, best first. */
|
|
902
904
|
measured: { part: string; candidates: PoseTraceCandidate[] }[];
|
|
905
|
+
/** The independent coverage lane; `levels` retains the original lane's pyramid. */
|
|
906
|
+
supplemental?: { part: string; levels: PoseTraceLevel[] }[];
|
|
903
907
|
}
|
|
904
908
|
|
|
905
909
|
// ---------------------------------------------------------------------------
|
|
@@ -1388,8 +1392,15 @@ function coarseCellResidual(level: Level, s: Samples, scale: number, rotDeg: num
|
|
|
1388
1392
|
* exactly two such cells — and the accepted list then keeps them apart so the
|
|
1389
1393
|
* refinement budget is not spent twice on one hill.
|
|
1390
1394
|
*/
|
|
1391
|
-
function
|
|
1392
|
-
|
|
1395
|
+
export function poseCoarseMinima(
|
|
1396
|
+
field: CoarseField,
|
|
1397
|
+
radius: number,
|
|
1398
|
+
keep: number,
|
|
1399
|
+
support = Infinity,
|
|
1400
|
+
covered: readonly Candidate[] = [],
|
|
1401
|
+
observation?: { accepted: number; covered: number; spacingChecks?: number },
|
|
1402
|
+
): Candidate[] {
|
|
1403
|
+
const found: { candidate: Candidate; gx: number; gy: number }[] = [];
|
|
1393
1404
|
for (let gy = 0; gy < field.rows; gy++) {
|
|
1394
1405
|
for (let gx = 0; gx < field.cols; gx++) {
|
|
1395
1406
|
const here = field.residual[gy * field.cols + gx];
|
|
@@ -1400,7 +1411,7 @@ function localMinima(field: CoarseField, radius: number, keep: number): Candidat
|
|
|
1400
1411
|
const nx = gx + dx;
|
|
1401
1412
|
const ny = gy + dy;
|
|
1402
1413
|
if (nx < 0 || ny < 0 || nx >= field.cols || ny >= field.rows) continue;
|
|
1403
|
-
if (field.residual[ny * field.cols + nx] < here) {
|
|
1414
|
+
if (hypot2(dx * field.stride, dy * field.stride) <= support && field.residual[ny * field.cols + nx] < here) {
|
|
1404
1415
|
minimal = false;
|
|
1405
1416
|
break;
|
|
1406
1417
|
}
|
|
@@ -1408,23 +1419,45 @@ function localMinima(field: CoarseField, radius: number, keep: number): Candidat
|
|
|
1408
1419
|
}
|
|
1409
1420
|
if (!minimal) continue;
|
|
1410
1421
|
const cell = gy * field.cols + gx;
|
|
1411
|
-
found.push({
|
|
1422
|
+
found.push({ gx, gy, candidate: {
|
|
1412
1423
|
cx: gx * field.stride + field.stride / 2,
|
|
1413
1424
|
cy: gy * field.stride + field.stride / 2,
|
|
1414
1425
|
rotDeg: field.rotDeg[cell],
|
|
1415
1426
|
scale: field.scale,
|
|
1416
1427
|
residual: here,
|
|
1417
|
-
});
|
|
1428
|
+
} });
|
|
1418
1429
|
}
|
|
1419
1430
|
}
|
|
1420
|
-
found.sort((a, b) => a.residual - b.residual);
|
|
1421
|
-
const
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1431
|
+
found.sort((a, b) => a.candidate.residual - b.candidate.residual);
|
|
1432
|
+
const spacing = Math.min(radius * field.stride, support);
|
|
1433
|
+
const gridRadius = spacing / field.stride;
|
|
1434
|
+
const bucketSpan = Math.max(1, Math.floor(gridRadius));
|
|
1435
|
+
const reach = Math.ceil(gridRadius / bucketSpan);
|
|
1436
|
+
const accepted = new Map<string, Candidate[]>();
|
|
1437
|
+
const novel: Candidate[] = [];
|
|
1438
|
+
for (const entry of found) {
|
|
1439
|
+
if (novel.length >= keep) break;
|
|
1440
|
+
const cand = entry.candidate;
|
|
1441
|
+
const bx = Math.floor(entry.gx / bucketSpan), by = Math.floor(entry.gy / bucketSpan);
|
|
1442
|
+
let overlaps = false;
|
|
1443
|
+
for (let y = by - reach; y <= by + reach && !overlaps; y++) {
|
|
1444
|
+
for (let x = bx - reach; x <= bx + reach && !overlaps; x++) {
|
|
1445
|
+
for (const a of accepted.get(`${x}:${y}`) ?? []) {
|
|
1446
|
+
if (observation !== undefined) observation.spacingChecks = (observation.spacingChecks ?? 0) + 1;
|
|
1447
|
+
if (hypot2(a.cx - cand.cx, a.cy - cand.cy) <= spacing) { overlaps = true; break; }
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
if (overlaps) continue;
|
|
1452
|
+
const key = `${bx}:${by}`, bucket = accepted.get(key) ?? [];
|
|
1453
|
+
bucket.push(cand);
|
|
1454
|
+
accepted.set(key, bucket);
|
|
1455
|
+
if (observation !== undefined) observation.accepted++;
|
|
1456
|
+
if (covered.some((b) => hypot2(cand.cx - b.cx, cand.cy - b.cy) <= support)) {
|
|
1457
|
+
if (observation !== undefined) observation.covered++;
|
|
1458
|
+
} else novel.push(cand);
|
|
1426
1459
|
}
|
|
1427
|
-
return
|
|
1460
|
+
return novel;
|
|
1428
1461
|
}
|
|
1429
1462
|
|
|
1430
1463
|
/**
|
|
@@ -1859,6 +1892,9 @@ export function partFiles(imagesDir: string, exclude: string): string[] {
|
|
|
1859
1892
|
// ---------------------------------------------------------------------------
|
|
1860
1893
|
|
|
1861
1894
|
export function estimatePose(options: PoseOptions): PoseReport {
|
|
1895
|
+
if (options.coarsePolicy !== undefined && options.coarsePolicy !== 'legacy-only' && options.coarsePolicy !== 'short-support') {
|
|
1896
|
+
throw new PoseError(`coarsePolicy ${JSON.stringify(options.coarsePolicy)}: expected "short-support" or "legacy-only" — omit it for the current search`);
|
|
1897
|
+
}
|
|
1862
1898
|
const framePath = resolve(options.framePath);
|
|
1863
1899
|
if (!existsSync(framePath)) throw new PoseError(`no pose frame at ${framePath}`);
|
|
1864
1900
|
let frame: Plate;
|
|
@@ -1959,6 +1995,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
|
|
|
1959
1995
|
{ min: scaleMin, max: scaleMax },
|
|
1960
1996
|
rotationBounds,
|
|
1961
1997
|
options.coarseGrid ?? COARSE_GRID,
|
|
1998
|
+
options.coarsePolicy ?? 'short-support',
|
|
1962
1999
|
options.trace,
|
|
1963
2000
|
),
|
|
1964
2001
|
);
|
|
@@ -1977,6 +2014,7 @@ function placePart(
|
|
|
1977
2014
|
scaleBounds: { min: number; max: number },
|
|
1978
2015
|
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1979
2016
|
coarseGrid: PoseCoarseGrid,
|
|
2017
|
+
coarsePolicy: 'legacy-only' | 'short-support',
|
|
1980
2018
|
trace?: PoseTrace,
|
|
1981
2019
|
): PosePart {
|
|
1982
2020
|
const scaleMin = scaleBounds.min;
|
|
@@ -2114,11 +2152,23 @@ function placePart(
|
|
|
2114
2152
|
const stride = Math.max(1, Math.round(spanAtCoarse * coarseGrid.strideFraction));
|
|
2115
2153
|
const coarseSamples = samplesFor(coarse.reduction, COARSE_SAMPLES);
|
|
2116
2154
|
let candidates: Candidate[] = [];
|
|
2155
|
+
const supplementalSeeds: Candidate[] = [];
|
|
2117
2156
|
let grid = { reduction: coarse.reduction, cols: 0, rows: 0, stride };
|
|
2118
2157
|
for (const scale of scales) {
|
|
2119
2158
|
const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
|
|
2120
2159
|
grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
|
|
2121
|
-
|
|
2160
|
+
const legacy = poseCoarseMinima(field, coarseGrid.suppressionCells, coarseGrid.minimaPerScale);
|
|
2161
|
+
candidates.push(...legacy);
|
|
2162
|
+
if (coarsePolicy === 'short-support') {
|
|
2163
|
+
// An isotropic half-LONG-span neighborhood can suppress another position
|
|
2164
|
+
// along a thin part. Its inscribed material disk supplies a rotation-free
|
|
2165
|
+
// bound, in level pixels, derived from the actual rung scale (issue #1649).
|
|
2166
|
+
const support = thickness * scale / (2 * coarse.reduction);
|
|
2167
|
+
const extra = poseCoarseMinima(field, coarseGrid.suppressionCells, coarseGrid.minimaPerScale, support, legacy);
|
|
2168
|
+
// Charge the independent budget AFTER excluding already represented
|
|
2169
|
+
// positions; charging it first spends its slots on seeds then discarded.
|
|
2170
|
+
supplementalSeeds.push(...extra);
|
|
2171
|
+
}
|
|
2122
2172
|
}
|
|
2123
2173
|
base.coarse = grid;
|
|
2124
2174
|
candidates.sort((a, b) => a.residual - b.residual);
|
|
@@ -2140,147 +2190,159 @@ function placePart(
|
|
|
2140
2190
|
// that arm is one half of the two-identical-limbs answer the whole instrument
|
|
2141
2191
|
// exists to report. Re-gridding the ladder at the first level with real detail
|
|
2142
2192
|
// brought it back at +35.
|
|
2143
|
-
const
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
2152
|
-
|
|
2153
|
-
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
2159
|
-
|
|
2160
|
-
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
grid
|
|
2193
|
+
const refineLane = (initial: Candidate[], supplemental = false, levelTrace?: PoseTraceLevel[]): Candidate[] => {
|
|
2194
|
+
let candidates = initial;
|
|
2195
|
+
const branchLevel = Math.max(0, coarseIndex - 1);
|
|
2196
|
+
/** How many rotations survive the re-grid at the branch level, per position. */
|
|
2197
|
+
const BRANCH_ROTATIONS = 3;
|
|
2198
|
+
let rotStep = rotationFree ? 0 : COARSE_ROTATION_STEP / 2;
|
|
2199
|
+
for (let li = coarseIndex; li >= 0; li--) {
|
|
2200
|
+
const level = levels[li];
|
|
2201
|
+
const plate = plates[li];
|
|
2202
|
+
const smooth = li !== coarseIndex;
|
|
2203
|
+
const branch = li === branchLevel;
|
|
2204
|
+
// Coarse scale scores are not comparable. Give the coverage pool its
|
|
2205
|
+
// first detailed/rotation-regridded reading before ranking scales together.
|
|
2206
|
+
const keep = li === coarseIndex || (supplemental && li === branchLevel) ? scales.length * coarseGrid.minimaPerScale : REFINE_CANDIDATES;
|
|
2207
|
+
const s = samplesFor(level.reduction, li === 0 ? POLISH_SAMPLES : REFINE_SAMPLES);
|
|
2208
|
+
// The coarse pass only sampled every `stride` pixels, so entering the
|
|
2209
|
+
// refinement the anchor can be half a stride out; the first polish gets a
|
|
2210
|
+
// step big enough to cross that rather than a step that assumes a pixel.
|
|
2211
|
+
const step = { translate: li === coarseIndex ? Math.max(1.5, stride) : 1.5, rotate: rotStep, scale: 0.08 };
|
|
2212
|
+
const floor =
|
|
2213
|
+
li === 0 ? { translate: 0.05, rotate: 0.1, scale: 0.001 } : { translate: 0.25, rotate: 0.5, scale: 0.01 };
|
|
2214
|
+
const seeds: Candidate[] = [];
|
|
2215
|
+
for (const start of candidates.slice(0, keep)) {
|
|
2216
|
+
if (branch && !rotationFree) {
|
|
2217
|
+
const grid: Candidate[] = [];
|
|
2218
|
+
for (const rotDeg of searchRotations) {
|
|
2219
|
+
const probe: Candidate = { ...start, rotDeg, residual: 0 };
|
|
2220
|
+
probe.residual = residualAt(level, plate, s, probe, smooth);
|
|
2221
|
+
grid.push(probe);
|
|
2222
|
+
}
|
|
2223
|
+
grid.sort((a, b) => a.residual - b.residual);
|
|
2224
|
+
// One position and one scale throughout, so this dedupe is a spread over
|
|
2225
|
+
// rotation alone: an angle within 25 degrees of one already kept is the
|
|
2226
|
+
// same basin under a slightly different name.
|
|
2227
|
+
seeds.push(...dedupe(grid, 1, 25, Infinity).slice(0, BRANCH_ROTATIONS));
|
|
2228
|
+
continue;
|
|
2168
2229
|
}
|
|
2169
|
-
|
|
2170
|
-
// One position and one scale throughout, so this dedupe is a spread over
|
|
2171
|
-
// rotation alone: an angle within 25 degrees of one already kept is the
|
|
2172
|
-
// same basin under a slightly different name.
|
|
2173
|
-
seeds.push(...dedupe(grid, 1, 25, Infinity).slice(0, BRANCH_ROTATIONS));
|
|
2174
|
-
continue;
|
|
2230
|
+
seeds.push(start);
|
|
2175
2231
|
}
|
|
2176
|
-
seeds.
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
residual: c.residual,
|
|
2203
|
-
};
|
|
2204
|
-
};
|
|
2205
|
-
let probeResidual: number | null = null;
|
|
2206
|
-
if (trace.probe !== undefined) {
|
|
2207
|
-
const q = trace.probe;
|
|
2208
|
-
const cos = Math.cos(q.rotationDeg * DEG) * q.scale;
|
|
2209
|
-
const sin = Math.sin(q.rotationDeg * DEG) * q.scale;
|
|
2210
|
-
const at: Candidate = {
|
|
2211
|
-
cx: (q.x - (ou * cos - ov * sin)) / level.reduction,
|
|
2212
|
-
cy: (q.y - (ou * sin + ov * cos)) / level.reduction,
|
|
2213
|
-
rotDeg: q.rotationDeg,
|
|
2214
|
-
scale: q.scale,
|
|
2215
|
-
residual: 0,
|
|
2232
|
+
const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
|
|
2233
|
+
candidates = seeds.map((seed, i) =>
|
|
2234
|
+
polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? POLISH_SCALE_ESCAPES : [], trace === undefined ? undefined : paths[i]),
|
|
2235
|
+
);
|
|
2236
|
+
const polished = candidates.slice();
|
|
2237
|
+
candidates.sort((a, b) => a.residual - b.residual);
|
|
2238
|
+
// ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
|
|
2239
|
+
// and the radius has to scale with the PART rather than be a pixel count.
|
|
2240
|
+
// Narrowing by residual alone lets near-copies of the best hill fill the
|
|
2241
|
+
// budget and crowd the SECOND hill out, which is exactly the answer this
|
|
2242
|
+
// instrument exists to keep: with a fixed one-pixel radius the fixture lost
|
|
2243
|
+
// one of its two identical arms.
|
|
2244
|
+
candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
|
|
2245
|
+
if (trace !== undefined && levelTrace !== undefined) {
|
|
2246
|
+
const ou = part.width / 2 - anchorX;
|
|
2247
|
+
const ov = part.height / 2 - anchorY;
|
|
2248
|
+
const said = (c: Candidate): PoseTraceCandidate => {
|
|
2249
|
+
const cos = Math.cos(c.rotDeg * DEG) * c.scale;
|
|
2250
|
+
const sin = Math.sin(c.rotDeg * DEG) * c.scale;
|
|
2251
|
+
return {
|
|
2252
|
+
x: c.cx * level.reduction + ou * cos - ov * sin,
|
|
2253
|
+
y: c.cy * level.reduction + ou * sin + ov * cos,
|
|
2254
|
+
rotationDeg: normaliseDegrees(c.rotDeg),
|
|
2255
|
+
scale: c.scale,
|
|
2256
|
+
residual: c.residual,
|
|
2257
|
+
};
|
|
2216
2258
|
};
|
|
2217
|
-
probeResidual
|
|
2259
|
+
let probeResidual: number | null = null;
|
|
2260
|
+
if (trace.probe !== undefined) {
|
|
2261
|
+
const q = trace.probe;
|
|
2262
|
+
const cos = Math.cos(q.rotationDeg * DEG) * q.scale;
|
|
2263
|
+
const sin = Math.sin(q.rotationDeg * DEG) * q.scale;
|
|
2264
|
+
const at: Candidate = {
|
|
2265
|
+
cx: (q.x - (ou * cos - ov * sin)) / level.reduction,
|
|
2266
|
+
cy: (q.y - (ou * sin + ov * cos)) / level.reduction,
|
|
2267
|
+
rotDeg: q.rotationDeg,
|
|
2268
|
+
scale: q.scale,
|
|
2269
|
+
residual: 0,
|
|
2270
|
+
};
|
|
2271
|
+
probeResidual = residualAt(level, plate, s, at, smooth);
|
|
2272
|
+
}
|
|
2273
|
+
levelTrace.push({
|
|
2274
|
+
part: name,
|
|
2275
|
+
level: li,
|
|
2276
|
+
reduction: level.reduction,
|
|
2277
|
+
keep,
|
|
2278
|
+
seeds: seeds.map(said),
|
|
2279
|
+
starts: paths.map((p) => p.start),
|
|
2280
|
+
paths: paths.map((p) => p.steps),
|
|
2281
|
+
polished: polished.map(said),
|
|
2282
|
+
out: candidates.map(said),
|
|
2283
|
+
probeResidual,
|
|
2284
|
+
coarseRotations:
|
|
2285
|
+
li === coarseIndex
|
|
2286
|
+
? {
|
|
2287
|
+
ladder: searchRotations.slice(),
|
|
2288
|
+
residuals: seeds.map((seed) =>
|
|
2289
|
+
searchRotations.map((rotDeg) => coarseCellResidual(level, coarseSamples, seed.scale, rotDeg, seed.cx, seed.cy)),
|
|
2290
|
+
),
|
|
2291
|
+
}
|
|
2292
|
+
: null,
|
|
2293
|
+
});
|
|
2294
|
+
}
|
|
2295
|
+
if (li > 0) {
|
|
2296
|
+
candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
|
|
2297
|
+
// A rotation-free part keeps its step at zero all the way down, so the
|
|
2298
|
+
// polish never touches an angle that means nothing and the report's 0° is
|
|
2299
|
+
// the placeholder it says it is rather than a wandered-to number.
|
|
2300
|
+
if (!rotationFree) rotStep = Math.max(1, rotStep / 2);
|
|
2218
2301
|
}
|
|
2219
|
-
trace.levels.push({
|
|
2220
|
-
part: name,
|
|
2221
|
-
level: li,
|
|
2222
|
-
reduction: level.reduction,
|
|
2223
|
-
keep,
|
|
2224
|
-
seeds: seeds.map(said),
|
|
2225
|
-
starts: paths.map((p) => p.start),
|
|
2226
|
-
paths: paths.map((p) => p.steps),
|
|
2227
|
-
polished: polished.map(said),
|
|
2228
|
-
out: candidates.map(said),
|
|
2229
|
-
probeResidual,
|
|
2230
|
-
coarseRotations:
|
|
2231
|
-
li === coarseIndex
|
|
2232
|
-
? {
|
|
2233
|
-
ladder: searchRotations.slice(),
|
|
2234
|
-
residuals: seeds.map((seed) =>
|
|
2235
|
-
searchRotations.map((rotDeg) => coarseCellResidual(level, coarseSamples, seed.scale, rotDeg, seed.cx, seed.cy)),
|
|
2236
|
-
),
|
|
2237
|
-
}
|
|
2238
|
-
: null,
|
|
2239
|
-
});
|
|
2240
|
-
}
|
|
2241
|
-
if (li > 0) {
|
|
2242
|
-
candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
|
|
2243
|
-
// A rotation-free part keeps its step at zero all the way down, so the
|
|
2244
|
-
// polish never touches an angle that means nothing and the report's 0° is
|
|
2245
|
-
// the placeholder it says it is rather than a wandered-to number.
|
|
2246
|
-
if (!rotationFree) rotStep = Math.max(1, rotStep / 2);
|
|
2247
2302
|
}
|
|
2248
|
-
}
|
|
2249
2303
|
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2262
|
-
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
|
|
2272
|
-
|
|
2273
|
-
|
|
2274
|
-
|
|
2275
|
-
|
|
2276
|
-
|
|
2277
|
-
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2304
|
+
// The one rotation family the translation scan cannot see: a part that is its
|
|
2305
|
+
// own mirror after a quarter or a half turn sits in the SAME place at more than
|
|
2306
|
+
// one angle, so the field records only whichever won. Probe them explicitly.
|
|
2307
|
+
//
|
|
2308
|
+
// 🚨 Only the turns the window contains, and this is the other half of #719's
|
|
2309
|
+
// measurement. A quarter turn off is a SEED, not a ladder rung — so under
|
|
2310
|
+
// `--rotation -5,5` it entered the answer from outside a window the report was
|
|
2311
|
+
// calling the search, and the candidate who ran the exam read `rot=91.2°`
|
|
2312
|
+
// under `rotation -5°–5°`. A caller who bounds the rotation has said the part
|
|
2313
|
+
// is not a quarter turn over; the honest response is not to look there rather
|
|
2314
|
+
// than to look and report it.
|
|
2315
|
+
if (!rotationFree && candidates.length > 0) {
|
|
2316
|
+
const primary = candidates[0];
|
|
2317
|
+
const s = samplesFor(1, POLISH_SAMPLES);
|
|
2318
|
+
for (const turn of [90, 180, 270]) {
|
|
2319
|
+
const turned = primary.rotDeg + turn;
|
|
2320
|
+
if (!rotationBounds.wraps && (turned < rotationBounds.min - 1e-9 || turned > rotationBounds.max + 1e-9)) continue;
|
|
2321
|
+
candidates.push(
|
|
2322
|
+
polish(
|
|
2323
|
+
levels[0],
|
|
2324
|
+
plates[0],
|
|
2325
|
+
s,
|
|
2326
|
+
{ ...primary, rotDeg: turned },
|
|
2327
|
+
{ translate: 1.5, rotate: 4, scale: 0.04 },
|
|
2328
|
+
{ translate: 0.05, rotate: 0.1, scale: 0.001 },
|
|
2329
|
+
true,
|
|
2330
|
+
scaleBounds,
|
|
2331
|
+
rotationBounds,
|
|
2332
|
+
[],
|
|
2333
|
+
),
|
|
2334
|
+
);
|
|
2335
|
+
}
|
|
2336
|
+
candidates.sort((a, b) => a.residual - b.residual);
|
|
2281
2337
|
}
|
|
2282
|
-
|
|
2283
|
-
|
|
2338
|
+
|
|
2339
|
+
return candidates;
|
|
2340
|
+
};
|
|
2341
|
+
const baselineCandidates = refineLane(candidates, false, trace?.levels);
|
|
2342
|
+
supplementalSeeds.sort((a, b) => a.residual - b.residual);
|
|
2343
|
+
const extraTrace: PoseTraceLevel[] | undefined = trace === undefined ? undefined : [];
|
|
2344
|
+
if (trace !== undefined && extraTrace !== undefined) (trace.supplemental ??= []).push({ part: name, levels: extraTrace });
|
|
2345
|
+
candidates = [...baselineCandidates, ...refineLane(supplementalSeeds, true, extraTrace)];
|
|
2284
2346
|
|
|
2285
2347
|
// Measured at full resolution over every pixel, then de-duplicated: two
|
|
2286
2348
|
// candidates that walked to the same optimum are one answer, not two.
|