@rig-c/core 2.82.0 → 2.82.2

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/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 localMinima(field: CoarseField, radius: number, keep: number): Candidate[] {
1392
- const found: Candidate[] = [];
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 accepted: Candidate[] = [];
1422
- for (const cand of found) {
1423
- if (accepted.length >= keep) break;
1424
- if (accepted.some((a) => hypot2(a.cx - cand.cx, a.cy - cand.cy) <= radius * field.stride)) continue;
1425
- accepted.push(cand);
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 accepted;
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
- candidates.push(...localMinima(field, coarseGrid.suppressionCells, coarseGrid.minimaPerScale));
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 branchLevel = Math.max(0, coarseIndex - 1);
2144
- /** How many rotations survive the re-grid at the branch level, per position. */
2145
- const BRANCH_ROTATIONS = 3;
2146
- let rotStep = rotationFree ? 0 : COARSE_ROTATION_STEP / 2;
2147
- for (let li = coarseIndex; li >= 0; li--) {
2148
- const level = levels[li];
2149
- const plate = plates[li];
2150
- const smooth = li !== coarseIndex;
2151
- const branch = li === branchLevel;
2152
- const keep = li === coarseIndex ? scales.length * coarseGrid.minimaPerScale : REFINE_CANDIDATES;
2153
- const s = samplesFor(level.reduction, li === 0 ? POLISH_SAMPLES : REFINE_SAMPLES);
2154
- // The coarse pass only sampled every `stride` pixels, so entering the
2155
- // refinement the anchor can be half a stride out; the first polish gets a
2156
- // step big enough to cross that rather than a step that assumes a pixel.
2157
- const step = { translate: li === coarseIndex ? Math.max(1.5, stride) : 1.5, rotate: rotStep, scale: 0.08 };
2158
- const floor =
2159
- li === 0 ? { translate: 0.05, rotate: 0.1, scale: 0.001 } : { translate: 0.25, rotate: 0.5, scale: 0.01 };
2160
- const seeds: Candidate[] = [];
2161
- for (const start of candidates.slice(0, keep)) {
2162
- if (branch && !rotationFree) {
2163
- const grid: Candidate[] = [];
2164
- for (const rotDeg of searchRotations) {
2165
- const probe: Candidate = { ...start, rotDeg, residual: 0 };
2166
- probe.residual = residualAt(level, plate, s, probe, smooth);
2167
- grid.push(probe);
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
- grid.sort((a, b) => a.residual - b.residual);
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.push(start);
2177
- }
2178
- const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
2179
- candidates = seeds.map((seed, i) =>
2180
- polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? POLISH_SCALE_ESCAPES : [], trace === undefined ? undefined : paths[i]),
2181
- );
2182
- const polished = candidates.slice();
2183
- candidates.sort((a, b) => a.residual - b.residual);
2184
- // ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
2185
- // and the radius has to scale with the PART rather than be a pixel count.
2186
- // Narrowing by residual alone lets near-copies of the best hill fill the
2187
- // budget and crowd the SECOND hill out, which is exactly the answer this
2188
- // instrument exists to keep: with a fixed one-pixel radius the fixture lost
2189
- // one of its two identical arms.
2190
- candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
2191
- if (trace !== undefined) {
2192
- const ou = part.width / 2 - anchorX;
2193
- const ov = part.height / 2 - anchorY;
2194
- const said = (c: Candidate): PoseTraceCandidate => {
2195
- const cos = Math.cos(c.rotDeg * DEG) * c.scale;
2196
- const sin = Math.sin(c.rotDeg * DEG) * c.scale;
2197
- return {
2198
- x: c.cx * level.reduction + ou * cos - ov * sin,
2199
- y: c.cy * level.reduction + ou * sin + ov * cos,
2200
- rotationDeg: normaliseDegrees(c.rotDeg),
2201
- scale: c.scale,
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 = residualAt(level, plate, s, at, smooth);
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
- // The one rotation family the translation scan cannot see: a part that is its
2251
- // own mirror after a quarter or a half turn sits in the SAME place at more than
2252
- // one angle, so the field records only whichever won. Probe them explicitly.
2253
- //
2254
- // 🚨 Only the turns the window contains, and this is the other half of #719's
2255
- // measurement. A quarter turn off is a SEED, not a ladder rung — so under
2256
- // `--rotation -5,5` it entered the answer from outside a window the report was
2257
- // calling the search, and the candidate who ran the exam read `rot=91.2°`
2258
- // under `rotation -5°–5°`. A caller who bounds the rotation has said the part
2259
- // is not a quarter turn over; the honest response is not to look there rather
2260
- // than to look and report it.
2261
- if (!rotationFree && candidates.length > 0) {
2262
- const primary = candidates[0];
2263
- const s = samplesFor(1, POLISH_SAMPLES);
2264
- for (const turn of [90, 180, 270]) {
2265
- const turned = primary.rotDeg + turn;
2266
- if (!rotationBounds.wraps && (turned < rotationBounds.min - 1e-9 || turned > rotationBounds.max + 1e-9)) continue;
2267
- candidates.push(
2268
- polish(
2269
- levels[0],
2270
- plates[0],
2271
- s,
2272
- { ...primary, rotDeg: turned },
2273
- { translate: 1.5, rotate: 4, scale: 0.04 },
2274
- { translate: 0.05, rotate: 0.1, scale: 0.001 },
2275
- true,
2276
- scaleBounds,
2277
- rotationBounds,
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
- candidates.sort((a, b) => a.residual - b.residual);
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.