spine-rigc 1.3.0 → 1.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 +1 -1
- package/cli.ts +66 -16
- package/docs/AUTHORING.md +293 -30
- package/package.json +1 -1
- package/src/atlas.ts +173 -21
- package/src/check.ts +22 -0
- package/src/pose.ts +314 -18
- package/src/render.ts +515 -22
package/src/pose.ts
CHANGED
|
@@ -116,15 +116,75 @@ export const COARSE_LONG_SIDE = 40;
|
|
|
116
116
|
*/
|
|
117
117
|
export const COARSE_PART_SPAN = 10;
|
|
118
118
|
|
|
119
|
+
/**
|
|
120
|
+
* Pixels the part's SHORT side must still span at the coarse level — the same
|
|
121
|
+
* argument as `COARSE_PART_SPAN`, applied to the axis that rule does not read.
|
|
122
|
+
*
|
|
123
|
+
* ⚠️ Found by the change that tightened the coarse grid (issue #865), not by the
|
|
124
|
+
* floor grid, whose parts are none of them thin enough to reach it. MOTION.md
|
|
125
|
+
* §6's 60x14 signal arm is 3.5 px thick at the 4x level its length chose; on the
|
|
126
|
+
* quarter-span grid an anchor cell happened to sit where refinement found its
|
|
127
|
+
* truth (residual 0.0305), and on the eighth-span grid none did, so pose B
|
|
128
|
+
* reported a placement 15 px down the arm at 0.1482. A part three pixels thick
|
|
129
|
+
* at the coarse level has no across-the-part signal for any grid to read. 4, 5
|
|
130
|
+
* and 6 were each measured to move that arm up one level and place it; 4 is the
|
|
131
|
+
* least, and at 4 no trial of the floor grid moves at all.
|
|
132
|
+
*/
|
|
133
|
+
const COARSE_PART_THICKNESS = 4;
|
|
134
|
+
|
|
119
135
|
/**
|
|
120
136
|
* Coarse anchor positions are stepped at a fraction of the part's own size.
|
|
121
137
|
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
*
|
|
125
|
-
*
|
|
138
|
+
* Scanning every pixel of the coarse level would make the scan's cost grow with
|
|
139
|
+
* the frame's area instead of with the number of places the part could plausibly
|
|
140
|
+
* be, so the anchors are strided. How far apart is the part's own business.
|
|
141
|
+
*
|
|
142
|
+
* ⚠️ An eighth, not the quarter it was, and the reason is measured rather than
|
|
143
|
+
* assumed (issue #865). "The objective varies over distances of order the part"
|
|
144
|
+
* is true of a solid part and false of a thin or holey one: a gun whose material
|
|
145
|
+
* is a third of its box, or a fist with gaps between the fingers, loses most of
|
|
146
|
+
* its fit a quarter-span off its truth. On `tools/pose_floor.ts`'s grid, 65
|
|
147
|
+
* failed trials had a truth that scored better than the answer reported and was
|
|
148
|
+
* missing from the final list; in 38 of them the truth, evaluated exactly at the
|
|
149
|
+
* coarse level, beat every cell of its scale rung, and the anchor CELL holding it
|
|
150
|
+
* did not — the basin fell between two anchors. A cutoff on how many minima
|
|
151
|
+
* survive (3 of the 65) was not what lost them. At an eighth the grid found
|
|
152
|
+
* 226 of 420 trials against 185 (with `MINIMA_PER_SCALE` still at three), and
|
|
153
|
+
* the search misses fell from 65 to 15, for 10–14% more CPU over the grid.
|
|
154
|
+
*
|
|
155
|
+
* 🔸 Not free of losses, and they are stated rather than averaged away: 9 trials
|
|
156
|
+
* that placed on the quarter grid did not on this one when it shipped. Five are
|
|
157
|
+
* blurred parts at 24–96 px that came back ambiguous between two scales at the
|
|
158
|
+
* same spot, or 2.0 px off against a 2 px bar — the objective's, not this
|
|
159
|
+
* grid's. The other four were the refinement's, and since issue #877 all four
|
|
160
|
+
* place at their truth again: the native 48 px arm (walk) at 0.07 px, 48 px
|
|
161
|
+
* goggles (walk) 0.04, 96 px goggles (setup) 0.03 — three polishes stopped in a
|
|
162
|
+
* scale–position valley, now left by `POLISH_SCALE_ESCAPE` — and the 24 px shin
|
|
163
|
+
* (setup) 0.03, ranked 15th into full resolution, now carried by
|
|
164
|
+
* `REFINE_CANDIDATES`. Of the five blurred ones, the blur2 32 px shin (setup)
|
|
165
|
+
* and the blur1 24 px mouth are found again too.
|
|
166
|
+
*
|
|
167
|
+
* ⚠️ #877 lost two of its own, both inside the ambiguity margin rather than
|
|
168
|
+
* off the truth: the blur2 24 px front shin (walk) now reports the truth as its
|
|
169
|
+
* BEST (0.03 px, 0.00989) with the old 0.57 px answer 0.0094 above it, and the
|
|
170
|
+
* blur1 64 px mouth (walk) keeps its best at 0.93 px and gains a second scale
|
|
171
|
+
* at the same spot 0.0031 above it. Each is two readings of one place the
|
|
172
|
+
* 0.01 absolute margin will not pick between — the verdict it exists to give.
|
|
126
173
|
*/
|
|
127
|
-
export const COARSE_STRIDE_FRACTION = 0.
|
|
174
|
+
export const COARSE_STRIDE_FRACTION = 0.125;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Grid cells around a coarse cell that must not beat it for it to be a minimum,
|
|
178
|
+
* and the spacing kept between the minima one scale rung sends down.
|
|
179
|
+
*
|
|
180
|
+
* Four at an eighth-span stride is, nominally, the half-span the old two cells
|
|
181
|
+
* at a quarter held (the stride is rounded to whole level pixels, so only
|
|
182
|
+
* nominally). Measured on the grid: leaving it at two when the stride halved let one
|
|
183
|
+
* wrong hill fill a rung's `MINIMA_PER_SCALE` with its own neighbours — 223
|
|
184
|
+
* trials found rather than 226, and 12 that placed on the old grid lost rather
|
|
185
|
+
* than 9 (both at three minima per rung).
|
|
186
|
+
*/
|
|
187
|
+
const COARSE_SUPPRESSION_CELLS = 4;
|
|
128
188
|
|
|
129
189
|
/** How many scale rungs one octave gets in the coarse ladder. */
|
|
130
190
|
export const SCALE_STEPS_PER_OCTAVE = 3;
|
|
@@ -180,11 +240,74 @@ export const BACKGROUND_TOLERANCE = 10;
|
|
|
180
240
|
/** Share of the frame's border ring one colour must hold before it is called the background. */
|
|
181
241
|
export const BACKGROUND_BORDER_SHARE = 0.6;
|
|
182
242
|
|
|
183
|
-
/**
|
|
184
|
-
|
|
243
|
+
/**
|
|
244
|
+
* How many distinct places each coarse scale rung sends down for refinement.
|
|
245
|
+
*
|
|
246
|
+
* ⚠️ Five rather than three, and read off the grid rather than off a probe
|
|
247
|
+
* (issue #865). The card's own probe raised this to ten and recovered the fist it
|
|
248
|
+
* was chasing; on the full grid that bought 2 trials net (3 gained, 1 lost),
|
|
249
|
+
* because only 3 of the 65 trials the search missed had their truth's basin
|
|
250
|
+
* ranked under the cutoff — the rest had fallen between anchor cells, which is
|
|
251
|
+
* `COARSE_STRIDE_FRACTION`'s job. With the stride fixed, one miss of that kind was
|
|
252
|
+
* left (a 48 px fist, its basin fifth on its rung), and five is the count that
|
|
253
|
+
* carries it: 227 trials found against 226 at three, none lost, and a CPU cost
|
|
254
|
+
* inside the grid's run-to-run noise.
|
|
255
|
+
*/
|
|
256
|
+
const MINIMA_PER_SCALE = 5;
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* How many candidates survive each refinement level.
|
|
260
|
+
*
|
|
261
|
+
* ⚠️ Fifteen rather than twelve, and it is the smallest count the grid named
|
|
262
|
+
* (issue #877). Of the 14 trials the search missed after #865, one was lost by
|
|
263
|
+
* this cutoff rather than by any polish: the native 24 px front shin, whose
|
|
264
|
+
* truth's candidate ranked 15th (index 14) of 28 at the 2x level — that part's
|
|
265
|
+
* coarse level — and was cut on the way into full resolution, where the truth
|
|
266
|
+
* scores 0.0199 against the 0.1188 reported. Fifteen is the least count that
|
|
267
|
+
* carries index 14; sixteen alone was measured to place it at 0.03 px and move
|
|
268
|
+
* no other trial (228 found, none lost). A band of the level's best was the
|
|
269
|
+
* alternative, and it was rejected on its number: to carry that candidate it
|
|
270
|
+
* would have had to reach 1.73x the best, and a band that wide carried up to 27
|
|
271
|
+
* candidates (14 of 108 sampled refinement levels over twelve) — a cost set by
|
|
272
|
+
* one trial and paid on all of them.
|
|
273
|
+
*/
|
|
274
|
+
const REFINE_CANDIDATES = 15;
|
|
185
275
|
|
|
186
|
-
/**
|
|
187
|
-
|
|
276
|
+
/**
|
|
277
|
+
* The factor a converged full-resolution polish tries its scale up by before it
|
|
278
|
+
* stops — half a coarse scale rung, the resolution the coarse ladder itself had.
|
|
279
|
+
*
|
|
280
|
+
* 🔍 What the pattern search cannot do, and the trace table of issue #877 is
|
|
281
|
+
* how it was seen: every probe moves ONE degree of freedom, and the objective
|
|
282
|
+
* couples scale to position along a diagonal valley — a part shrunk a little
|
|
283
|
+
* fits best a pixel or two off its truth, so from there every single-axis probe
|
|
284
|
+
* is worse and every joint move is better. In 12 of the 14 missed trials the
|
|
285
|
+
* truth's candidate reached full resolution ranked first or second, the
|
|
286
|
+
* full-resolution objective preferred the truth to everything kept, and the
|
|
287
|
+
* polish stopped 2–7 px off it at a scale of 0.64–0.96. Not one polish ever
|
|
288
|
+
* accepted a worse residual — a polish moves only on a strictly lower probe of
|
|
289
|
+
* its own level's objective, and `PO26` holds that — so the rise from one level
|
|
290
|
+
* to the next is a change of objective, never a step taken uphill.
|
|
291
|
+
*
|
|
292
|
+
* ⭐ So the escape re-fits position at the new scale before comparing — the
|
|
293
|
+
* joint move, taken one axis at a time — and a better point restarts the polish
|
|
294
|
+
* there. UP only, and that is derived rather than tuned: the objective charges a
|
|
295
|
+
* part pixel that lands off the figure and charges nothing for figure left
|
|
296
|
+
* uncovered, so a shrunk placement is the cheap error the reduced levels make —
|
|
297
|
+
* every stuck point in the table sat below scale 1, none above.
|
|
298
|
+
*
|
|
299
|
+
* 📏 Measured on the grid with `REFINE_CANDIDATES` at fifteen: 227 → 237 trials
|
|
300
|
+
* found, 12 gained, 2 lost, search misses 14 → 8, for 43% more refinement
|
|
301
|
+
* samples (the coarse pass is untouched). Both ways at twelve found 236 (12
|
|
302
|
+
* gained, 3 lost) for 38% more against up only's 21%; a second step each way,
|
|
303
|
+
* 237 (15, 5) for 76%. ⚠️ The two it loses are one shape, a scale twin at the
|
|
304
|
+
* same spot: the blurred 64 px mouth (walk) keeps its best at 0.9 px and now
|
|
305
|
+
* reports a second placement 1.8 px off at scale 0.825 within the ambiguity
|
|
306
|
+
* margin, and the blurred 24 px front shin (walk) now places AT its truth
|
|
307
|
+
* (0.03 px, 0.00989) where it placed 0.57 px off at 0.0191 — the old answer is
|
|
308
|
+
* still reported beside it, 0.0094 above, inside the 0.01 absolute margin.
|
|
309
|
+
*/
|
|
310
|
+
const POLISH_SCALE_ESCAPE = 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE));
|
|
188
311
|
|
|
189
312
|
/** Sample budgets per stage. The reported residual uses every pixel regardless. */
|
|
190
313
|
const COARSE_SAMPLES = 96;
|
|
@@ -224,8 +347,8 @@ export const POSE_FLOOR = {
|
|
|
224
347
|
span: 24,
|
|
225
348
|
detail: 0.5,
|
|
226
349
|
/** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
|
|
227
|
-
rateBelow: 0.
|
|
228
|
-
rateAbove: 0.
|
|
350
|
+
rateBelow: 0.074,
|
|
351
|
+
rateAbove: 0.765,
|
|
229
352
|
};
|
|
230
353
|
|
|
231
354
|
/** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
|
|
@@ -266,8 +389,16 @@ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: '
|
|
|
266
389
|
// ⚠️ "Above the floor" is not "the search found the right hill". Measured on
|
|
267
390
|
// the floor's own material, a part above it came back ambiguous between two
|
|
268
391
|
// equally WRONG placements (residual 0.148 each, 173 px off) while the truth
|
|
269
|
-
// scored 0.035 — a basin the coarse pass never sent down.
|
|
270
|
-
//
|
|
392
|
+
// scored 0.035 — a basin the coarse pass never sent down. Issue #865 fixed
|
|
393
|
+
// that one (the coarse grid had stepped over it), and the reading still
|
|
394
|
+
// occurs: on the grid after it, 10 ambiguous trials above the floor had a
|
|
395
|
+
// truth scoring better than both answers — native 24 px gun and shin, 48 px
|
|
396
|
+
// arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
|
|
397
|
+
// up. Nine of the ten sat 2–6 px from the truth rather than on another hill.
|
|
398
|
+
// Issue #877's polish took that to 6 — native 24 px gun, 96 px goggles, and
|
|
399
|
+
// blurred goggles and gun from 128 px and rear shin at 48 px — five of them
|
|
400
|
+
// 2.1–2.7 px off, the rear shin 19 px. So the sentence names that reading
|
|
401
|
+
// too, with the one remedy that separates it: a window.
|
|
271
402
|
return (
|
|
272
403
|
`${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
|
|
273
404
|
(verdict === 'refused'
|
|
@@ -502,6 +633,67 @@ export interface PoseOptions {
|
|
|
502
633
|
scale?: { min: number; max: number };
|
|
503
634
|
rotation?: { minDeg: number; maxDeg: number };
|
|
504
635
|
maxResidual?: number;
|
|
636
|
+
/**
|
|
637
|
+
* An instrument's sink, never read by the search itself: when present, every
|
|
638
|
+
* level of every part's refinement is appended to it. See `PoseTrace`.
|
|
639
|
+
*/
|
|
640
|
+
trace?: PoseTrace;
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
/** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
|
|
644
|
+
export interface PoseTraceCandidate {
|
|
645
|
+
x: number;
|
|
646
|
+
y: number;
|
|
647
|
+
rotationDeg: number;
|
|
648
|
+
scale: number;
|
|
649
|
+
/** The level's own sampled objective — not the reported, every-pixel residual. */
|
|
650
|
+
residual: number;
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/** One accepted move of a polish: the residual it moved to and the steps it moved with. */
|
|
654
|
+
export interface PoseTraceStep {
|
|
655
|
+
residual: number;
|
|
656
|
+
translate: number;
|
|
657
|
+
rotate: number;
|
|
658
|
+
scale: number;
|
|
659
|
+
/** The accepted probe's scale or rotation was held by its window rather than taken as stepped. */
|
|
660
|
+
clamped: boolean;
|
|
661
|
+
/** A restart from a converged point to a different scale, rather than a step of the pattern. */
|
|
662
|
+
escape: boolean;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
export interface PoseTraceLevel {
|
|
666
|
+
part: string;
|
|
667
|
+
/** Pyramid index; 0 is full resolution. */
|
|
668
|
+
level: number;
|
|
669
|
+
reduction: number;
|
|
670
|
+
/** How many of the candidates handed down this level took, before any rotation branching. */
|
|
671
|
+
keep: number;
|
|
672
|
+
/** The seeds this level polished, in the order they were ranked going in. */
|
|
673
|
+
seeds: PoseTraceCandidate[];
|
|
674
|
+
/** Per seed, this level's objective at the seed — where its polish started from. */
|
|
675
|
+
starts: number[];
|
|
676
|
+
/** Per seed, the moves its polish accepted, in order. */
|
|
677
|
+
paths: PoseTraceStep[][];
|
|
678
|
+
/** Per seed, where its polish ended. */
|
|
679
|
+
polished: PoseTraceCandidate[];
|
|
680
|
+
/** The candidates this level hands on, ranked and deduplicated. */
|
|
681
|
+
out: PoseTraceCandidate[];
|
|
682
|
+
/** This level's objective at `PoseTrace.probe`, when one was given. */
|
|
683
|
+
probeResidual: number | null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
/**
|
|
687
|
+
* The refinement written down level by level, for an instrument that knows
|
|
688
|
+
* where the truth is and needs to say where the search left it (issue #877).
|
|
689
|
+
* Pure bookkeeping: a search with a trace returns the same report as one without.
|
|
690
|
+
*/
|
|
691
|
+
export interface PoseTrace {
|
|
692
|
+
/** A placement, in the report's own space, to score at every level alongside the candidates. */
|
|
693
|
+
probe?: { x: number; y: number; rotationDeg: number; scale: number };
|
|
694
|
+
levels: PoseTraceLevel[];
|
|
695
|
+
/** Per part, every candidate that reached the report, with its every-pixel residual, best first. */
|
|
696
|
+
measured: { part: string; candidates: PoseTraceCandidate[] }[];
|
|
505
697
|
}
|
|
506
698
|
|
|
507
699
|
// ---------------------------------------------------------------------------
|
|
@@ -1030,16 +1222,47 @@ function polish(
|
|
|
1030
1222
|
* default run moves.
|
|
1031
1223
|
*/
|
|
1032
1224
|
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1225
|
+
/** Scale factors tried from the point the steps converged on; a better one restarts the polish there. */
|
|
1226
|
+
escapes: readonly number[],
|
|
1227
|
+
/** An instrument's record of where the polish started and the moves it accepted; the search never reads it. */
|
|
1228
|
+
path?: { start: number; steps: PoseTraceStep[] },
|
|
1033
1229
|
): Candidate {
|
|
1034
1230
|
const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
|
|
1035
1231
|
const hold = (v: number): number =>
|
|
1036
1232
|
rotationBounds.wraps ? v : Math.min(rotationBounds.max, Math.max(rotationBounds.min, v));
|
|
1037
1233
|
let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
|
|
1234
|
+
if (path !== undefined) path.start = cur.residual;
|
|
1038
1235
|
let dt = step.translate;
|
|
1039
1236
|
let dr = step.rotate;
|
|
1040
1237
|
let ds = step.scale;
|
|
1041
1238
|
for (let guard = 0; guard < 200; guard++) {
|
|
1042
|
-
if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale)
|
|
1239
|
+
if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) {
|
|
1240
|
+
let jump = cur;
|
|
1241
|
+
for (const f of escapes) {
|
|
1242
|
+
const scale = clamp(cur.scale * f);
|
|
1243
|
+
if (scale === cur.scale) continue;
|
|
1244
|
+
const refit = polish(
|
|
1245
|
+
level,
|
|
1246
|
+
plate,
|
|
1247
|
+
s,
|
|
1248
|
+
{ ...cur, scale },
|
|
1249
|
+
{ translate: step.translate, rotate: 0, scale: 0 },
|
|
1250
|
+
{ translate: floor.translate, rotate: 0, scale: 0 },
|
|
1251
|
+
smooth,
|
|
1252
|
+
bounds,
|
|
1253
|
+
rotationBounds,
|
|
1254
|
+
[],
|
|
1255
|
+
);
|
|
1256
|
+
if (refit.residual < jump.residual) jump = refit;
|
|
1257
|
+
}
|
|
1258
|
+
if (jump === cur) break;
|
|
1259
|
+
path?.steps.push({ residual: jump.residual, translate: dt, rotate: dr, scale: jump.scale / cur.scale - 1, clamped: false, escape: true });
|
|
1260
|
+
cur = jump;
|
|
1261
|
+
dt = step.translate;
|
|
1262
|
+
dr = step.rotate;
|
|
1263
|
+
ds = step.scale;
|
|
1264
|
+
continue;
|
|
1265
|
+
}
|
|
1043
1266
|
const probes: Candidate[] = [];
|
|
1044
1267
|
const push = (cand: Omit<Candidate, 'residual'>): void => {
|
|
1045
1268
|
probes.push({ ...cand, residual: residualAt(level, plate, s, { ...cand, residual: 0 }, smooth) });
|
|
@@ -1074,6 +1297,12 @@ function polish(
|
|
|
1074
1297
|
ds /= 2;
|
|
1075
1298
|
continue;
|
|
1076
1299
|
}
|
|
1300
|
+
if (path !== undefined) {
|
|
1301
|
+
const clamped =
|
|
1302
|
+
(best.scale !== cur.scale && best.scale !== cur.scale * (1 + ds) && best.scale !== cur.scale * (1 - ds)) ||
|
|
1303
|
+
(best.rotDeg !== cur.rotDeg && best.rotDeg !== cur.rotDeg + dr && best.rotDeg !== cur.rotDeg - dr);
|
|
1304
|
+
path.steps.push({ residual: best.residual, translate: dt, rotate: dr, scale: ds, clamped, escape: false });
|
|
1305
|
+
}
|
|
1077
1306
|
cur = best;
|
|
1078
1307
|
}
|
|
1079
1308
|
return cur;
|
|
@@ -1488,6 +1717,7 @@ export function estimatePose(options: PoseOptions): PoseReport {
|
|
|
1488
1717
|
maxResidual,
|
|
1489
1718
|
{ min: scaleMin, max: scaleMax },
|
|
1490
1719
|
rotationBounds,
|
|
1720
|
+
options.trace,
|
|
1491
1721
|
),
|
|
1492
1722
|
);
|
|
1493
1723
|
}
|
|
@@ -1504,6 +1734,7 @@ function placePart(
|
|
|
1504
1734
|
maxResidual: number,
|
|
1505
1735
|
scaleBounds: { min: number; max: number },
|
|
1506
1736
|
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1737
|
+
trace?: PoseTrace,
|
|
1507
1738
|
): PosePart {
|
|
1508
1739
|
const scaleMin = scaleBounds.min;
|
|
1509
1740
|
/** The scale the sample sets are sized for — the middle of the window, and NOT the scale under test. */
|
|
@@ -1623,9 +1854,18 @@ function placePart(
|
|
|
1623
1854
|
// only about the frame. The pyramid stops when the frame fits in
|
|
1624
1855
|
// COARSE_LONG_SIDE; this then walks back UP it until the part still spans
|
|
1625
1856
|
// COARSE_PART_SPAN pixels there, because a level that has reduced the part to
|
|
1626
|
-
// three pixels cannot say where the part is at any price
|
|
1857
|
+
// three pixels cannot say where the part is at any price — and until its short
|
|
1858
|
+
// side still spans COARSE_PART_THICKNESS, which is the same sentence about a
|
|
1859
|
+
// part that is long and thin.
|
|
1627
1860
|
let coarseIndex = levels.length - 1;
|
|
1628
|
-
|
|
1861
|
+
const thickness = Math.min(tw, th);
|
|
1862
|
+
while (
|
|
1863
|
+
coarseIndex > 0 &&
|
|
1864
|
+
((span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN ||
|
|
1865
|
+
(thickness * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_THICKNESS)
|
|
1866
|
+
) {
|
|
1867
|
+
coarseIndex--;
|
|
1868
|
+
}
|
|
1629
1869
|
const coarse = levels[coarseIndex];
|
|
1630
1870
|
const spanAtCoarse = (span * scaleReference) / coarse.reduction;
|
|
1631
1871
|
const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
|
|
@@ -1635,7 +1875,7 @@ function placePart(
|
|
|
1635
1875
|
for (const scale of scales) {
|
|
1636
1876
|
const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
|
|
1637
1877
|
grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
|
|
1638
|
-
candidates.push(...localMinima(field,
|
|
1878
|
+
candidates.push(...localMinima(field, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
|
|
1639
1879
|
}
|
|
1640
1880
|
base.coarse = grid;
|
|
1641
1881
|
candidates.sort((a, b) => a.residual - b.residual);
|
|
@@ -1692,7 +1932,11 @@ function placePart(
|
|
|
1692
1932
|
}
|
|
1693
1933
|
seeds.push(start);
|
|
1694
1934
|
}
|
|
1695
|
-
|
|
1935
|
+
const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
|
|
1936
|
+
candidates = seeds.map((seed, i) =>
|
|
1937
|
+
polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? [POLISH_SCALE_ESCAPE] : [], trace === undefined ? undefined : paths[i]),
|
|
1938
|
+
);
|
|
1939
|
+
const polished = candidates.slice();
|
|
1696
1940
|
candidates.sort((a, b) => a.residual - b.residual);
|
|
1697
1941
|
// ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
|
|
1698
1942
|
// and the radius has to scale with the PART rather than be a pixel count.
|
|
@@ -1701,6 +1945,47 @@ function placePart(
|
|
|
1701
1945
|
// instrument exists to keep: with a fixed one-pixel radius the fixture lost
|
|
1702
1946
|
// one of its two identical arms.
|
|
1703
1947
|
candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
|
|
1948
|
+
if (trace !== undefined) {
|
|
1949
|
+
const ou = part.width / 2 - anchorX;
|
|
1950
|
+
const ov = part.height / 2 - anchorY;
|
|
1951
|
+
const said = (c: Candidate): PoseTraceCandidate => {
|
|
1952
|
+
const cos = Math.cos(c.rotDeg * DEG) * c.scale;
|
|
1953
|
+
const sin = Math.sin(c.rotDeg * DEG) * c.scale;
|
|
1954
|
+
return {
|
|
1955
|
+
x: c.cx * level.reduction + ou * cos - ov * sin,
|
|
1956
|
+
y: c.cy * level.reduction + ou * sin + ov * cos,
|
|
1957
|
+
rotationDeg: normaliseDegrees(c.rotDeg),
|
|
1958
|
+
scale: c.scale,
|
|
1959
|
+
residual: c.residual,
|
|
1960
|
+
};
|
|
1961
|
+
};
|
|
1962
|
+
let probeResidual: number | null = null;
|
|
1963
|
+
if (trace.probe !== undefined) {
|
|
1964
|
+
const q = trace.probe;
|
|
1965
|
+
const cos = Math.cos(q.rotationDeg * DEG) * q.scale;
|
|
1966
|
+
const sin = Math.sin(q.rotationDeg * DEG) * q.scale;
|
|
1967
|
+
const at: Candidate = {
|
|
1968
|
+
cx: (q.x - (ou * cos - ov * sin)) / level.reduction,
|
|
1969
|
+
cy: (q.y - (ou * sin + ov * cos)) / level.reduction,
|
|
1970
|
+
rotDeg: q.rotationDeg,
|
|
1971
|
+
scale: q.scale,
|
|
1972
|
+
residual: 0,
|
|
1973
|
+
};
|
|
1974
|
+
probeResidual = residualAt(level, plate, s, at, smooth);
|
|
1975
|
+
}
|
|
1976
|
+
trace.levels.push({
|
|
1977
|
+
part: name,
|
|
1978
|
+
level: li,
|
|
1979
|
+
reduction: level.reduction,
|
|
1980
|
+
keep,
|
|
1981
|
+
seeds: seeds.map(said),
|
|
1982
|
+
starts: paths.map((p) => p.start),
|
|
1983
|
+
paths: paths.map((p) => p.steps),
|
|
1984
|
+
polished: polished.map(said),
|
|
1985
|
+
out: candidates.map(said),
|
|
1986
|
+
probeResidual,
|
|
1987
|
+
});
|
|
1988
|
+
}
|
|
1704
1989
|
if (li > 0) {
|
|
1705
1990
|
candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
|
|
1706
1991
|
// A rotation-free part keeps its step at zero all the way down, so the
|
|
@@ -1738,6 +2023,7 @@ function placePart(
|
|
|
1738
2023
|
true,
|
|
1739
2024
|
scaleBounds,
|
|
1740
2025
|
rotationBounds,
|
|
2026
|
+
[],
|
|
1741
2027
|
),
|
|
1742
2028
|
);
|
|
1743
2029
|
}
|
|
@@ -1748,6 +2034,16 @@ function placePart(
|
|
|
1748
2034
|
// candidates that walked to the same optimum are one answer, not two.
|
|
1749
2035
|
const measured = candidates.map((cand) => ({ cand, placement: toPlacement(part, anchorX, anchorY, cand, measure(levels[0], plates[0], part, anchorX, anchorY, cand)) }));
|
|
1750
2036
|
measured.sort((a, b) => a.placement.residual - b.placement.residual || b.placement.footprint - a.placement.footprint);
|
|
2037
|
+
trace?.measured.push({
|
|
2038
|
+
part: name,
|
|
2039
|
+
candidates: measured.map((m) => ({
|
|
2040
|
+
x: m.placement.x,
|
|
2041
|
+
y: m.placement.y,
|
|
2042
|
+
rotationDeg: m.placement.rotationDeg,
|
|
2043
|
+
scale: m.placement.scale,
|
|
2044
|
+
residual: m.placement.residual,
|
|
2045
|
+
})),
|
|
2046
|
+
});
|
|
1751
2047
|
const distinct: typeof measured = [];
|
|
1752
2048
|
for (const m of measured) {
|
|
1753
2049
|
const same = distinct.some(
|