spine-rigc 1.2.3 → 1.4.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/src/pose.ts CHANGED
@@ -116,15 +116,63 @@ 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
- * The objective varies over distances of order the part, not of order a pixel, so
123
- * scanning every pixel of the coarse level buys resolution the refinement stages
124
- * supply anyway — and it is what makes the scan's cost grow with the frame's area
125
- * instead of with the number of places the part could plausibly be.
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 do not on this one. Five of them are blurred
157
+ * parts that now come back ambiguous between two scales at the same spot, or
158
+ * 2.0 px off against a 2 px bar; three are a basin reached and then polished
159
+ * onto a worse placement beside it, and one a basin ranked out of the
160
+ * refinement's twelve — the refinement, not this grid.
126
161
  */
127
- export const COARSE_STRIDE_FRACTION = 0.25;
162
+ export const COARSE_STRIDE_FRACTION = 0.125;
163
+
164
+ /**
165
+ * Grid cells around a coarse cell that must not beat it for it to be a minimum,
166
+ * and the spacing kept between the minima one scale rung sends down.
167
+ *
168
+ * Four at an eighth-span stride is, nominally, the half-span the old two cells
169
+ * at a quarter held (the stride is rounded to whole level pixels, so only
170
+ * nominally). Measured on the grid: leaving it at two when the stride halved let one
171
+ * wrong hill fill a rung's `MINIMA_PER_SCALE` with its own neighbours — 223
172
+ * trials found rather than 226, and 12 that placed on the old grid lost rather
173
+ * than 9 (both at three minima per rung).
174
+ */
175
+ const COARSE_SUPPRESSION_CELLS = 4;
128
176
 
129
177
  /** How many scale rungs one octave gets in the coarse ladder. */
130
178
  export const SCALE_STEPS_PER_OCTAVE = 3;
@@ -180,8 +228,20 @@ export const BACKGROUND_TOLERANCE = 10;
180
228
  /** Share of the frame's border ring one colour must hold before it is called the background. */
181
229
  export const BACKGROUND_BORDER_SHARE = 0.6;
182
230
 
183
- /** How many distinct places each coarse scale rung sends down for refinement. */
184
- const MINIMA_PER_SCALE = 3;
231
+ /**
232
+ * How many distinct places each coarse scale rung sends down for refinement.
233
+ *
234
+ * ⚠️ Five rather than three, and read off the grid rather than off a probe
235
+ * (issue #865). The card's own probe raised this to ten and recovered the fist it
236
+ * was chasing; on the full grid that bought 2 trials net (3 gained, 1 lost),
237
+ * because only 3 of the 65 trials the search missed had their truth's basin
238
+ * ranked under the cutoff — the rest had fallen between anchor cells, which is
239
+ * `COARSE_STRIDE_FRACTION`'s job. With the stride fixed, one miss of that kind was
240
+ * left (a 48 px fist, its basin fifth on its rung), and five is the count that
241
+ * carries it: 227 trials found against 226 at three, none lost, and a CPU cost
242
+ * inside the grid's run-to-run noise.
243
+ */
244
+ const MINIMA_PER_SCALE = 5;
185
245
 
186
246
  /** How many candidates survive each refinement level. */
187
247
  const REFINE_CANDIDATES = 12;
@@ -196,6 +256,92 @@ const MAX_ALTERNATES = 3;
196
256
 
197
257
  const DEG = Math.PI / 180;
198
258
 
259
+ /**
260
+ * The detail below which `pose` has been MEASURED to almost never place a part
261
+ * — the floor a refusal or an ambiguity is read against (issue #857).
262
+ *
263
+ * 📏 Measured, not chosen: `tools/pose_floor.ts` builds the grid and derives
264
+ * `detail` (`deriveFloor`: the largest rung of a fixed ladder under which at
265
+ * most a tenth of the grid's trials were found), and `docs/AUTHORING.md` §11.5
266
+ * carries the grid, the method and what it rejected. A part is BELOW the floor
267
+ * when its `detail` is under `detail`, or its longest side, in its own pixels,
268
+ * under `span` — the grid cut its parts at scale 1, so its sizes are both.
269
+ *
270
+ * ⚠️ Two things this is not. It is not a size found to matter: `span` is the
271
+ * smallest size the grid measured, and over 24–192 px no texture level placed
272
+ * measurably better when larger — a part under it is below the floor because
273
+ * nothing was measured there, not because it was measured to fail. And it is
274
+ * not a line above which `pose` is reliable: over it, on IDEAL cuts (parts cut
275
+ * from the frame's own render, residual at the truth near zero), `rateAbove`
276
+ * of the trials were found. A generated cut also disagrees with its picture,
277
+ * which can only make that lower. So "above the floor" means "plainness does
278
+ * not explain this refusal", which is the question the issue asked, and never
279
+ * "this part will place".
280
+ */
281
+ export const POSE_FLOOR = {
282
+ material: 'examples/spineboy, cut from its own render (tools/pose_floor.ts)',
283
+ withinPx: 2,
284
+ span: 24,
285
+ detail: 0.5,
286
+ /** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
287
+ rateBelow: 0.082,
288
+ rateAbove: 0.728,
289
+ };
290
+
291
+ /** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
292
+ export function floorSide(side: number, detail: number): 'below' | 'above' {
293
+ return side >= POSE_FLOOR.span && detail >= POSE_FLOOR.detail ? 'above' : 'below';
294
+ }
295
+
296
+ /** The floor as one clause, for the sentences that cite it. */
297
+ export function floorClause(): string {
298
+ return `detail ${POSE_FLOOR.detail}, measured from ${POSE_FLOOR.span} px up`;
299
+ }
300
+
301
+ /**
302
+ * The sentence that tells "the part is wrong" from "the part is too small or too
303
+ * plain for `pose`", with every figure it rests on (issue #857).
304
+ *
305
+ * ⭐ Exported for the reason `windowEdgeNote` is: the guide quotes it, and a
306
+ * message and the document teaching it must be built from one place.
307
+ */
308
+ export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: 'refused' | 'ambiguous'): string {
309
+ const figures =
310
+ `part is ${l.width}x${l.height} px (span ${l.span} frame px, opaque ${l.opaqueShare}) with texture ${l.texture}, ` +
311
+ `detail ${l.detail}; ` +
312
+ `${l.candidates} candidate(s), best ${l.best.toFixed(4)}` +
313
+ (l.next === null || l.spread === null ? '' : `, next ${l.next.toFixed(4)}, spread ${l.spread.toFixed(4)}`);
314
+ if (l.floor === 'below') {
315
+ const why =
316
+ Math.max(l.width, l.height) < POSE_FLOOR.span
317
+ ? `smaller than the smallest size the floor was measured at, so nothing measured says pose can ` +
318
+ `${verdict === 'refused' ? 'place' : 'separate'} it`
319
+ : `pose placed ${Math.round(POSE_FLOOR.rateBelow * 100)}% of the measured parts this plain, so this part is too ` +
320
+ `plain for pose to ${verdict === 'refused' ? 'place' : 'tell its placements apart'}`;
321
+ return (
322
+ `${figures} — below the measured floor (AUTHORING §11.5: ${floorClause()}): ${why}; that says nothing about ` +
323
+ 'whether the cut is right'
324
+ );
325
+ }
326
+ // ⚠️ "Above the floor" is not "the search found the right hill". Measured on
327
+ // the floor's own material, a part above it came back ambiguous between two
328
+ // equally WRONG placements (residual 0.148 each, 173 px off) while the truth
329
+ // scored 0.035 — a basin the coarse pass never sent down. Issue #865 fixed
330
+ // that one (the coarse grid had stepped over it), and the reading still
331
+ // occurs: on the grid after it, 10 ambiguous trials above the floor had a
332
+ // truth scoring better than both answers — native 24 px gun and shin, 48 px
333
+ // arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
334
+ // up. Nine of the ten sat 2–6 px from the truth rather than on another hill. So the sentence names that reading too, with the one remedy that
335
+ // separates it: a window.
336
+ return (
337
+ `${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
338
+ (verdict === 'refused'
339
+ ? 'the cut (or the search window) is the suspect'
340
+ : 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
341
+ 'a narrower --scale or --rotation window around what you know of the part tells the two apart')
342
+ );
343
+ }
344
+
199
345
  // ---------------------------------------------------------------------------
200
346
  // the report
201
347
  // ---------------------------------------------------------------------------
@@ -307,10 +453,69 @@ export interface PosePart {
307
453
  * the part is small relative to the frame and the first pass had little to go on.
308
454
  */
309
455
  coarse: { reduction: number; cols: number; rows: number; stride: number } | null;
456
+ /**
457
+ * What the part itself gave the search to work with, and how the search's
458
+ * answers stood against each other — `null` only when nothing was searched
459
+ * (`empty-part`, `larger-than-canvas`). See `PoseLegibility`.
460
+ */
461
+ legibility: PoseLegibility | null;
310
462
  /** Plain-language versions of everything above, in the order they were found. */
311
463
  notes: string[];
312
464
  }
313
465
 
466
+ /**
467
+ * The facts that tell "this part is wrong" from "this part is too small or too
468
+ * plain for `pose`" (issue #857).
469
+ *
470
+ * ⭐ Both readings end in the same refusal or the same ambiguity, and nothing in
471
+ * a residual separates them: a correct cut of a plain sleeve and a foreign part
472
+ * can print the same number. What separates them is the part's own evidence —
473
+ * how big it is in the frame and how much pattern it carries — held against a
474
+ * floor measured on parts whose placement was known (`POSE_FLOOR`). So every
475
+ * searched part carries these, whatever its verdict, and a refusal or an
476
+ * ambiguity quotes them in a sentence that says which of the two it is.
477
+ */
478
+ export interface PoseLegibility {
479
+ /** The part's material box, part pixels — the PNG less its transparent margin. */
480
+ width: number;
481
+ height: number;
482
+ /**
483
+ * Longest side of the material box at the best placement's scale, frame pixels.
484
+ * ⚠️ Not what `floor` is read from — the part's own `width`/`height` are, since
485
+ * this one moves with a placement that may be wrong.
486
+ */
487
+ span: number;
488
+ /** The part's alpha weight over its material box's area, 0..1. A thin diagonal limb reads low; a filled block reads 1. */
489
+ opaqueShare: number;
490
+ /** Mean colour step between neighbouring material pixels, 0..1 — see `partTexture`. */
491
+ texture: number;
492
+ /**
493
+ * `texture` times the material box's longest side in PART pixels: the colour
494
+ * change a walk along the part's length accumulates. The figure the floor is
495
+ * stated in — see `POSE_FLOOR` for why it and not `texture` alone.
496
+ */
497
+ detail: number;
498
+ /** Distinct placements the search measured at full resolution, the best included. */
499
+ candidates: number;
500
+ /** The best placement's residual, and the second distinct one's — `null` when there was no second. */
501
+ best: number;
502
+ next: number | null;
503
+ /** `next − best`: how far the search's second answer was from its first. `null` with no second. */
504
+ spread: number | null;
505
+ /**
506
+ * Where the part stands against `POSE_FLOOR` — `below` when its span or its
507
+ * detail is under the floor's. A reading of the part, not of the answer: it is set on accepted
508
+ * parts too, and it is what a refusal's sentence is chosen by.
509
+ */
510
+ floor: 'below' | 'above';
511
+ /**
512
+ * The sentence a refused or ambiguous part carries, saying which of the two
513
+ * readings its figures support — `null` on a part that was placed. Built by
514
+ * `legibilityReading`, and the console prints the same text.
515
+ */
516
+ reading: string | null;
517
+ }
518
+
314
519
  export interface PoseSearch {
315
520
  scale: { min: number; max: number; steps: number };
316
521
  /**
@@ -550,6 +755,45 @@ function materialBox(part: Plate): { minX: number; minY: number; maxX: number; m
550
755
  return { minX, minY, maxX: maxX + 1, maxY: maxY + 1, weight };
551
756
  }
552
757
 
758
+ /**
759
+ * The part's texture figure: the mean colour step between neighbouring material
760
+ * pixels, 0..1, in the residual's own units.
761
+ *
762
+ * Every pair of a pixel and its right or lower neighbour, both with material,
763
+ * contributes its mean absolute channel difference over 255, weighted by the
764
+ * smaller of the two alphas. A part filled with one colour reads exactly 0.
765
+ *
766
+ * ⭐ Why this figure rather than a colour variance: it is, to first order, what a
767
+ * one-pixel shift costs. Moving a part by a pixel inside material that continues
768
+ * under it compares each of its pixels against its neighbour's colour, so the
769
+ * residual rises by about this much per pixel of shift — and a part whose
770
+ * residual cannot rise when it moves cannot be told apart from itself moved.
771
+ * A variance does not see that: a part half one colour and half another has a
772
+ * large variance and one edge. The silhouette is deliberately not counted — it
773
+ * is what the material term already reads, and only where the frame is ground.
774
+ */
775
+ export function partTexture(part: Plate): number {
776
+ const w = part.width;
777
+ const d = part.data;
778
+ let weight = 0;
779
+ let acc = 0;
780
+ const pair = (i: number, j: number): void => {
781
+ const k = Math.min(d[i + 3], d[j + 3]) / 255;
782
+ if (k <= 0) return;
783
+ weight += k;
784
+ acc += (k * (Math.abs(d[i] - d[j]) + Math.abs(d[i + 1] - d[j + 1]) + Math.abs(d[i + 2] - d[j + 2]))) / 765;
785
+ };
786
+ for (let y = 0; y < part.height; y++) {
787
+ for (let x = 0; x < w; x++) {
788
+ const i = (y * w + x) * 4;
789
+ if (d[i + 3] === 0) continue;
790
+ if (x + 1 < w) pair(i, i + 4);
791
+ if (y + 1 < part.height) pair(i, i + w * 4);
792
+ }
793
+ }
794
+ return weight === 0 ? 0 : acc / weight;
795
+ }
796
+
553
797
  // ---------------------------------------------------------------------------
554
798
  // the objective
555
799
  // ---------------------------------------------------------------------------
@@ -1347,6 +1591,7 @@ function placePart(
1347
1591
  rotationFree: false,
1348
1592
  rotationSelfSimilarity: 1,
1349
1593
  coarse: null,
1594
+ legibility: null,
1350
1595
  notes: [`${name} could not be decoded, so it was not placed.`],
1351
1596
  };
1352
1597
  }
@@ -1363,6 +1608,7 @@ function placePart(
1363
1608
  rotationFree: false,
1364
1609
  rotationSelfSimilarity: 1,
1365
1610
  coarse: null,
1611
+ legibility: null,
1366
1612
  notes: [],
1367
1613
  };
1368
1614
 
@@ -1442,9 +1688,18 @@ function placePart(
1442
1688
  // only about the frame. The pyramid stops when the frame fits in
1443
1689
  // COARSE_LONG_SIDE; this then walks back UP it until the part still spans
1444
1690
  // COARSE_PART_SPAN pixels there, because a level that has reduced the part to
1445
- // three pixels cannot say where the part is at any price.
1691
+ // three pixels cannot say where the part is at any price — and until its short
1692
+ // side still spans COARSE_PART_THICKNESS, which is the same sentence about a
1693
+ // part that is long and thin.
1446
1694
  let coarseIndex = levels.length - 1;
1447
- while (coarseIndex > 0 && (span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN) coarseIndex--;
1695
+ const thickness = Math.min(tw, th);
1696
+ while (
1697
+ coarseIndex > 0 &&
1698
+ ((span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN ||
1699
+ (thickness * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_THICKNESS)
1700
+ ) {
1701
+ coarseIndex--;
1702
+ }
1448
1703
  const coarse = levels[coarseIndex];
1449
1704
  const spanAtCoarse = (span * scaleReference) / coarse.reduction;
1450
1705
  const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
@@ -1454,7 +1709,7 @@ function placePart(
1454
1709
  for (const scale of scales) {
1455
1710
  const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
1456
1711
  grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
1457
- candidates.push(...localMinima(field, 2, MINIMA_PER_SCALE));
1712
+ candidates.push(...localMinima(field, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
1458
1713
  }
1459
1714
  base.coarse = grid;
1460
1715
  candidates.sort((a, b) => a.residual - b.residual);
@@ -1579,6 +1834,26 @@ function placePart(
1579
1834
  }
1580
1835
 
1581
1836
  const best = distinct[0].placement;
1837
+ const second = distinct.length > 1 ? distinct[1].placement.residual : null;
1838
+ base.legibility = {
1839
+ width: tw,
1840
+ height: th,
1841
+ span: roundTo(span * best.scale, 1),
1842
+ opaqueShare: roundTo(box.weight / (tw * th), 4),
1843
+ texture: roundTo(partTexture(part), 4),
1844
+ detail: roundTo(partTexture(part) * span, 3),
1845
+ candidates: distinct.length,
1846
+ best: best.residual,
1847
+ next: second,
1848
+ spread: second === null ? null : roundTo(second - best.residual, 5),
1849
+ floor: 'above',
1850
+ reading: null,
1851
+ };
1852
+ // The part's OWN size, not `span`: `span` is scaled by the best placement,
1853
+ // and on the parts this reading exists for that placement is the one in
1854
+ // doubt — a gun cut at 32 px read as 16 px under the half-scale optimum it
1855
+ // wrongly settled on. The floor was measured at scale 1, where the two agree.
1856
+ base.legibility.floor = floorSide(Math.max(tw, th), base.legibility.detail);
1582
1857
  const margin = Math.max(AMBIGUITY_ABSOLUTE, best.residual * AMBIGUITY_RELATIVE);
1583
1858
  const close = distinct.slice(1).filter((d) => d.placement.residual - best.residual <= margin);
1584
1859
  base.placement = best;
@@ -1653,6 +1928,17 @@ function placePart(
1653
1928
  `${base.walls.map((wall) => wallClause(wall.axis, wall.edge, wall.window)).join('; ')}.`,
1654
1929
  );
1655
1930
  }
1931
+ // ⭐ Which of the two readings a refusal or an ambiguity is (issue #857),
1932
+ // in the refusal's own detail and in a note, from the figures `legibility`
1933
+ // already carries. After the walls on purpose: a wall is the first thing to
1934
+ // move, and this sentence is about what moving it cannot change.
1935
+ if (base.refusal !== null || base.ambiguous) {
1936
+ const verdict = base.refusal !== null ? 'refused' : 'ambiguous';
1937
+ const reading = legibilityReading(base.legibility, verdict);
1938
+ base.legibility.reading = reading;
1939
+ if (base.refusal !== null) base.refusal.detail += `; ${reading}`;
1940
+ base.notes.push(`${name}: ${reading}.`);
1941
+ }
1656
1942
  if (best.unexplained > 0.25 && best.residual <= maxResidual) {
1657
1943
  base.notes.push(
1658
1944
  `${roundTo(best.unexplained * 100, 1)}% of ${name}'s material disagrees with the frame at this placement. ` +
@@ -1728,6 +2014,7 @@ export function poseLines(report: PoseReport): string[] {
1728
2014
  lines.push(` ${' '.repeat(width)} alt ${i + 2}: ${placementLine(alt)}`);
1729
2015
  });
1730
2016
  if (part.refusal !== null) lines.push(` ${' '.repeat(width)} ${part.refusal.reason}: ${part.refusal.detail}`);
2017
+ else if (part.legibility?.reading) lines.push(` ${' '.repeat(width)} ambiguous: ${part.legibility.reading}`);
1731
2018
  }
1732
2019
  lines.push('');
1733
2020
  lines.push(' .. residuals are a trust signal, not a score — nothing here has a pass bar.');