rig-c 0.0.0-stage → 2.21.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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +13 -0
- package/LICENSE +30 -0
- package/NOTICE.md +145 -0
- package/README.md +817 -3
- package/bin/rigc.cjs +83 -0
- package/cli.ts +61 -0
- package/cli_core.ts +46 -0
- package/docs/AUTHORING.md +9923 -0
- package/docs/FACE.md +1948 -0
- package/docs/INGEST.md +1488 -0
- package/docs/MOTION.md +1241 -0
- package/docs/PROMPTING.md +109 -0
- package/docs/RIGGING.md +1441 -0
- package/docs/SPEC_COVERAGE.md +357 -0
- package/package.json +108 -4
- package/skills/rigc/SKILL.md +133 -0
- package/skills/rigc-face/SKILL.md +60 -0
- package/skills/rigc-ingest/SKILL.md +78 -0
- package/skills/rigc-motion/SKILL.md +51 -0
- package/skills/rigc-rigging/SKILL.md +49 -0
- package/src/areaband.ts +159 -0
- package/src/assertions/bodies/a01.ts +23 -0
- package/src/assertions/bodies/a02.ts +21 -0
- package/src/assertions/bodies/a03.ts +27 -0
- package/src/assertions/bodies/a04.ts +40 -0
- package/src/assertions/bodies/a05.ts +56 -0
- package/src/assertions/bodies/a06.ts +245 -0
- package/src/assertions/bodies/a07.ts +68 -0
- package/src/assertions/bodies/a08.ts +76 -0
- package/src/assertions/bodies/a09.ts +82 -0
- package/src/assertions/bodies/a10.ts +116 -0
- package/src/assertions/bodies/a11.ts +15 -0
- package/src/assertions/bodies/a12.ts +30 -0
- package/src/assertions/bodies/a13.ts +51 -0
- package/src/assertions/bodies/a14.ts +35 -0
- package/src/assertions/bodies/a15.ts +97 -0
- package/src/assertions/bodies/a16.ts +24 -0
- package/src/assertions/bodies/a17.ts +26 -0
- package/src/assertions/bodies/a18.ts +62 -0
- package/src/assertions/bodies/a19.ts +404 -0
- package/src/assertions/bodies/a20.ts +122 -0
- package/src/assertions/bodies/a21.ts +190 -0
- package/src/assertions/bodies/a22.ts +39 -0
- package/src/assertions/bodies/a23.ts +305 -0
- package/src/assertions/bodies/a24.ts +68 -0
- package/src/assertions/bodies/a25.ts +39 -0
- package/src/assertions/bodies/a26.ts +61 -0
- package/src/assertions/bodies/a27.ts +33 -0
- package/src/assertions/bodies/a28.ts +70 -0
- package/src/assertions/bodies/a29.ts +34 -0
- package/src/assertions/bodies/a30.ts +50 -0
- package/src/assertions/bodies/a31.ts +61 -0
- package/src/assertions/bodies/a32.ts +44 -0
- package/src/assertions/bodies/a33.ts +110 -0
- package/src/assertions/bodies/a34.ts +133 -0
- package/src/assertions/bodies/a35.ts +160 -0
- package/src/assertions/bodies/a36.ts +81 -0
- package/src/assertions/bodies/a37.ts +77 -0
- package/src/assertions/bodies/a38.ts +73 -0
- package/src/assertions/bodies/a39.ts +303 -0
- package/src/assertions/bodies/a40.ts +128 -0
- package/src/assertions/bodies/a42.ts +97 -0
- package/src/assertions/bodies/a43.ts +181 -0
- package/src/assertions/bodies/a44.ts +23 -0
- package/src/assertions/bodies/a45.ts +172 -0
- package/src/assertions/bodies/a46.ts +224 -0
- package/src/assertions/bodies/a47.ts +126 -0
- package/src/assertions/bodies/a48.ts +83 -0
- package/src/assertions/bodies/a49.ts +81 -0
- package/src/assertions/bodies/a50.ts +97 -0
- package/src/assertions/constraint_words.ts +169 -0
- package/src/assertions/emitted/index.ts +148 -0
- package/src/assertions/facts/animated_bones.ts +30 -0
- package/src/assertions/facts/animation_durations.ts +37 -0
- package/src/assertions/facts/atlas_pages.ts +19 -0
- package/src/assertions/facts/atlas_regions.ts +52 -0
- package/src/assertions/facts/bone_timelines.ts +37 -0
- package/src/assertions/facts/constraint_targets.ts +56 -0
- package/src/assertions/facts/constraints.ts +155 -0
- package/src/assertions/facts/deform_survey.ts +27 -0
- package/src/assertions/facts/event_keys.ts +55 -0
- package/src/assertions/facts/linked_meshes.ts +38 -0
- package/src/assertions/facts/mesh_attachments.ts +100 -0
- package/src/assertions/facts/region_joins.ts +34 -0
- package/src/assertions/facts/sequences.ts +85 -0
- package/src/assertions/facts/skeleton_roster.ts +45 -0
- package/src/assertions/facts/skin_entries.ts +37 -0
- package/src/assertions/facts/skin_members.ts +53 -0
- package/src/assertions/facts/slider_composition.ts +78 -0
- package/src/assertions/facts/slot_colour.ts +43 -0
- package/src/assertions/facts/stage.ts +27 -0
- package/src/assertions/facts/stage_box.ts +65 -0
- package/src/assertions/facts/stepped_poses.ts +74 -0
- package/src/assertions/facts/two_colour.ts +52 -0
- package/src/assertions/facts/vertex_polygons.ts +53 -0
- package/src/assertions/footprints.ts +367 -0
- package/src/assertions/harness.ts +109 -0
- package/src/assertions/inward_advance.ts +58 -0
- package/src/assertions/kinds.ts +105 -0
- package/src/assertions/mesh_kinds.ts +56 -0
- package/src/assertions/model/animated_bones.ts +38 -0
- package/src/assertions/model/animation_durations.ts +57 -0
- package/src/assertions/model/atlas_pages.ts +15 -0
- package/src/assertions/model/atlas_regions.ts +76 -0
- package/src/assertions/model/bone_timelines.ts +58 -0
- package/src/assertions/model/constraint_targets.ts +82 -0
- package/src/assertions/model/constraints.ts +233 -0
- package/src/assertions/model/declared.ts +125 -0
- package/src/assertions/model/deform_survey.ts +24 -0
- package/src/assertions/model/event_keys.ts +45 -0
- package/src/assertions/model/given.ts +45 -0
- package/src/assertions/model/index.ts +398 -0
- package/src/assertions/model/linked_meshes.ts +24 -0
- package/src/assertions/model/mesh_attachments.ts +119 -0
- package/src/assertions/model/parse.ts +146 -0
- package/src/assertions/model/region_joins.ts +67 -0
- package/src/assertions/model/runtime_timelines.ts +78 -0
- package/src/assertions/model/sequences.ts +157 -0
- package/src/assertions/model/skeleton_roster.ts +23 -0
- package/src/assertions/model/skin_entries.ts +69 -0
- package/src/assertions/model/skin_members.ts +64 -0
- package/src/assertions/model/slider_composition.ts +193 -0
- package/src/assertions/model/slot_colour.ts +81 -0
- package/src/assertions/model/stage.ts +28 -0
- package/src/assertions/model/stage_box.ts +51 -0
- package/src/assertions/model/stepped_poses.ts +105 -0
- package/src/assertions/model/two_colour.ts +61 -0
- package/src/assertions/model/vertex_polygons.ts +72 -0
- package/src/assertions/reasons.ts +129 -0
- package/src/assertions/region_lookups.ts +61 -0
- package/src/assertions/report.ts +189 -0
- package/src/assertions/values.ts +39 -0
- package/src/atlas.ts +2870 -0
- package/src/ballot.ts +866 -0
- package/src/bonedist.ts +643 -0
- package/src/chainfit.ts +2752 -0
- package/src/chains.ts +170 -0
- package/src/check.ts +4303 -0
- package/src/checkpics.ts +295 -0
- package/src/cli/core_commands.ts +1627 -0
- package/src/cli/repack.ts +414 -0
- package/src/cli/shared.ts +2776 -0
- package/src/cli/spine_commands.ts +820 -0
- package/src/compile.ts +9414 -0
- package/src/core/additive.ts +458 -0
- package/src/core/animation.ts +1050 -0
- package/src/core/clipping.ts +696 -0
- package/src/core/constraints.ts +1876 -0
- package/src/core/constraints_path.ts +964 -0
- package/src/core/constraints_physics.ts +881 -0
- package/src/core/constraints_slider.ts +635 -0
- package/src/core/deform.ts +613 -0
- package/src/core/draw_order.ts +125 -0
- package/src/core/events.ts +135 -0
- package/src/core/hooks.ts +249 -0
- package/src/core/index.ts +1400 -0
- package/src/core/raw.ts +739 -0
- package/src/core/skins.ts +129 -0
- package/src/core/uvs.ts +469 -0
- package/src/core/vertices.ts +490 -0
- package/src/core/walk.ts +197 -0
- package/src/core/world.ts +289 -0
- package/src/correspondence.ts +15 -0
- package/src/deformbuild.ts +60 -0
- package/src/deformgen.ts +630 -0
- package/src/deformmeasure.ts +732 -0
- package/src/deformreport.ts +373 -0
- package/src/deformstructure.ts +386 -0
- package/src/deformsurvey.ts +2162 -0
- package/src/depth.ts +784 -0
- package/src/diff.ts +2252 -0
- package/src/emit.ts +134 -0
- package/src/emit_spine.ts +854 -0
- package/src/errors.ts +53 -0
- package/src/framing.ts +819 -0
- package/src/generation.ts +139 -0
- package/src/ingest.ts +2293 -0
- package/src/json-position.ts +253 -0
- package/src/keyorder.ts +587 -0
- package/src/keys.ts +486 -0
- package/src/ladder.ts +121 -0
- package/src/mesh.ts +2382 -0
- package/src/meshcompare.ts +1191 -0
- package/src/meshquality.ts +2051 -0
- package/src/meshrasters.ts +944 -0
- package/src/meshreduce.ts +1444 -0
- package/src/model.ts +1245 -0
- package/src/motion.ts +809 -0
- package/src/nonfinite.ts +54 -0
- package/src/package_meta.ts +48 -0
- package/src/png.ts +297 -0
- package/src/pose.ts +2324 -0
- package/src/preview.ts +434 -0
- package/src/region_joins.ts +54 -0
- package/src/render.ts +1013 -0
- package/src/render_core.ts +871 -0
- package/src/render_shared.ts +2958 -0
- package/src/repack.ts +495 -0
- package/src/rig.ts +2941 -0
- package/src/slots.ts +892 -0
- package/src/spine_side.ts +138 -0
- package/src/timelines.ts +837 -0
- package/src/trackgen.ts +364 -0
- package/src/transform.ts +310 -0
- package/src/types.ts +1797 -0
- package/src/validate.ts +3875 -0
- package/tools/contact.ts +126 -0
- package/tools/editor_roundtrip.ts +1641 -0
- package/tools/font5x7.ts +101 -0
- package/tools/measure_contact_depth.ts +105 -0
- package/tools/plate.ts +508 -0
- package/tools/png_probe.mjs +72 -0
package/src/pose.ts
ADDED
|
@@ -0,0 +1,2324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* pose — where each loose part sits in one pose frame.
|
|
3
|
+
*
|
|
4
|
+
* ⭐ This is an ENTRY instrument, and the distinction decides every choice below.
|
|
5
|
+
* The user hands over a condition — "here is the pose I want" as a picture — and
|
|
6
|
+
* an agent has to turn it into spec coordinates. Nothing here grades anything:
|
|
7
|
+
* the poses become inputs the spec then states by construction, so no pass bar
|
|
8
|
+
* attaches to any number this file produces. The residual exists so the agent
|
|
9
|
+
* knows **how much to trust** a placement and **where two answers are equally
|
|
10
|
+
* good**, which is a different job from scoring and needs the opposite defaults.
|
|
11
|
+
*
|
|
12
|
+
* What that means in practice: a refusal names the part and the reason and still
|
|
13
|
+
* prints the best it found, an ambiguity reports BOTH optima rather than picking,
|
|
14
|
+
* and a part whose rotation genuinely does not matter is reported as having a free
|
|
15
|
+
* degree of freedom instead of a bad one.
|
|
16
|
+
*
|
|
17
|
+
* 🔍 The estimator. For every part PNG it searches the rigid family
|
|
18
|
+
* (translation, one rotation, one uniform scale) for the placement whose pixels
|
|
19
|
+
* best explain the frame's pixels **inside the part's own alpha footprint**. The
|
|
20
|
+
* objective is an alpha-weighted mean absolute colour error in 0..1:
|
|
21
|
+
*
|
|
22
|
+
* err(part pixel) = material · |partRGB − frameRGB| / 255 + (1 − material)
|
|
23
|
+
*
|
|
24
|
+
* where `material` is how much of the frame is *not* background there — so a part
|
|
25
|
+
* pixel hanging over the background, or off the canvas entirely, costs the maximum
|
|
26
|
+
* 1 rather than whatever colour distance the background happens to give.
|
|
27
|
+
* Normalising by the part's own alpha weight is what makes residuals comparable
|
|
28
|
+
* between a thumb and a torso.
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ Measuring on the part's own footprint is also the only occlusion robustness
|
|
31
|
+
* here, and it is deliberately not a solver. A part drawn *behind* another in the
|
|
32
|
+
* frame has the occluder's pixels where its own should be, so its residual rises
|
|
33
|
+
* even at the correct placement. `unexplained` separates the two readings: a low
|
|
34
|
+
* residual is a confident placement, a middling residual with a high `unexplained`
|
|
35
|
+
* is usually a correct placement seen through something else. Weigh accordingly;
|
|
36
|
+
* do not read either as a verdict.
|
|
37
|
+
*
|
|
38
|
+
* Coordinates are the frame's own: **frame pixels, y down, origin top-left**, the
|
|
39
|
+
* same convention a cut manifest uses. `rotationDeg` is screen degrees — positive
|
|
40
|
+
* turns clockwise on screen — so `screenToSpineDegrees` in `src/transform.ts` is
|
|
41
|
+
* the one conversion to Spine's y-up CCW world, and `cropToSpineY` the other.
|
|
42
|
+
*
|
|
43
|
+
* ⭐ **This file owns the objective, and `src/chainfit.ts` borrows it rather than
|
|
44
|
+
* holding a second opinion about it.** The pixel machinery below — the background
|
|
45
|
+
* read, the material plate, the alpha-weighted halving, the sample sets and the
|
|
46
|
+
* two error functions — is exported for exactly one caller, whose whole claim is
|
|
47
|
+
* that it is *this* estimator with the occluders taken out of the denominator. Two
|
|
48
|
+
* implementations of "how well does this part explain these pixels" would make the
|
|
49
|
+
* two instruments' residuals incomparable, which is the one thing a caller reading
|
|
50
|
+
* both of them needs them not to be. What `chainfit` does NOT borrow is the search:
|
|
51
|
+
* it has the candidate rig, so it searches one degree of freedom where this file
|
|
52
|
+
* searches four.
|
|
53
|
+
*/
|
|
54
|
+
import { existsSync, readdirSync, statSync } from 'node:fs';
|
|
55
|
+
import { basename, join, resolve } from 'node:path';
|
|
56
|
+
import { Plate, readPlate } from '../tools/plate.ts';
|
|
57
|
+
// The rasteriser's sampler rather than a second one written here: how a pixel is
|
|
58
|
+
// read between two pixel centres is exactly the kind of thing two
|
|
59
|
+
// implementations drift on.
|
|
60
|
+
//
|
|
61
|
+
// ⭐ `bilinear` — the PREMULTIPLIED tap — and the fourth channel is what makes
|
|
62
|
+
// that read oddly at first. `materialPlate` hands this file the frame's own RGB
|
|
63
|
+
// with alpha rewritten to mean "how much material is here", so the weighting is
|
|
64
|
+
// not a colour-space correction here: a texel with no material has no material
|
|
65
|
+
// COLOUR either — it carries the background's — so it must get a vote in the
|
|
66
|
+
// coverage and none in the colour. That is exactly what premultiplying by the
|
|
67
|
+
// fourth channel does, and interpolating the colours channel by channel is what
|
|
68
|
+
// mixed the ground into the material along every silhouette (issue #306, the
|
|
69
|
+
// same defect class as #292 in the instrument that prices placements).
|
|
70
|
+
//
|
|
71
|
+
// Both tap paths in this file are colour-against-colour, and neither reads a
|
|
72
|
+
// mask as if it were a colour:
|
|
73
|
+
// • the search and `measure` sample the MATERIAL PLATE — mask fourth channel,
|
|
74
|
+
// the frame's own RGB;
|
|
75
|
+
// • `rotationSelfSimilarity` samples the PART plate itself, whose fourth
|
|
76
|
+
// channel is the art's own straight alpha — the #292 case verbatim.
|
|
77
|
+
// `bilinear` computes its fourth channel with the unchanged `lerpTap`, so the
|
|
78
|
+
// coverage term `errBilinear` gates on is bit-identical to what the
|
|
79
|
+
// channel-independent tap returned; only the colour moves, and only where a tap
|
|
80
|
+
// straddles an edge. #301 deliberately left this call on the old arithmetic to
|
|
81
|
+
// hold the fitting numbers still while the from-zero run 2 was in flight; #306
|
|
82
|
+
// moved it, with the re-baseline of every figure that quoted a residual.
|
|
83
|
+
import { bilinear } from './render_shared.ts';
|
|
84
|
+
|
|
85
|
+
export class PoseError extends Error {}
|
|
86
|
+
|
|
87
|
+
/** The `spec` field every report carries, so a consumer can refuse a future shape. */
|
|
88
|
+
export const POSE_SPEC = 'rigc-pose/1';
|
|
89
|
+
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
// the constants the search is made of — every one of them is reported
|
|
92
|
+
// ---------------------------------------------------------------------------
|
|
93
|
+
//
|
|
94
|
+
// They are exported because a number that steers a refusal has to be quotable:
|
|
95
|
+
// the docs cite them, the selftest states its tolerances against them, and the
|
|
96
|
+
// report repeats the ones a caller can move.
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Longest side, in pixels, the coarse scan would like to reduce the FRAME to.
|
|
100
|
+
*
|
|
101
|
+
* A ceiling on the pyramid, not the level the scan runs at — `COARSE_PART_SPAN`
|
|
102
|
+
* can overrule it downward, because a level that has reduced the part to three
|
|
103
|
+
* pixels tells nobody anything about where the part is.
|
|
104
|
+
*/
|
|
105
|
+
export const COARSE_LONG_SIDE = 40;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Pixels the PART must still span at the level the coarse scan runs at.
|
|
109
|
+
*
|
|
110
|
+
* ⚠️ The other half of choosing that level, and the half a frame-only rule gets
|
|
111
|
+
* wrong. Measured: on a 1600x1200 frame the frame rule alone picks a 64x
|
|
112
|
+
* reduction, at which a 120x180 part is 2x3 pixels sampled six times — no signal
|
|
113
|
+
* at all, and eight such parts came back with the wrong scale and a position out
|
|
114
|
+
* by seventeen pixels. The coarse level is therefore the coarser of "the frame
|
|
115
|
+
* fits in COARSE_LONG_SIDE" and "the part still spans this much".
|
|
116
|
+
*/
|
|
117
|
+
export const COARSE_PART_SPAN = 10;
|
|
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
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Coarse anchor positions are stepped at a fraction of the part's own size.
|
|
137
|
+
*
|
|
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.
|
|
173
|
+
*/
|
|
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;
|
|
188
|
+
|
|
189
|
+
/** How many scale rungs one octave gets in the coarse ladder. */
|
|
190
|
+
export const SCALE_STEPS_PER_OCTAVE = 3;
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The COARSEST step, in degrees, the rotation ladder is allowed to take.
|
|
194
|
+
*
|
|
195
|
+
* ⚠️ A ceiling on the step rather than the step itself, and the distinction is
|
|
196
|
+
* the whole of issue #719. Read as "the step", a window narrower than it prints
|
|
197
|
+
* a resolution the search never had: `--rotation -5,5` reported `step 15°` over
|
|
198
|
+
* a ten-degree window. The ladder therefore divides the window into whole steps
|
|
199
|
+
* no coarser than this — the same shape `scaleLadder` has always had for
|
|
200
|
+
* octaves — and the report states the step that division produced.
|
|
201
|
+
*/
|
|
202
|
+
export const COARSE_ROTATION_STEP = 15;
|
|
203
|
+
|
|
204
|
+
/** Default scale window, as frame pixels per part pixel. */
|
|
205
|
+
export const DEFAULT_SCALE_MIN = 0.5;
|
|
206
|
+
export const DEFAULT_SCALE_MAX = 2;
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Above this residual the placement is refused by name rather than reported flat.
|
|
210
|
+
*
|
|
211
|
+
* ⚠️ Not a pass bar. It is where "this part is somewhere in this picture" stops
|
|
212
|
+
* being a claim worth making — a foreign part scores far above it and a real one
|
|
213
|
+
* far below, and the report carries the number either way so a caller who
|
|
214
|
+
* disagrees can read past the refusal.
|
|
215
|
+
*/
|
|
216
|
+
export const DEFAULT_MAX_RESIDUAL = 0.25;
|
|
217
|
+
|
|
218
|
+
/** Two optima this close are reported as both, never as one. Absolute, then relative to the best. */
|
|
219
|
+
export const AMBIGUITY_ABSOLUTE = 0.01;
|
|
220
|
+
export const AMBIGUITY_RELATIVE = 0.2;
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Max self-residual under rotation, relative to the identity, for a part to be
|
|
224
|
+
* called rotation-free.
|
|
225
|
+
*
|
|
226
|
+
* The gap it sits in is wide, which is why one number can hold it: on the
|
|
227
|
+
* selftest's own art a smooth 32px ball reads 0.014 and the least distinctive
|
|
228
|
+
* non-round part in the set — a two-tone head — reads 0.307. Anything with a
|
|
229
|
+
* corner, a silhouette or an off-centre feature is an order of magnitude clear
|
|
230
|
+
* of this line.
|
|
231
|
+
*/
|
|
232
|
+
export const ROTATION_FREE_TOLERANCE = 0.04;
|
|
233
|
+
|
|
234
|
+
/** Per-pixel error above which a pixel counts toward `unexplained`. */
|
|
235
|
+
export const UNEXPLAINED_TOLERANCE = 0.15;
|
|
236
|
+
|
|
237
|
+
/** Mean absolute channel distance, 0..255, within which a frame pixel counts as background. */
|
|
238
|
+
export const BACKGROUND_TOLERANCE = 10;
|
|
239
|
+
|
|
240
|
+
/** Share of the frame's border ring one colour must hold before it is called the background. */
|
|
241
|
+
export const BACKGROUND_BORDER_SHARE = 0.6;
|
|
242
|
+
|
|
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;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The factors 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
|
+
* and a quarter of one (issue #886); the better of the two re-fits wins.
|
|
280
|
+
*
|
|
281
|
+
* 🔍 What the pattern search cannot do, and the trace table of issue #877 is
|
|
282
|
+
* how it was seen: every probe moves ONE degree of freedom, and the objective
|
|
283
|
+
* couples scale to position along a diagonal valley — a part shrunk a little
|
|
284
|
+
* fits best a pixel or two off its truth, so from there every single-axis probe
|
|
285
|
+
* is worse and every joint move is better. In 12 of the 14 missed trials the
|
|
286
|
+
* truth's candidate reached full resolution ranked first or second, the
|
|
287
|
+
* full-resolution objective preferred the truth to everything kept, and the
|
|
288
|
+
* polish stopped 2–7 px off it at a scale of 0.64–0.96. Not one polish ever
|
|
289
|
+
* accepted a worse residual — a polish moves only on a strictly lower probe of
|
|
290
|
+
* its own level's objective, and `PO26` holds that — so the rise from one level
|
|
291
|
+
* to the next is a change of objective, never a step taken uphill.
|
|
292
|
+
*
|
|
293
|
+
* ⭐ So the escape re-fits position at the new scale before comparing — the
|
|
294
|
+
* joint move, taken one axis at a time — and a better point restarts the polish
|
|
295
|
+
* there. UP only, and that is derived rather than tuned: the objective charges a
|
|
296
|
+
* part pixel that lands off the figure and charges nothing for figure left
|
|
297
|
+
* uncovered, so a shrunk placement is the cheap error the reduced levels make —
|
|
298
|
+
* every stuck point in the table sat below scale 1, none above.
|
|
299
|
+
*
|
|
300
|
+
* 📏 Measured on the grid with `REFINE_CANDIDATES` at fifteen: 227 → 237 trials
|
|
301
|
+
* found, 12 gained, 2 lost, search misses 14 → 8, for 43% more refinement
|
|
302
|
+
* samples (the coarse pass is untouched). Both ways at twelve found 236 (12
|
|
303
|
+
* gained, 3 lost) for 38% more against up only's 21%; a second step each way,
|
|
304
|
+
* 237 (15, 5) for 76%. ⚠️ The two it loses are one shape, a scale twin at the
|
|
305
|
+
* same spot: the blurred 64 px mouth (walk) keeps its best at 0.9 px and now
|
|
306
|
+
* reports a second placement 1.8 px off at scale 0.825 within the ambiguity
|
|
307
|
+
* margin, and the blurred 24 px front shin (walk) now places AT its truth
|
|
308
|
+
* (0.03 px, 0.00989) where it placed 0.57 px off at 0.0191 — the old answer is
|
|
309
|
+
* still reported beside it, 0.0094 above, inside the 0.01 absolute margin.
|
|
310
|
+
*
|
|
311
|
+
* 🔍 The quarter rung is issue #886, and the fixed-scale profile is what
|
|
312
|
+
* named it. MOTION.md §6's post (14x96, pose A, `--scale 0.85,1.2`) came back
|
|
313
|
+
* at scale 0.917 and residual 0.0649 where the page had recorded 0.975 at
|
|
314
|
+
* 0.0589 — the old placement still scores 0.0589 on this objective, so the
|
|
315
|
+
* search missed it. The trace: the coarse cell nearest the truth (2.4 px off
|
|
316
|
+
* it, at the 2x level) kept a half-turned post, its polish ended 6.7 px from
|
|
317
|
+
* the truth, the full-resolution rotation re-grid set it upright there, and
|
|
318
|
+
* the polish climbed the scale–position valley to 0.917.
|
|
319
|
+
* The half-rung escape then tried 1.029: best position there 0.0980, worse
|
|
320
|
+
* than 0.0649, so it declined — while the best position at every scale
|
|
321
|
+
* between 0.94 and 1.00 scores 0.0619–0.0589. A part whose material fills its
|
|
322
|
+
* image pays for every pixel pushed past the figure, so its profile above the
|
|
323
|
+
* truth is a cliff, and half a rung stepped over the basin onto it. A quarter
|
|
324
|
+
* rung lands at 0.971 (0.0593, 0.1 px off the page's placement).
|
|
325
|
+
*
|
|
326
|
+
* 📏 Both, not the quarter alone, and measured on the grid rather than argued:
|
|
327
|
+
* the quarter alone found 234 (3 gained, 6 lost — the native arms #877 was
|
|
328
|
+
* made for need the half); half then quarter, the quarter tried only when the
|
|
329
|
+
* half declines, 237 (1, 1); both, the better kept, 238 (2 gained, 1 lost) and
|
|
330
|
+
* misses 8 → 6, for 16% more user CPU over the grid. The one it loses is a
|
|
331
|
+
* margin reading of the kind named above: the blurred 32 px front shin (walk)
|
|
332
|
+
* now reports its truth as its BEST (0.07 px, 0.00968) with the old 0.51 px
|
|
333
|
+
* answer 0.0045 above it, inside the 0.01 absolute margin.
|
|
334
|
+
*/
|
|
335
|
+
const POLISH_SCALE_ESCAPES = [2 ** (1 / (4 * SCALE_STEPS_PER_OCTAVE)), 2 ** (1 / (2 * SCALE_STEPS_PER_OCTAVE))];
|
|
336
|
+
|
|
337
|
+
/** Sample budgets per stage. The reported residual uses every pixel regardless. */
|
|
338
|
+
const COARSE_SAMPLES = 96;
|
|
339
|
+
const REFINE_SAMPLES = 384;
|
|
340
|
+
const POLISH_SAMPLES = 2048;
|
|
341
|
+
|
|
342
|
+
/** Alternates beyond this many are not printed; the count is still stated. */
|
|
343
|
+
const MAX_ALTERNATES = 3;
|
|
344
|
+
|
|
345
|
+
const DEG = Math.PI / 180;
|
|
346
|
+
|
|
347
|
+
/**
|
|
348
|
+
* The detail below which `pose` has been MEASURED to almost never place a part
|
|
349
|
+
* — the floor a refusal or an ambiguity is read against (issue #857).
|
|
350
|
+
*
|
|
351
|
+
* 📏 Measured, not chosen: `tools/pose_floor.ts` builds the grid and derives
|
|
352
|
+
* `detail` (`deriveFloor`: the largest rung of a fixed ladder under which at
|
|
353
|
+
* most a tenth of the grid's trials were found), and `docs/AUTHORING.md` §11.5
|
|
354
|
+
* carries the grid, the method and what it rejected. A part is BELOW the floor
|
|
355
|
+
* when its `detail` is under `detail`, or its longest side, in its own pixels,
|
|
356
|
+
* under `span` — the grid cut its parts at scale 1, so its sizes are both.
|
|
357
|
+
*
|
|
358
|
+
* ⚠️ Two things this is not. It is not a size found to matter: `span` is the
|
|
359
|
+
* smallest size the grid measured, and over 24–192 px no texture level placed
|
|
360
|
+
* measurably better when larger — a part under it is below the floor because
|
|
361
|
+
* nothing was measured there, not because it was measured to fail. And it is
|
|
362
|
+
* not a line above which `pose` is reliable: over it, on IDEAL cuts (parts cut
|
|
363
|
+
* from the frame's own render, residual at the truth near zero), `rateAbove`
|
|
364
|
+
* of the trials were found. A generated cut also disagrees with its picture,
|
|
365
|
+
* which can only make that lower. So "above the floor" means "plainness does
|
|
366
|
+
* not explain this refusal", which is the question the issue asked, and never
|
|
367
|
+
* "this part will place".
|
|
368
|
+
*/
|
|
369
|
+
export const POSE_FLOOR = {
|
|
370
|
+
material: 'examples/spineboy, cut from its own render (tools/pose_floor.ts)',
|
|
371
|
+
withinPx: 2,
|
|
372
|
+
span: 24,
|
|
373
|
+
detail: 0.5,
|
|
374
|
+
/** Found share of the grid's trials under `detail`, and over it — both measured by `deriveFloor`. */
|
|
375
|
+
rateBelow: 0.066,
|
|
376
|
+
rateAbove: 0.772,
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
/** Which side of `POSE_FLOOR` a part is on, from its own longest side in part pixels and its detail. */
|
|
380
|
+
export function floorSide(side: number, detail: number): 'below' | 'above' {
|
|
381
|
+
return side >= POSE_FLOOR.span && detail >= POSE_FLOOR.detail ? 'above' : 'below';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** The floor as one clause, for the sentences that cite it. */
|
|
385
|
+
export function floorClause(): string {
|
|
386
|
+
return `detail ${POSE_FLOOR.detail}, measured from ${POSE_FLOOR.span} px up`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* The sentence that tells "the part is wrong" from "the part is too small or too
|
|
391
|
+
* plain for `pose`", with every figure it rests on (issue #857).
|
|
392
|
+
*
|
|
393
|
+
* ⭐ Exported for the reason `windowEdgeNote` is: the guide quotes it, and a
|
|
394
|
+
* message and the document teaching it must be built from one place.
|
|
395
|
+
*/
|
|
396
|
+
export function legibilityReading(l: Omit<PoseLegibility, 'reading'>, verdict: 'refused' | 'ambiguous'): string {
|
|
397
|
+
const figures =
|
|
398
|
+
`part is ${l.width}x${l.height} px (span ${l.span} frame px, opaque ${l.opaqueShare}) with texture ${l.texture}, ` +
|
|
399
|
+
`detail ${l.detail}; ` +
|
|
400
|
+
`${l.candidates} candidate(s), best ${l.best.toFixed(4)}` +
|
|
401
|
+
(l.next === null || l.spread === null ? '' : `, next ${l.next.toFixed(4)}, spread ${l.spread.toFixed(4)}`);
|
|
402
|
+
if (l.floor === 'below') {
|
|
403
|
+
const why =
|
|
404
|
+
Math.max(l.width, l.height) < POSE_FLOOR.span
|
|
405
|
+
? `smaller than the smallest size the floor was measured at, so nothing measured says pose can ` +
|
|
406
|
+
`${verdict === 'refused' ? 'place' : 'separate'} it`
|
|
407
|
+
: `pose placed ${Math.round(POSE_FLOOR.rateBelow * 100)}% of the measured parts this plain, so this part is too ` +
|
|
408
|
+
`plain for pose to ${verdict === 'refused' ? 'place' : 'tell its placements apart'}`;
|
|
409
|
+
return (
|
|
410
|
+
`${figures} — below the measured floor (AUTHORING §11.5: ${floorClause()}): ${why}; that says nothing about ` +
|
|
411
|
+
'whether the cut is right'
|
|
412
|
+
);
|
|
413
|
+
}
|
|
414
|
+
// ⚠️ "Above the floor" is not "the search found the right hill". Measured on
|
|
415
|
+
// the floor's own material, a part above it came back ambiguous between two
|
|
416
|
+
// equally WRONG placements (residual 0.148 each, 173 px off) while the truth
|
|
417
|
+
// scored 0.035 — a basin the coarse pass never sent down. Issue #865 fixed
|
|
418
|
+
// that one (the coarse grid had stepped over it), and the reading still
|
|
419
|
+
// occurs: on the grid after it, 10 ambiguous trials above the floor had a
|
|
420
|
+
// truth scoring better than both answers — native 24 px gun and shin, 48 px
|
|
421
|
+
// arm, 96 px goggles twice, and blurred gun, goggles and rear shin from 48 px
|
|
422
|
+
// up. Nine of the ten sat 2–6 px from the truth rather than on another hill.
|
|
423
|
+
// Issue #877's polish took that to 6 — native 24 px gun, 96 px goggles, and
|
|
424
|
+
// blurred goggles and gun from 128 px and rear shin at 48 px — five of them
|
|
425
|
+
// 2.1–2.7 px off, the rear shin 19 px. So the sentence names that reading
|
|
426
|
+
// too, with the one remedy that separates it: a window.
|
|
427
|
+
return (
|
|
428
|
+
`${figures} — above the measured floor (AUTHORING §11.5: ${floorClause()}), so size and texture do not explain this; ` +
|
|
429
|
+
(verdict === 'refused'
|
|
430
|
+
? 'the cut (or the search window) is the suspect'
|
|
431
|
+
: 'either the frame holds more than one place this part fits, or every candidate missed the true one — ' +
|
|
432
|
+
'a narrower --scale or --rotation window around what you know of the part tells the two apart')
|
|
433
|
+
);
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// the report
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
export interface PoseBackground {
|
|
441
|
+
kind: 'transparent' | 'colour' | 'unknown';
|
|
442
|
+
/** The background colour, when there is one. */
|
|
443
|
+
colour: [number, number, number] | null;
|
|
444
|
+
/** Share of the one-pixel border ring that agreed with the verdict, 0..1. */
|
|
445
|
+
borderShare: number;
|
|
446
|
+
/** Share of the frame that counts as material, 0..1. */
|
|
447
|
+
materialShare: number;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/** One rigid placement of one part, in frame pixels, y down, origin top-left. */
|
|
451
|
+
export interface PosePlacement {
|
|
452
|
+
/** Where the part image's own centre — `(width/2, height/2)` — lands. */
|
|
453
|
+
x: number;
|
|
454
|
+
y: number;
|
|
455
|
+
/** Screen degrees: positive turns clockwise on screen. `screenToSpineDegrees` converts. */
|
|
456
|
+
rotationDeg: number;
|
|
457
|
+
/** Uniform, as frame pixels per part pixel. */
|
|
458
|
+
scale: number;
|
|
459
|
+
/** Alpha-weighted mean absolute error over the part's own footprint, 0..1. Lower is better explained. */
|
|
460
|
+
residual: number;
|
|
461
|
+
/** Share of the part's alpha weight whose per-pixel error clears `UNEXPLAINED_TOLERANCE`. Occlusion shows up here. */
|
|
462
|
+
unexplained: number;
|
|
463
|
+
/** Share of the part's alpha weight that lands outside the frame canvas. */
|
|
464
|
+
offCanvas: number;
|
|
465
|
+
/** Frame pixels of material this placement accounts for — the tie-break between equal residuals. */
|
|
466
|
+
footprint: number;
|
|
467
|
+
/** Axis-aligned box the placed part's material occupies, frame pixels. */
|
|
468
|
+
bbox: { x: number; y: number; width: number; height: number };
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
export type PoseRefusalReason = 'empty-part' | 'larger-than-canvas' | 'no-match';
|
|
472
|
+
|
|
473
|
+
export interface PoseRefusal {
|
|
474
|
+
reason: PoseRefusalReason;
|
|
475
|
+
detail: string;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* One wall of the search window a reported placement stands ON.
|
|
480
|
+
*
|
|
481
|
+
* ⭐ A fact about the search, not about the picture: the refinement is clamped
|
|
482
|
+
* to the window, so a value held by a wall is the bound itself — which is what
|
|
483
|
+
* lets "on" be told from "near". A placement that settled inside stops short of
|
|
484
|
+
* the wall by at least the polish's last step; one that was held by it sits on
|
|
485
|
+
* it exactly (issue #737).
|
|
486
|
+
*/
|
|
487
|
+
export interface PoseWall {
|
|
488
|
+
axis: 'scale' | 'rotation';
|
|
489
|
+
edge: 'floor' | 'ceiling';
|
|
490
|
+
/** The window as its flag spells it, `min,max` — `--scale 0.5,2`'s is `"0.5,2"`. */
|
|
491
|
+
window: string;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
export interface PosePart {
|
|
495
|
+
/** The PNG's file name — how the report names the part everywhere. */
|
|
496
|
+
part: string;
|
|
497
|
+
path: string;
|
|
498
|
+
width: number;
|
|
499
|
+
height: number;
|
|
500
|
+
/**
|
|
501
|
+
* The walls of the search window `placement` stands on — empty when it settled
|
|
502
|
+
* inside the window, and always empty when there is no placement.
|
|
503
|
+
*
|
|
504
|
+
* 🔒 Set whatever the verdict, and read by one function (`placementWalls`) for
|
|
505
|
+
* both: a `no-match` quotes these in its `refusal.detail`, an accepted part
|
|
506
|
+
* carries them on its console line. Until issue #737 only the refusal said so,
|
|
507
|
+
* and an accepted `scale=2.000` under `--scale 0.5,2` over a part whose truth
|
|
508
|
+
* was 2.30 carried no mark at all.
|
|
509
|
+
*
|
|
510
|
+
* ⚠️ A wall here is not a claim about which side the truth is on. Measured over
|
|
511
|
+
* the rendered corpus, a floor holds two kinds of answer — a truth below the
|
|
512
|
+
* window, and a part shrunk into the region it came from while its truth sits
|
|
513
|
+
* inside the window — and nothing in one frame separates them. What is certain
|
|
514
|
+
* is that the value is where the search was held, not where it came to rest.
|
|
515
|
+
*/
|
|
516
|
+
walls: PoseWall[];
|
|
517
|
+
/**
|
|
518
|
+
* Why this part's answer should not be taken at face value, or `null`.
|
|
519
|
+
*
|
|
520
|
+
* ⚠️ `placement` is still filled in under a `no-match` refusal, on purpose: a
|
|
521
|
+
* refusal here names why you should not trust a number, it does not hide it.
|
|
522
|
+
* `empty-part` and `larger-than-canvas` leave it `null` because nothing was
|
|
523
|
+
* searched.
|
|
524
|
+
*/
|
|
525
|
+
refusal: PoseRefusal | null;
|
|
526
|
+
placement: PosePlacement | null;
|
|
527
|
+
/** Other optima worth reporting, best first. Non-empty means the answer was not unique. */
|
|
528
|
+
alternates: PosePlacement[];
|
|
529
|
+
/** True when at least one alternate sits inside the ambiguity margin. */
|
|
530
|
+
ambiguous: boolean;
|
|
531
|
+
/** True when the part is self-similar under rotation, so `rotationDeg` is yours to choose. */
|
|
532
|
+
rotationFree: boolean;
|
|
533
|
+
/**
|
|
534
|
+
* Worst residual the part scores against itself over eleven rotations, 0..1.
|
|
535
|
+
*
|
|
536
|
+
* The number `rotationFree` is a threshold on — reported because "how round is
|
|
537
|
+
* this part" is a spectrum, and a part just over the line is worth knowing about.
|
|
538
|
+
*/
|
|
539
|
+
rotationSelfSimilarity: number;
|
|
540
|
+
/**
|
|
541
|
+
* The grid this part was actually looked for on: the frame reduction the
|
|
542
|
+
* exhaustive pass ran at, its anchor grid, and the step between anchors in
|
|
543
|
+
* those reduced pixels. A coarse grid of a handful of cells is a warning that
|
|
544
|
+
* the part is small relative to the frame and the first pass had little to go on.
|
|
545
|
+
*/
|
|
546
|
+
coarse: { reduction: number; cols: number; rows: number; stride: number } | null;
|
|
547
|
+
/**
|
|
548
|
+
* What the part itself gave the search to work with, and how the search's
|
|
549
|
+
* answers stood against each other — `null` only when nothing was searched
|
|
550
|
+
* (`empty-part`, `larger-than-canvas`). See `PoseLegibility`.
|
|
551
|
+
*/
|
|
552
|
+
legibility: PoseLegibility | null;
|
|
553
|
+
/** Plain-language versions of everything above, in the order they were found. */
|
|
554
|
+
notes: string[];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* The facts that tell "this part is wrong" from "this part is too small or too
|
|
559
|
+
* plain for `pose`" (issue #857).
|
|
560
|
+
*
|
|
561
|
+
* ⭐ Both readings end in the same refusal or the same ambiguity, and nothing in
|
|
562
|
+
* a residual separates them: a correct cut of a plain sleeve and a foreign part
|
|
563
|
+
* can print the same number. What separates them is the part's own evidence —
|
|
564
|
+
* how big it is in the frame and how much pattern it carries — held against a
|
|
565
|
+
* floor measured on parts whose placement was known (`POSE_FLOOR`). So every
|
|
566
|
+
* searched part carries these, whatever its verdict, and a refusal or an
|
|
567
|
+
* ambiguity quotes them in a sentence that says which of the two it is.
|
|
568
|
+
*/
|
|
569
|
+
export interface PoseLegibility {
|
|
570
|
+
/** The part's material box, part pixels — the PNG less its transparent margin. */
|
|
571
|
+
width: number;
|
|
572
|
+
height: number;
|
|
573
|
+
/**
|
|
574
|
+
* Longest side of the material box at the best placement's scale, frame pixels.
|
|
575
|
+
* ⚠️ Not what `floor` is read from — the part's own `width`/`height` are, since
|
|
576
|
+
* this one moves with a placement that may be wrong.
|
|
577
|
+
*/
|
|
578
|
+
span: number;
|
|
579
|
+
/** The part's alpha weight over its material box's area, 0..1. A thin diagonal limb reads low; a filled block reads 1. */
|
|
580
|
+
opaqueShare: number;
|
|
581
|
+
/** Mean colour step between neighbouring material pixels, 0..1 — see `partTexture`. */
|
|
582
|
+
texture: number;
|
|
583
|
+
/**
|
|
584
|
+
* `texture` times the material box's longest side in PART pixels: the colour
|
|
585
|
+
* change a walk along the part's length accumulates. The figure the floor is
|
|
586
|
+
* stated in — see `POSE_FLOOR` for why it and not `texture` alone.
|
|
587
|
+
*/
|
|
588
|
+
detail: number;
|
|
589
|
+
/** Distinct placements the search measured at full resolution, the best included. */
|
|
590
|
+
candidates: number;
|
|
591
|
+
/** The best placement's residual, and the second distinct one's — `null` when there was no second. */
|
|
592
|
+
best: number;
|
|
593
|
+
next: number | null;
|
|
594
|
+
/** `next − best`: how far the search's second answer was from its first. `null` with no second. */
|
|
595
|
+
spread: number | null;
|
|
596
|
+
/**
|
|
597
|
+
* Where the part stands against `POSE_FLOOR` — `below` when its span or its
|
|
598
|
+
* detail is under the floor's. A reading of the part, not of the answer: it is set on accepted
|
|
599
|
+
* parts too, and it is what a refusal's sentence is chosen by.
|
|
600
|
+
*/
|
|
601
|
+
floor: 'below' | 'above';
|
|
602
|
+
/**
|
|
603
|
+
* The sentence a refused or ambiguous part carries, saying which of the two
|
|
604
|
+
* readings its figures support — `null` on a part that was placed. Built by
|
|
605
|
+
* `legibilityReading`, and the console prints the same text.
|
|
606
|
+
*/
|
|
607
|
+
reading: string | null;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export interface PoseSearch {
|
|
611
|
+
scale: { min: number; max: number; steps: number };
|
|
612
|
+
/**
|
|
613
|
+
* The rotation window, and the ladder it produced.
|
|
614
|
+
*
|
|
615
|
+
* 🔒 `stepDeg` is read off `degrees` rather than off `COARSE_ROTATION_STEP`,
|
|
616
|
+
* and `degrees` is the array the coarse pass iterated — so the two cannot say
|
|
617
|
+
* different things about the same run (issue #719). `steps` is how many angles
|
|
618
|
+
* that is, which is `degrees.length`.
|
|
619
|
+
*/
|
|
620
|
+
rotation: { minDeg: number; maxDeg: number; stepDeg: number; steps: number; degrees: number[] };
|
|
621
|
+
/**
|
|
622
|
+
* How the exhaustive first pass was sized. The level it runs at is chosen PER
|
|
623
|
+
* PART — see `PosePart.coarse` — because it depends on how big the part is.
|
|
624
|
+
*/
|
|
625
|
+
coarse: { frameLongSide: number; partSpan: number; strideFraction: number; framePyramid: number };
|
|
626
|
+
maxResidual: number;
|
|
627
|
+
ambiguity: { absolute: number; relative: number };
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
export interface PoseReport {
|
|
631
|
+
spec: string;
|
|
632
|
+
/** The coordinate contract, spelled out in the file rather than assumed. */
|
|
633
|
+
space: string;
|
|
634
|
+
images: string;
|
|
635
|
+
frame: { path: string; width: number; height: number; background: PoseBackground };
|
|
636
|
+
search: PoseSearch;
|
|
637
|
+
/** What the numbers above cannot see. Read before consuming them. */
|
|
638
|
+
caveats: string[];
|
|
639
|
+
parts: PosePart[];
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
export interface PoseOptions {
|
|
643
|
+
/** Directory of loose part PNGs. */
|
|
644
|
+
imagesDir: string;
|
|
645
|
+
/** One pose frame. */
|
|
646
|
+
framePath: string;
|
|
647
|
+
/**
|
|
648
|
+
* The parts to place, when the caller already knows which they are. Default:
|
|
649
|
+
* every `.png` in `imagesDir`, in name order — which is what the CLI does.
|
|
650
|
+
*
|
|
651
|
+
* ⭐ The one thing `src/chainfit.ts` needs from this signature. It holds a
|
|
652
|
+
* candidate rig, so it knows exactly which images are parts and which of the
|
|
653
|
+
* directory's PNGs the figure never draws, and searching the rest would spend
|
|
654
|
+
* the pass and add refusals to read past. A directory is still the CLI's
|
|
655
|
+
* contract — this narrows it, it does not replace it.
|
|
656
|
+
*/
|
|
657
|
+
parts?: string[];
|
|
658
|
+
scale?: { min: number; max: number };
|
|
659
|
+
rotation?: { minDeg: number; maxDeg: number };
|
|
660
|
+
maxResidual?: number;
|
|
661
|
+
/**
|
|
662
|
+
* An instrument's sink, never read by the search itself: when present, every
|
|
663
|
+
* level of every part's refinement is appended to it. See `PoseTrace`.
|
|
664
|
+
*/
|
|
665
|
+
trace?: PoseTrace;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
/** A search candidate as the trace states it: where the part image's centre would land, frame pixels. */
|
|
669
|
+
export interface PoseTraceCandidate {
|
|
670
|
+
x: number;
|
|
671
|
+
y: number;
|
|
672
|
+
rotationDeg: number;
|
|
673
|
+
scale: number;
|
|
674
|
+
/** The level's own sampled objective — not the reported, every-pixel residual. */
|
|
675
|
+
residual: number;
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
/** One accepted move of a polish: the residual it moved to and the steps it moved with. */
|
|
679
|
+
export interface PoseTraceStep {
|
|
680
|
+
residual: number;
|
|
681
|
+
translate: number;
|
|
682
|
+
rotate: number;
|
|
683
|
+
scale: number;
|
|
684
|
+
/** The accepted probe's scale or rotation was held by its window rather than taken as stepped. */
|
|
685
|
+
clamped: boolean;
|
|
686
|
+
/** A restart from a converged point to a different scale, rather than a step of the pattern. */
|
|
687
|
+
escape: boolean;
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
export interface PoseTraceLevel {
|
|
691
|
+
part: string;
|
|
692
|
+
/** Pyramid index; 0 is full resolution. */
|
|
693
|
+
level: number;
|
|
694
|
+
reduction: number;
|
|
695
|
+
/** How many of the candidates handed down this level took, before any rotation branching. */
|
|
696
|
+
keep: number;
|
|
697
|
+
/** The seeds this level polished, in the order they were ranked going in. */
|
|
698
|
+
seeds: PoseTraceCandidate[];
|
|
699
|
+
/** Per seed, this level's objective at the seed — where its polish started from. */
|
|
700
|
+
starts: number[];
|
|
701
|
+
/** Per seed, the moves its polish accepted, in order. */
|
|
702
|
+
paths: PoseTraceStep[][];
|
|
703
|
+
/** Per seed, where its polish ended. */
|
|
704
|
+
polished: PoseTraceCandidate[];
|
|
705
|
+
/** The candidates this level hands on, ranked and deduplicated. */
|
|
706
|
+
out: PoseTraceCandidate[];
|
|
707
|
+
/** This level's objective at `PoseTrace.probe`, when one was given. */
|
|
708
|
+
probeResidual: number | null;
|
|
709
|
+
/**
|
|
710
|
+
* At the coarse level only, and `null` on every other (issue #892): the
|
|
711
|
+
* rotation ladder the coarse field scanned, and per seed the field's own
|
|
712
|
+
* objective at that seed's anchor cell and scale for every rung of it, in
|
|
713
|
+
* ladder order — the numbers the field picked the seed's one rotation from.
|
|
714
|
+
* Computed with the field's arithmetic, so the rung a seed carries reads the
|
|
715
|
+
* least value of its row.
|
|
716
|
+
*/
|
|
717
|
+
coarseRotations: { ladder: number[]; residuals: number[][] } | null;
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
/**
|
|
721
|
+
* The refinement written down level by level, for an instrument that knows
|
|
722
|
+
* where the truth is and needs to say where the search left it (issue #877).
|
|
723
|
+
* Pure bookkeeping: a search with a trace returns the same report as one without.
|
|
724
|
+
*/
|
|
725
|
+
export interface PoseTrace {
|
|
726
|
+
/** A placement, in the report's own space, to score at every level alongside the candidates. */
|
|
727
|
+
probe?: { x: number; y: number; rotationDeg: number; scale: number };
|
|
728
|
+
levels: PoseTraceLevel[];
|
|
729
|
+
/** Per part, every candidate that reached the report, with its every-pixel residual, best first. */
|
|
730
|
+
measured: { part: string; candidates: PoseTraceCandidate[] }[];
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
// ---------------------------------------------------------------------------
|
|
734
|
+
// pixels
|
|
735
|
+
// ---------------------------------------------------------------------------
|
|
736
|
+
|
|
737
|
+
/** One rung of a plate pyramid, flattened for the inner loops. */
|
|
738
|
+
export interface Level {
|
|
739
|
+
data: Uint8Array;
|
|
740
|
+
width: number;
|
|
741
|
+
height: number;
|
|
742
|
+
/** Full-resolution pixels per pixel of this level. */
|
|
743
|
+
reduction: number;
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
export function levelOf(plate: Plate, reduction: number): Level {
|
|
747
|
+
return { data: plate.data, width: plate.width, height: plate.height, reduction };
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
/**
|
|
751
|
+
* Box-filter one plate down by two.
|
|
752
|
+
*
|
|
753
|
+
* RGB is averaged **weighted by alpha** and alpha plainly: averaging colour
|
|
754
|
+
* straight would drag every edge pixel toward whatever the transparent
|
|
755
|
+
* neighbour happens to store, which for a cut-out part is usually black.
|
|
756
|
+
*/
|
|
757
|
+
export function halvePlate(src: Plate): Plate {
|
|
758
|
+
const w = Math.max(1, src.width >> 1);
|
|
759
|
+
const h = Math.max(1, src.height >> 1);
|
|
760
|
+
const out = new Plate(w, h);
|
|
761
|
+
for (let y = 0; y < h; y++) {
|
|
762
|
+
for (let x = 0; x < w; x++) {
|
|
763
|
+
let sa = 0;
|
|
764
|
+
let sr = 0;
|
|
765
|
+
let sg = 0;
|
|
766
|
+
let sb = 0;
|
|
767
|
+
let n = 0;
|
|
768
|
+
for (let dy = 0; dy < 2; dy++) {
|
|
769
|
+
const sy = Math.min(src.height - 1, y * 2 + dy);
|
|
770
|
+
for (let dx = 0; dx < 2; dx++) {
|
|
771
|
+
const sx = Math.min(src.width - 1, x * 2 + dx);
|
|
772
|
+
const i = (sy * src.width + sx) * 4;
|
|
773
|
+
const a = src.data[i + 3];
|
|
774
|
+
sa += a;
|
|
775
|
+
sr += src.data[i] * a;
|
|
776
|
+
sg += src.data[i + 1] * a;
|
|
777
|
+
sb += src.data[i + 2] * a;
|
|
778
|
+
n++;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
const o = (y * w + x) * 4;
|
|
782
|
+
out.data[o + 3] = Math.round(sa / n);
|
|
783
|
+
if (sa > 0) {
|
|
784
|
+
out.data[o] = Math.round(sr / sa);
|
|
785
|
+
out.data[o + 1] = Math.round(sg / sa);
|
|
786
|
+
out.data[o + 2] = Math.round(sb / sa);
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
return out;
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
/** `plate`, then every halving of it down to `minLongSide`, capped at `maxLevels`. */
|
|
794
|
+
function pyramid(plate: Plate, maxLevels: number, minLongSide: number): Plate[] {
|
|
795
|
+
const out = [plate];
|
|
796
|
+
while (out.length <= maxLevels) {
|
|
797
|
+
const top = out[out.length - 1];
|
|
798
|
+
if (Math.max(top.width, top.height) <= minLongSide) break;
|
|
799
|
+
if (top.width < 2 || top.height < 2) break;
|
|
800
|
+
out.push(halvePlate(top));
|
|
801
|
+
}
|
|
802
|
+
return out;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
/**
|
|
806
|
+
* What the frame's background is, read off its one-pixel border ring.
|
|
807
|
+
*
|
|
808
|
+
* ⭐ Why the background matters at all: without it, a grey part placed on grey
|
|
809
|
+
* emptiness scores as well as a grey part placed on the grey figure, and the
|
|
810
|
+
* whole silhouette signal is gone. With it, "the frame has nothing here" is the
|
|
811
|
+
* maximum error rather than a lucky colour match.
|
|
812
|
+
*
|
|
813
|
+
* A border that is not dominated by one colour is reported `unknown` rather than
|
|
814
|
+
* guessed at — the objective then reduces to plain colour matching, which is a
|
|
815
|
+
* weaker instrument, and the report says so instead of quietly being weaker.
|
|
816
|
+
*/
|
|
817
|
+
export function readBackground(frame: Plate): PoseBackground {
|
|
818
|
+
const w = frame.width;
|
|
819
|
+
const h = frame.height;
|
|
820
|
+
let ringCount = 0;
|
|
821
|
+
let transparent = 0;
|
|
822
|
+
const buckets = new Map<number, { n: number; r: number; g: number; b: number }>();
|
|
823
|
+
const visit = (x: number, y: number): void => {
|
|
824
|
+
const i = (y * w + x) * 4;
|
|
825
|
+
ringCount++;
|
|
826
|
+
if (frame.data[i + 3] < 8) {
|
|
827
|
+
transparent++;
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
const r = frame.data[i];
|
|
831
|
+
const g = frame.data[i + 1];
|
|
832
|
+
const b = frame.data[i + 2];
|
|
833
|
+
const key = ((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4);
|
|
834
|
+
const cell = buckets.get(key) ?? { n: 0, r: 0, g: 0, b: 0 };
|
|
835
|
+
cell.n++;
|
|
836
|
+
cell.r += r;
|
|
837
|
+
cell.g += g;
|
|
838
|
+
cell.b += b;
|
|
839
|
+
buckets.set(key, cell);
|
|
840
|
+
};
|
|
841
|
+
for (let x = 0; x < w; x++) {
|
|
842
|
+
visit(x, 0);
|
|
843
|
+
if (h > 1) visit(x, h - 1);
|
|
844
|
+
}
|
|
845
|
+
for (let y = 1; y < h - 1; y++) {
|
|
846
|
+
visit(0, y);
|
|
847
|
+
if (w > 1) visit(w - 1, y);
|
|
848
|
+
}
|
|
849
|
+
if (ringCount === 0) return { kind: 'unknown', colour: null, borderShare: 0, materialShare: 1 };
|
|
850
|
+
if (transparent / ringCount >= 0.5) {
|
|
851
|
+
return { kind: 'transparent', colour: null, borderShare: transparent / ringCount, materialShare: 0 };
|
|
852
|
+
}
|
|
853
|
+
let best: { n: number; r: number; g: number; b: number } | null = null;
|
|
854
|
+
for (const cell of buckets.values()) if (best === null || cell.n > best.n) best = cell;
|
|
855
|
+
if (best === null || best.n / ringCount < BACKGROUND_BORDER_SHARE) {
|
|
856
|
+
return { kind: 'unknown', colour: null, borderShare: best ? best.n / ringCount : 0, materialShare: 1 };
|
|
857
|
+
}
|
|
858
|
+
return {
|
|
859
|
+
kind: 'colour',
|
|
860
|
+
colour: [Math.round(best.r / best.n), Math.round(best.g / best.n), Math.round(best.b / best.n)],
|
|
861
|
+
borderShare: best.n / ringCount,
|
|
862
|
+
materialShare: 0,
|
|
863
|
+
};
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* The frame as the objective reads it: the frame's own RGB, with alpha rewritten
|
|
868
|
+
* to mean **how much material is here** rather than how opaque the file is.
|
|
869
|
+
*
|
|
870
|
+
* Keeping it in a `Plate` is what lets the pyramid, `bilinear` and the nearest
|
|
871
|
+
* lookup all be the ones this repository already has.
|
|
872
|
+
*/
|
|
873
|
+
export function materialPlate(frame: Plate, background: PoseBackground): { plate: Plate; share: number } {
|
|
874
|
+
const out = new Plate(frame.width, frame.height);
|
|
875
|
+
let material = 0;
|
|
876
|
+
const bg = background.colour;
|
|
877
|
+
for (let i = 0; i < frame.data.length; i += 4) {
|
|
878
|
+
const a = frame.data[i + 3];
|
|
879
|
+
out.data[i] = frame.data[i];
|
|
880
|
+
out.data[i + 1] = frame.data[i + 1];
|
|
881
|
+
out.data[i + 2] = frame.data[i + 2];
|
|
882
|
+
let m: number;
|
|
883
|
+
if (background.kind === 'transparent') {
|
|
884
|
+
m = a;
|
|
885
|
+
} else if (background.kind === 'colour' && bg !== null) {
|
|
886
|
+
const d = (Math.abs(frame.data[i] - bg[0]) + Math.abs(frame.data[i + 1] - bg[1]) + Math.abs(frame.data[i + 2] - bg[2])) / 3;
|
|
887
|
+
m = d > BACKGROUND_TOLERANCE ? a : 0;
|
|
888
|
+
} else {
|
|
889
|
+
m = a;
|
|
890
|
+
}
|
|
891
|
+
out.data[i + 3] = m;
|
|
892
|
+
material += m / 255;
|
|
893
|
+
}
|
|
894
|
+
return { plate: out, share: material / Math.max(1, frame.width * frame.height) };
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** The box the part's material occupies, in part pixels. `null` when there is none. */
|
|
898
|
+
function materialBox(part: Plate): { minX: number; minY: number; maxX: number; maxY: number; weight: number } | null {
|
|
899
|
+
let minX = Infinity;
|
|
900
|
+
let minY = Infinity;
|
|
901
|
+
let maxX = -Infinity;
|
|
902
|
+
let maxY = -Infinity;
|
|
903
|
+
let weight = 0;
|
|
904
|
+
for (let y = 0; y < part.height; y++) {
|
|
905
|
+
for (let x = 0; x < part.width; x++) {
|
|
906
|
+
const a = part.data[(y * part.width + x) * 4 + 3];
|
|
907
|
+
if (a === 0) continue;
|
|
908
|
+
weight += a / 255;
|
|
909
|
+
if (x < minX) minX = x;
|
|
910
|
+
if (x > maxX) maxX = x;
|
|
911
|
+
if (y < minY) minY = y;
|
|
912
|
+
if (y > maxY) maxY = y;
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
if (weight === 0) return null;
|
|
916
|
+
return { minX, minY, maxX: maxX + 1, maxY: maxY + 1, weight };
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
/**
|
|
920
|
+
* The part's texture figure: the mean colour step between neighbouring material
|
|
921
|
+
* pixels, 0..1, in the residual's own units.
|
|
922
|
+
*
|
|
923
|
+
* Every pair of a pixel and its right or lower neighbour, both with material,
|
|
924
|
+
* contributes its mean absolute channel difference over 255, weighted by the
|
|
925
|
+
* smaller of the two alphas. A part filled with one colour reads exactly 0.
|
|
926
|
+
*
|
|
927
|
+
* ⭐ Why this figure rather than a colour variance: it is, to first order, what a
|
|
928
|
+
* one-pixel shift costs. Moving a part by a pixel inside material that continues
|
|
929
|
+
* under it compares each of its pixels against its neighbour's colour, so the
|
|
930
|
+
* residual rises by about this much per pixel of shift — and a part whose
|
|
931
|
+
* residual cannot rise when it moves cannot be told apart from itself moved.
|
|
932
|
+
* A variance does not see that: a part half one colour and half another has a
|
|
933
|
+
* large variance and one edge. The silhouette is deliberately not counted — it
|
|
934
|
+
* is what the material term already reads, and only where the frame is ground.
|
|
935
|
+
*/
|
|
936
|
+
export function partTexture(part: Plate): number {
|
|
937
|
+
const w = part.width;
|
|
938
|
+
const d = part.data;
|
|
939
|
+
let weight = 0;
|
|
940
|
+
let acc = 0;
|
|
941
|
+
const pair = (i: number, j: number): void => {
|
|
942
|
+
const k = Math.min(d[i + 3], d[j + 3]) / 255;
|
|
943
|
+
if (k <= 0) return;
|
|
944
|
+
weight += k;
|
|
945
|
+
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;
|
|
946
|
+
};
|
|
947
|
+
for (let y = 0; y < part.height; y++) {
|
|
948
|
+
for (let x = 0; x < w; x++) {
|
|
949
|
+
const i = (y * w + x) * 4;
|
|
950
|
+
if (d[i + 3] === 0) continue;
|
|
951
|
+
if (x + 1 < w) pair(i, i + 4);
|
|
952
|
+
if (y + 1 < part.height) pair(i, i + w * 4);
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
return weight === 0 ? 0 : acc / weight;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
// ---------------------------------------------------------------------------
|
|
959
|
+
// the objective
|
|
960
|
+
// ---------------------------------------------------------------------------
|
|
961
|
+
|
|
962
|
+
/**
|
|
963
|
+
* The part, reduced to a list of coloured offsets from its anchor.
|
|
964
|
+
*
|
|
965
|
+
* Offsets are in **full-resolution part pixels** whatever mip they were read
|
|
966
|
+
* from, so one sample set is valid at every search level: the level only decides
|
|
967
|
+
* what the offsets get divided by on the way in.
|
|
968
|
+
*/
|
|
969
|
+
export interface Samples {
|
|
970
|
+
u: Float64Array;
|
|
971
|
+
v: Float64Array;
|
|
972
|
+
r: Float64Array;
|
|
973
|
+
g: Float64Array;
|
|
974
|
+
b: Float64Array;
|
|
975
|
+
w: Float64Array;
|
|
976
|
+
count: number;
|
|
977
|
+
weight: number;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
const EMPTY_SAMPLES: Samples = {
|
|
981
|
+
u: new Float64Array(0),
|
|
982
|
+
v: new Float64Array(0),
|
|
983
|
+
r: new Float64Array(0),
|
|
984
|
+
g: new Float64Array(0),
|
|
985
|
+
b: new Float64Array(0),
|
|
986
|
+
w: new Float64Array(0),
|
|
987
|
+
count: 0,
|
|
988
|
+
weight: 0,
|
|
989
|
+
};
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* Pick at most `cap` of a mip's material pixels, by a fixed stride over the
|
|
993
|
+
* material list.
|
|
994
|
+
*
|
|
995
|
+
* Deterministic on purpose — `src/` has no randomness, and a sampler that
|
|
996
|
+
* shuffled would make two runs of the same command disagree in the last decimal
|
|
997
|
+
* of every residual.
|
|
998
|
+
*/
|
|
999
|
+
export function buildSamples(mip: Plate, reduction: number, anchorX: number, anchorY: number, cap: number): Samples {
|
|
1000
|
+
const idx: number[] = [];
|
|
1001
|
+
for (let y = 0; y < mip.height; y++) {
|
|
1002
|
+
for (let x = 0; x < mip.width; x++) {
|
|
1003
|
+
if (mip.data[(y * mip.width + x) * 4 + 3] > 0) idx.push(y * mip.width + x);
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
if (idx.length === 0) return EMPTY_SAMPLES;
|
|
1007
|
+
const count = Math.min(cap, idx.length);
|
|
1008
|
+
const s: Samples = {
|
|
1009
|
+
u: new Float64Array(count),
|
|
1010
|
+
v: new Float64Array(count),
|
|
1011
|
+
r: new Float64Array(count),
|
|
1012
|
+
g: new Float64Array(count),
|
|
1013
|
+
b: new Float64Array(count),
|
|
1014
|
+
w: new Float64Array(count),
|
|
1015
|
+
count,
|
|
1016
|
+
weight: 0,
|
|
1017
|
+
};
|
|
1018
|
+
for (let k = 0; k < count; k++) {
|
|
1019
|
+
const at = idx[Math.floor((k * idx.length) / count)];
|
|
1020
|
+
const px = at % mip.width;
|
|
1021
|
+
const py = (at - px) / mip.width;
|
|
1022
|
+
const i = at * 4;
|
|
1023
|
+
s.u[k] = (px + 0.5) * reduction - anchorX;
|
|
1024
|
+
s.v[k] = (py + 0.5) * reduction - anchorY;
|
|
1025
|
+
s.r[k] = mip.data[i];
|
|
1026
|
+
s.g[k] = mip.data[i + 1];
|
|
1027
|
+
s.b[k] = mip.data[i + 2];
|
|
1028
|
+
s.w[k] = mip.data[i + 3] / 255;
|
|
1029
|
+
s.weight += s.w[k];
|
|
1030
|
+
}
|
|
1031
|
+
return s;
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
/** Error of one part pixel against the level, nearest neighbour. 1 outside the canvas. */
|
|
1035
|
+
export function errNearest(level: Level, x: number, y: number, pr: number, pg: number, pb: number): number {
|
|
1036
|
+
const ix = Math.floor(x);
|
|
1037
|
+
const iy = Math.floor(y);
|
|
1038
|
+
if (ix < 0 || iy < 0 || ix >= level.width || iy >= level.height) return 1;
|
|
1039
|
+
const i = (iy * level.width + ix) * 4;
|
|
1040
|
+
const m = level.data[i + 3] / 255;
|
|
1041
|
+
if (m <= 0) return 1;
|
|
1042
|
+
const d = (Math.abs(level.data[i] - pr) + Math.abs(level.data[i + 1] - pg) + Math.abs(level.data[i + 2] - pb)) / 765;
|
|
1043
|
+
return m * d + (1 - m);
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
/**
|
|
1047
|
+
* Same, sampled between pixel centres — what the refinement stages measure with.
|
|
1048
|
+
*
|
|
1049
|
+
* ⭐ The two halves of the tap are read differently on purpose, and `bilinear`
|
|
1050
|
+
* is what supplies both: the colour is the material-weighted mean (a texel with
|
|
1051
|
+
* no material contributes no colour), while `fa` — the coverage this charges
|
|
1052
|
+
* `1 − m` for — is the plain interpolation of the fourth channel, unchanged.
|
|
1053
|
+
* Weighting a mask as if it were a colour is the mistake the import comment
|
|
1054
|
+
* warns about; weighting the colour BY the mask is the objective.
|
|
1055
|
+
*/
|
|
1056
|
+
export function errBilinear(level: Level, plate: Plate, x: number, y: number, pr: number, pg: number, pb: number): number {
|
|
1057
|
+
if (x < 0 || y < 0 || x >= level.width || y >= level.height) return 1;
|
|
1058
|
+
const [fr, fg, fb, fa] = bilinear(plate, x - 0.5, y - 0.5);
|
|
1059
|
+
const m = fa / 255;
|
|
1060
|
+
if (m <= 0) return 1;
|
|
1061
|
+
const d = (Math.abs(fr - pr) + Math.abs(fg - pg) + Math.abs(fb - pb)) / 765;
|
|
1062
|
+
return m * d + (1 - m);
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
/** A placement mid-search: the anchor's position at some level, plus the two other degrees of freedom. */
|
|
1066
|
+
interface Candidate {
|
|
1067
|
+
/** Anchor position in the level's own pixels. */
|
|
1068
|
+
cx: number;
|
|
1069
|
+
cy: number;
|
|
1070
|
+
rotDeg: number;
|
|
1071
|
+
/** Frame pixels per part pixel, at FULL resolution — level-independent. */
|
|
1072
|
+
scale: number;
|
|
1073
|
+
residual: number;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function residualAt(level: Level, plate: Plate, s: Samples, cand: Candidate, smooth: boolean): number {
|
|
1077
|
+
if (s.count === 0) return 1;
|
|
1078
|
+
const k = cand.scale / level.reduction;
|
|
1079
|
+
const cos = Math.cos(cand.rotDeg * DEG) * k;
|
|
1080
|
+
const sin = Math.sin(cand.rotDeg * DEG) * k;
|
|
1081
|
+
let acc = 0;
|
|
1082
|
+
for (let i = 0; i < s.count; i++) {
|
|
1083
|
+
const fx = cand.cx + s.u[i] * cos - s.v[i] * sin;
|
|
1084
|
+
const fy = cand.cy + s.u[i] * sin + s.v[i] * cos;
|
|
1085
|
+
acc += s.w[i] * (smooth ? errBilinear(level, plate, fx, fy, s.r[i], s.g[i], s.b[i]) : errNearest(level, fx, fy, s.r[i], s.g[i], s.b[i]));
|
|
1086
|
+
}
|
|
1087
|
+
return acc / s.weight;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
// ---------------------------------------------------------------------------
|
|
1091
|
+
// the search
|
|
1092
|
+
// ---------------------------------------------------------------------------
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* Every anchor cell of the coarse level, holding the best (rotation, scale) found
|
|
1096
|
+
* for it.
|
|
1097
|
+
*
|
|
1098
|
+
* A per-cell best rather than a global top-K, because the thing this instrument
|
|
1099
|
+
* must not lose is the SECOND place a part could sit — and a global top-K fills
|
|
1100
|
+
* up with a hundred neighbours of the single best cell before it ever reaches it.
|
|
1101
|
+
*
|
|
1102
|
+
* 📏 ONE rotation per cell, and issue #892 measured what that costs rather than
|
|
1103
|
+
* assuming it. Over `tools/pose_floor.ts`'s 420 trials, the coarse seed nearest
|
|
1104
|
+
* the truth is within 2.4 px of it and a half-turn off on 2 of the 238 found
|
|
1105
|
+
* trials (both a mouth at 48 px, both found anyway) and on none of the 6
|
|
1106
|
+
* misses; every failed trial whose nearest seed is a quarter- or half-turn off
|
|
1107
|
+
* but one is a flat part, and none of those 26 is a miss: each is the objective
|
|
1108
|
+
* preferring another placement or the truth reported and tied. MOTION.md
|
|
1109
|
+
* §6's post is the one case, and a second rotation per cell would not reach it:
|
|
1110
|
+
* at its nearest cell the upright rung scores 0.305 against the half-turn's
|
|
1111
|
+
* 0.168 — 1.8 times, wider than the 1.73 band `REFINE_CANDIDATES` rejected.
|
|
1112
|
+
* The other two levers the card named were measured on the grid and lost found
|
|
1113
|
+
* trials outside the ambiguity margin: re-gridding rotation at this level
|
|
1114
|
+
* rather than the next (238 → 236: 7 gained, 9 lost, six of them native, three
|
|
1115
|
+
* guns now 37–151 px off), and suppressing minima only within a rotation family
|
|
1116
|
+
* (238 → 231: 2 gained, 10 lost, among them the 48 px fist `PO23` holds).
|
|
1117
|
+
*/
|
|
1118
|
+
interface CoarseField {
|
|
1119
|
+
residual: Float64Array;
|
|
1120
|
+
rotDeg: Float64Array;
|
|
1121
|
+
scale: number;
|
|
1122
|
+
/** Grid size, which is the LEVEL's size divided by `stride`. */
|
|
1123
|
+
cols: number;
|
|
1124
|
+
rows: number;
|
|
1125
|
+
/** Level pixels per grid step. */
|
|
1126
|
+
stride: number;
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
/**
|
|
1130
|
+
* One field per scale rung, and that separation is the load-bearing part.
|
|
1131
|
+
*
|
|
1132
|
+
* 🚨 A coarse level cannot compare scales. At a 4x reduction a striped torso and
|
|
1133
|
+
* a ringed ball are both near-uniform blobs, so the rung that scores best on one
|
|
1134
|
+
* is whichever fits deepest inside it — the smallest — and folding all rungs into
|
|
1135
|
+
* one field bakes that preference in before any level with detail gets a vote.
|
|
1136
|
+
* Keeping the fields apart means the coarse scan only ever answers the question it
|
|
1137
|
+
* CAN answer — "given this size, where and at what angle?" — and every rung sends
|
|
1138
|
+
* its own best guesses down to the levels that can tell them apart. Measured on
|
|
1139
|
+
* the fixture, folding them cost a torso its scale (0.59 against a true 1.15) and
|
|
1140
|
+
* cost the estimator one of two identical arms.
|
|
1141
|
+
*/
|
|
1142
|
+
function coarseScan(level: Level, s: Samples, scale: number, rotations: number[], stride: number): CoarseField {
|
|
1143
|
+
const cols = Math.max(1, Math.ceil(level.width / stride));
|
|
1144
|
+
const rows = Math.max(1, Math.ceil(level.height / stride));
|
|
1145
|
+
const field: CoarseField = {
|
|
1146
|
+
residual: new Float64Array(cols * rows).fill(Infinity),
|
|
1147
|
+
rotDeg: new Float64Array(cols * rows),
|
|
1148
|
+
scale,
|
|
1149
|
+
cols,
|
|
1150
|
+
rows,
|
|
1151
|
+
stride,
|
|
1152
|
+
};
|
|
1153
|
+
if (s.count === 0) return field;
|
|
1154
|
+
const k = scale / level.reduction;
|
|
1155
|
+
for (const rotDeg of rotations) {
|
|
1156
|
+
const cos = Math.cos(rotDeg * DEG) * k;
|
|
1157
|
+
const sin = Math.sin(rotDeg * DEG) * k;
|
|
1158
|
+
const dx = new Float64Array(s.count);
|
|
1159
|
+
const dy = new Float64Array(s.count);
|
|
1160
|
+
for (let i = 0; i < s.count; i++) {
|
|
1161
|
+
dx[i] = s.u[i] * cos - s.v[i] * sin;
|
|
1162
|
+
dy[i] = s.u[i] * sin + s.v[i] * cos;
|
|
1163
|
+
}
|
|
1164
|
+
for (let gy = 0; gy < rows; gy++) {
|
|
1165
|
+
for (let gx = 0; gx < cols; gx++) {
|
|
1166
|
+
const cell = gy * cols + gx;
|
|
1167
|
+
// The bound is this cell's own best so far: anything that cannot beat
|
|
1168
|
+
// it changes nothing, so the loop may leave the moment it passes it.
|
|
1169
|
+
const bound = field.residual[cell] * s.weight;
|
|
1170
|
+
const ax = gx * stride + stride / 2;
|
|
1171
|
+
const ay = gy * stride + stride / 2;
|
|
1172
|
+
let acc = 0;
|
|
1173
|
+
let beaten = false;
|
|
1174
|
+
for (let i = 0; i < s.count; i++) {
|
|
1175
|
+
acc += s.w[i] * errNearest(level, ax + dx[i], ay + dy[i], s.r[i], s.g[i], s.b[i]);
|
|
1176
|
+
if ((i & 15) === 15 && acc >= bound) {
|
|
1177
|
+
beaten = true;
|
|
1178
|
+
break;
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
if (beaten) continue;
|
|
1182
|
+
const residual = acc / s.weight;
|
|
1183
|
+
if (residual < field.residual[cell]) {
|
|
1184
|
+
field.residual[cell] = residual;
|
|
1185
|
+
field.rotDeg[cell] = rotDeg;
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
return field;
|
|
1191
|
+
}
|
|
1192
|
+
|
|
1193
|
+
/**
|
|
1194
|
+
* The coarse field's objective at one anchor, scale and rotation, summed in the
|
|
1195
|
+
* order `coarseScan` sums it and without its early exit — the trace's reading of
|
|
1196
|
+
* a cell, never the search's.
|
|
1197
|
+
*/
|
|
1198
|
+
function coarseCellResidual(level: Level, s: Samples, scale: number, rotDeg: number, ax: number, ay: number): number {
|
|
1199
|
+
if (s.count === 0) return Infinity;
|
|
1200
|
+
const k = scale / level.reduction;
|
|
1201
|
+
const cos = Math.cos(rotDeg * DEG) * k;
|
|
1202
|
+
const sin = Math.sin(rotDeg * DEG) * k;
|
|
1203
|
+
let acc = 0;
|
|
1204
|
+
for (let i = 0; i < s.count; i++) {
|
|
1205
|
+
const dx = s.u[i] * cos - s.v[i] * sin;
|
|
1206
|
+
const dy = s.u[i] * sin + s.v[i] * cos;
|
|
1207
|
+
acc += s.w[i] * errNearest(level, ax + dx, ay + dy, s.r[i], s.g[i], s.b[i]);
|
|
1208
|
+
}
|
|
1209
|
+
return acc / s.weight;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* The distinct places a part could sit, best first.
|
|
1214
|
+
*
|
|
1215
|
+
* A cell survives when nothing within `radius` beats it — the two-arms case is
|
|
1216
|
+
* exactly two such cells — and the accepted list then keeps them apart so the
|
|
1217
|
+
* refinement budget is not spent twice on one hill.
|
|
1218
|
+
*/
|
|
1219
|
+
function localMinima(field: CoarseField, radius: number, keep: number): Candidate[] {
|
|
1220
|
+
const found: Candidate[] = [];
|
|
1221
|
+
for (let gy = 0; gy < field.rows; gy++) {
|
|
1222
|
+
for (let gx = 0; gx < field.cols; gx++) {
|
|
1223
|
+
const here = field.residual[gy * field.cols + gx];
|
|
1224
|
+
if (!Number.isFinite(here)) continue;
|
|
1225
|
+
let minimal = true;
|
|
1226
|
+
for (let dy = -radius; dy <= radius && minimal; dy++) {
|
|
1227
|
+
for (let dx = -radius; dx <= radius; dx++) {
|
|
1228
|
+
const nx = gx + dx;
|
|
1229
|
+
const ny = gy + dy;
|
|
1230
|
+
if (nx < 0 || ny < 0 || nx >= field.cols || ny >= field.rows) continue;
|
|
1231
|
+
if (field.residual[ny * field.cols + nx] < here) {
|
|
1232
|
+
minimal = false;
|
|
1233
|
+
break;
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
}
|
|
1237
|
+
if (!minimal) continue;
|
|
1238
|
+
const cell = gy * field.cols + gx;
|
|
1239
|
+
found.push({
|
|
1240
|
+
cx: gx * field.stride + field.stride / 2,
|
|
1241
|
+
cy: gy * field.stride + field.stride / 2,
|
|
1242
|
+
rotDeg: field.rotDeg[cell],
|
|
1243
|
+
scale: field.scale,
|
|
1244
|
+
residual: here,
|
|
1245
|
+
});
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
found.sort((a, b) => a.residual - b.residual);
|
|
1249
|
+
const accepted: Candidate[] = [];
|
|
1250
|
+
for (const cand of found) {
|
|
1251
|
+
if (accepted.length >= keep) break;
|
|
1252
|
+
if (accepted.some((a) => Math.hypot(a.cx - cand.cx, a.cy - cand.cy) <= radius * field.stride)) continue;
|
|
1253
|
+
accepted.push(cand);
|
|
1254
|
+
}
|
|
1255
|
+
return accepted;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
/**
|
|
1259
|
+
* Pattern search on all four degrees of freedom: probe, move to the best
|
|
1260
|
+
* improvement, and halve the steps when none of them improves.
|
|
1261
|
+
*
|
|
1262
|
+
* Cheaper than a local grid by an order of magnitude and it is the same answer —
|
|
1263
|
+
* the objective is smooth at this range, and the coarse scan has already done the
|
|
1264
|
+
* part a local method cannot (finding the right hill).
|
|
1265
|
+
*/
|
|
1266
|
+
function polish(
|
|
1267
|
+
level: Level,
|
|
1268
|
+
plate: Plate,
|
|
1269
|
+
s: Samples,
|
|
1270
|
+
start: Candidate,
|
|
1271
|
+
step: { translate: number; rotate: number; scale: number },
|
|
1272
|
+
floor: { translate: number; rotate: number; scale: number },
|
|
1273
|
+
smooth: boolean,
|
|
1274
|
+
/** The scale window the report declares. A polish that walked outside it would report a scale nobody searched. */
|
|
1275
|
+
bounds: { min: number; max: number },
|
|
1276
|
+
/**
|
|
1277
|
+
* The rotation window the report declares, held for exactly the reason above.
|
|
1278
|
+
*
|
|
1279
|
+
* ⚠️ This argument did not exist until issue #719, and the sentence over
|
|
1280
|
+
* `bounds` was the whole argument for it the entire time: a polish free to
|
|
1281
|
+
* walk outside the window reports an answer nobody searched, and the window is
|
|
1282
|
+
* a field a caller is entitled to read as a promise. `src/chainfit.ts` had
|
|
1283
|
+
* already written that argument out for its own hinge — *"for the same reason
|
|
1284
|
+
* `pose`'s polish clamps its scale"* — while this file, the one it was citing,
|
|
1285
|
+
* clamped one of its two windows. Measured on a rotation window of `-5,5`: the
|
|
1286
|
+
* ladder walked its two endpoints and the report came back with 28.1°, 121.3°
|
|
1287
|
+
* and −122.3°.
|
|
1288
|
+
*
|
|
1289
|
+
* A full turn contains every angle, so it is left unclamped and the rotation
|
|
1290
|
+
* may wrap — which is what the default window is, and why nothing about a
|
|
1291
|
+
* default run moves.
|
|
1292
|
+
*/
|
|
1293
|
+
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1294
|
+
/** Scale factors tried from the point the steps converged on; a better one restarts the polish there. */
|
|
1295
|
+
escapes: readonly number[],
|
|
1296
|
+
/** An instrument's record of where the polish started and the moves it accepted; the search never reads it. */
|
|
1297
|
+
path?: { start: number; steps: PoseTraceStep[] },
|
|
1298
|
+
): Candidate {
|
|
1299
|
+
const clamp = (v: number): number => Math.min(bounds.max, Math.max(bounds.min, v));
|
|
1300
|
+
const hold = (v: number): number =>
|
|
1301
|
+
rotationBounds.wraps ? v : Math.min(rotationBounds.max, Math.max(rotationBounds.min, v));
|
|
1302
|
+
let cur: Candidate = { ...start, residual: residualAt(level, plate, s, start, smooth) };
|
|
1303
|
+
if (path !== undefined) path.start = cur.residual;
|
|
1304
|
+
let dt = step.translate;
|
|
1305
|
+
let dr = step.rotate;
|
|
1306
|
+
let ds = step.scale;
|
|
1307
|
+
for (let guard = 0; guard < 200; guard++) {
|
|
1308
|
+
if (dt <= floor.translate && dr <= floor.rotate && ds <= floor.scale) {
|
|
1309
|
+
let jump = cur;
|
|
1310
|
+
for (const f of escapes) {
|
|
1311
|
+
const scale = clamp(cur.scale * f);
|
|
1312
|
+
if (scale === cur.scale) continue;
|
|
1313
|
+
const refit = polish(
|
|
1314
|
+
level,
|
|
1315
|
+
plate,
|
|
1316
|
+
s,
|
|
1317
|
+
{ ...cur, scale },
|
|
1318
|
+
{ translate: step.translate, rotate: 0, scale: 0 },
|
|
1319
|
+
{ translate: floor.translate, rotate: 0, scale: 0 },
|
|
1320
|
+
smooth,
|
|
1321
|
+
bounds,
|
|
1322
|
+
rotationBounds,
|
|
1323
|
+
[],
|
|
1324
|
+
);
|
|
1325
|
+
if (refit.residual < jump.residual) jump = refit;
|
|
1326
|
+
}
|
|
1327
|
+
if (jump === cur) break;
|
|
1328
|
+
path?.steps.push({ residual: jump.residual, translate: dt, rotate: dr, scale: jump.scale / cur.scale - 1, clamped: false, escape: true });
|
|
1329
|
+
cur = jump;
|
|
1330
|
+
dt = step.translate;
|
|
1331
|
+
dr = step.rotate;
|
|
1332
|
+
ds = step.scale;
|
|
1333
|
+
continue;
|
|
1334
|
+
}
|
|
1335
|
+
const probes: Candidate[] = [];
|
|
1336
|
+
const push = (cand: Omit<Candidate, 'residual'>): void => {
|
|
1337
|
+
probes.push({ ...cand, residual: residualAt(level, plate, s, { ...cand, residual: 0 }, smooth) });
|
|
1338
|
+
};
|
|
1339
|
+
if (dt > floor.translate) {
|
|
1340
|
+
for (const [ox, oy] of [
|
|
1341
|
+
[1, 0],
|
|
1342
|
+
[-1, 0],
|
|
1343
|
+
[0, 1],
|
|
1344
|
+
[0, -1],
|
|
1345
|
+
[1, 1],
|
|
1346
|
+
[1, -1],
|
|
1347
|
+
[-1, 1],
|
|
1348
|
+
[-1, -1],
|
|
1349
|
+
]) {
|
|
1350
|
+
push({ cx: cur.cx + ox * dt, cy: cur.cy + oy * dt, rotDeg: cur.rotDeg, scale: cur.scale });
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
if (dr > floor.rotate) {
|
|
1354
|
+
push({ cx: cur.cx, cy: cur.cy, rotDeg: hold(cur.rotDeg + dr), scale: cur.scale });
|
|
1355
|
+
push({ cx: cur.cx, cy: cur.cy, rotDeg: hold(cur.rotDeg - dr), scale: cur.scale });
|
|
1356
|
+
}
|
|
1357
|
+
if (ds > floor.scale) {
|
|
1358
|
+
push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg, scale: clamp(cur.scale * (1 + ds)) });
|
|
1359
|
+
push({ cx: cur.cx, cy: cur.cy, rotDeg: cur.rotDeg, scale: clamp(cur.scale * (1 - ds)) });
|
|
1360
|
+
}
|
|
1361
|
+
let best = cur;
|
|
1362
|
+
for (const p of probes) if (p.residual < best.residual) best = p;
|
|
1363
|
+
if (best === cur) {
|
|
1364
|
+
dt /= 2;
|
|
1365
|
+
dr /= 2;
|
|
1366
|
+
ds /= 2;
|
|
1367
|
+
continue;
|
|
1368
|
+
}
|
|
1369
|
+
if (path !== undefined) {
|
|
1370
|
+
const clamped =
|
|
1371
|
+
(best.scale !== cur.scale && best.scale !== cur.scale * (1 + ds) && best.scale !== cur.scale * (1 - ds)) ||
|
|
1372
|
+
(best.rotDeg !== cur.rotDeg && best.rotDeg !== cur.rotDeg + dr && best.rotDeg !== cur.rotDeg - dr);
|
|
1373
|
+
path.steps.push({ residual: best.residual, translate: dt, rotate: dr, scale: ds, clamped, escape: false });
|
|
1374
|
+
}
|
|
1375
|
+
cur = best;
|
|
1376
|
+
}
|
|
1377
|
+
return cur;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
/** Candidates that walked to one optimum, collapsed to the best of them. Input must be sorted. */
|
|
1381
|
+
function dedupe(sorted: Candidate[], within: number, degrees: number, scaleRatio: number): Candidate[] {
|
|
1382
|
+
const out: Candidate[] = [];
|
|
1383
|
+
for (const cand of sorted) {
|
|
1384
|
+
const same = out.some(
|
|
1385
|
+
(o) =>
|
|
1386
|
+
Math.hypot(o.cx - cand.cx, o.cy - cand.cy) <= within &&
|
|
1387
|
+
Math.abs(normaliseDegrees(o.rotDeg - cand.rotDeg)) <= degrees &&
|
|
1388
|
+
Math.abs(Math.log(o.scale / cand.scale)) <= Math.log(scaleRatio),
|
|
1389
|
+
);
|
|
1390
|
+
if (!same) out.push(cand);
|
|
1391
|
+
}
|
|
1392
|
+
return out;
|
|
1393
|
+
}
|
|
1394
|
+
|
|
1395
|
+
// ---------------------------------------------------------------------------
|
|
1396
|
+
// measuring the answer
|
|
1397
|
+
// ---------------------------------------------------------------------------
|
|
1398
|
+
|
|
1399
|
+
/** The reported numbers, taken at full resolution over EVERY part pixel rather than a sample of them. */
|
|
1400
|
+
function measure(
|
|
1401
|
+
level: Level,
|
|
1402
|
+
plate: Plate,
|
|
1403
|
+
part: Plate,
|
|
1404
|
+
anchorX: number,
|
|
1405
|
+
anchorY: number,
|
|
1406
|
+
cand: Candidate,
|
|
1407
|
+
): Omit<PosePlacement, 'x' | 'y' | 'rotationDeg' | 'scale'> {
|
|
1408
|
+
const cos = Math.cos(cand.rotDeg * DEG) * cand.scale;
|
|
1409
|
+
const sin = Math.sin(cand.rotDeg * DEG) * cand.scale;
|
|
1410
|
+
let weight = 0;
|
|
1411
|
+
let acc = 0;
|
|
1412
|
+
let unexplained = 0;
|
|
1413
|
+
let off = 0;
|
|
1414
|
+
let onMaterial = 0;
|
|
1415
|
+
let minX = Infinity;
|
|
1416
|
+
let minY = Infinity;
|
|
1417
|
+
let maxX = -Infinity;
|
|
1418
|
+
let maxY = -Infinity;
|
|
1419
|
+
for (let y = 0; y < part.height; y++) {
|
|
1420
|
+
for (let x = 0; x < part.width; x++) {
|
|
1421
|
+
const i = (y * part.width + x) * 4;
|
|
1422
|
+
const a = part.data[i + 3];
|
|
1423
|
+
if (a === 0) continue;
|
|
1424
|
+
const w = a / 255;
|
|
1425
|
+
const u = x + 0.5 - anchorX;
|
|
1426
|
+
const v = y + 0.5 - anchorY;
|
|
1427
|
+
const fx = cand.cx + u * cos - v * sin;
|
|
1428
|
+
const fy = cand.cy + u * sin + v * cos;
|
|
1429
|
+
weight += w;
|
|
1430
|
+
if (fx < minX) minX = fx;
|
|
1431
|
+
if (fx > maxX) maxX = fx;
|
|
1432
|
+
if (fy < minY) minY = fy;
|
|
1433
|
+
if (fy > maxY) maxY = fy;
|
|
1434
|
+
const inside = fx >= 0 && fy >= 0 && fx < level.width && fy < level.height;
|
|
1435
|
+
if (!inside) off += w;
|
|
1436
|
+
const err = errBilinear(level, plate, fx, fy, part.data[i], part.data[i + 1], part.data[i + 2]);
|
|
1437
|
+
acc += w * err;
|
|
1438
|
+
if (err > UNEXPLAINED_TOLERANCE) unexplained += w;
|
|
1439
|
+
if (inside) {
|
|
1440
|
+
const ix = Math.min(level.width - 1, Math.floor(fx));
|
|
1441
|
+
const iy = Math.min(level.height - 1, Math.floor(fy));
|
|
1442
|
+
onMaterial += w * (level.data[(iy * level.width + ix) * 4 + 3] / 255);
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
}
|
|
1446
|
+
if (weight === 0) {
|
|
1447
|
+
return { residual: 1, unexplained: 1, offCanvas: 1, footprint: 0, bbox: { x: 0, y: 0, width: 0, height: 0 } };
|
|
1448
|
+
}
|
|
1449
|
+
return {
|
|
1450
|
+
residual: acc / weight,
|
|
1451
|
+
unexplained: unexplained / weight,
|
|
1452
|
+
offCanvas: off / weight,
|
|
1453
|
+
// One part pixel covers `scale²` frame pixels, so this is the frame area the
|
|
1454
|
+
// placement actually accounts for — which is what separates two placements
|
|
1455
|
+
// whose per-pixel residuals are the same.
|
|
1456
|
+
footprint: onMaterial * cand.scale * cand.scale,
|
|
1457
|
+
bbox: { x: minX, y: minY, width: maxX - minX, height: maxY - minY },
|
|
1458
|
+
};
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
export function roundTo(n: number, places: number): number {
|
|
1462
|
+
const f = 10 ** places;
|
|
1463
|
+
const v = Math.round(n * f) / f;
|
|
1464
|
+
return v === 0 ? 0 : v;
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/** Degrees into (-180, 180], the way an editor shows a rotation. */
|
|
1468
|
+
export function normaliseDegrees(deg: number): number {
|
|
1469
|
+
let v = ((deg % 360) + 360) % 360;
|
|
1470
|
+
if (v > 180) v -= 360;
|
|
1471
|
+
return v;
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/** A finished candidate, converted into the report's own frame of reference. */
|
|
1475
|
+
function toPlacement(part: Plate, anchorX: number, anchorY: number, cand: Candidate, stats: ReturnType<typeof measure>): PosePlacement {
|
|
1476
|
+
const cos = Math.cos(cand.rotDeg * DEG) * cand.scale;
|
|
1477
|
+
const sin = Math.sin(cand.rotDeg * DEG) * cand.scale;
|
|
1478
|
+
const ou = part.width / 2 - anchorX;
|
|
1479
|
+
const ov = part.height / 2 - anchorY;
|
|
1480
|
+
return {
|
|
1481
|
+
x: roundTo(cand.cx + ou * cos - ov * sin, 3),
|
|
1482
|
+
y: roundTo(cand.cy + ou * sin + ov * cos, 3),
|
|
1483
|
+
rotationDeg: roundTo(normaliseDegrees(cand.rotDeg), 3),
|
|
1484
|
+
scale: roundTo(cand.scale, 5),
|
|
1485
|
+
residual: roundTo(stats.residual, 5),
|
|
1486
|
+
unexplained: roundTo(stats.unexplained, 4),
|
|
1487
|
+
offCanvas: roundTo(stats.offCanvas, 4),
|
|
1488
|
+
footprint: roundTo(stats.footprint, 1),
|
|
1489
|
+
bbox: {
|
|
1490
|
+
x: roundTo(stats.bbox.x, 2),
|
|
1491
|
+
y: roundTo(stats.bbox.y, 2),
|
|
1492
|
+
width: roundTo(stats.bbox.width, 2),
|
|
1493
|
+
height: roundTo(stats.bbox.height, 2),
|
|
1494
|
+
},
|
|
1495
|
+
};
|
|
1496
|
+
}
|
|
1497
|
+
|
|
1498
|
+
/**
|
|
1499
|
+
* Is this part self-similar under rotation — a ball rather than an arm?
|
|
1500
|
+
*
|
|
1501
|
+
* Measured with the same objective, against the part itself: rotate it about its
|
|
1502
|
+
* own material centre and ask how much of it still lands on itself, in the same
|
|
1503
|
+
* colours. A disc answers ~0 at every angle; anything with a corner or a pattern
|
|
1504
|
+
* does not.
|
|
1505
|
+
*/
|
|
1506
|
+
function rotationSelfSimilarity(part: Plate, anchorX: number, anchorY: number): number {
|
|
1507
|
+
const level = levelOf(part, 1);
|
|
1508
|
+
const samples = buildSamples(part, 1, anchorX, anchorY, POLISH_SAMPLES);
|
|
1509
|
+
if (samples.count === 0) return 1;
|
|
1510
|
+
const at = (deg: number): number =>
|
|
1511
|
+
residualAt(level, part, samples, { cx: anchorX, cy: anchorY, rotDeg: deg, scale: 1, residual: 0 }, true);
|
|
1512
|
+
// ⚠️ Measured against the IDENTITY, not against zero. A part with a soft rim
|
|
1513
|
+
// scores above zero when laid over itself unrotated — every partly transparent
|
|
1514
|
+
// pixel pays the `1 − material` term against its own partial alpha — so an
|
|
1515
|
+
// absolute reading calls a perfectly round anti-aliased ball asymmetric. On the
|
|
1516
|
+
// fixture's 32px ball that baseline is most of a 0.033 absolute reading, which
|
|
1517
|
+
// sits the wrong side of a tolerance the shape plainly deserves to pass.
|
|
1518
|
+
//
|
|
1519
|
+
// 📏 Re-baselined for #306: the absolute reading was 0.034 under the
|
|
1520
|
+
// channel-independent tap and the BASELINE did not move at all (0.0198 both
|
|
1521
|
+
// ways). It could not: at zero rotation every sample lands on a texel centre,
|
|
1522
|
+
// where premultiplying and dividing back out is the identity. What premultiplying
|
|
1523
|
+
// took off is the rotated readings — worst relative 0.0143 -> 0.0129 — which is
|
|
1524
|
+
// the ball's own soft rim no longer being compared against black.
|
|
1525
|
+
const baseline = at(0);
|
|
1526
|
+
let worst = 0;
|
|
1527
|
+
for (let deg = 30; deg < 360; deg += 30) {
|
|
1528
|
+
const r = at(deg) - baseline;
|
|
1529
|
+
if (r > worst) worst = r;
|
|
1530
|
+
}
|
|
1531
|
+
return worst;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// ---------------------------------------------------------------------------
|
|
1535
|
+
// arguments
|
|
1536
|
+
// ---------------------------------------------------------------------------
|
|
1537
|
+
|
|
1538
|
+
function scaleLadder(min: number, max: number): number[] {
|
|
1539
|
+
if (max <= min) return [min];
|
|
1540
|
+
const octaves = Math.log2(max / min);
|
|
1541
|
+
const steps = Math.max(1, Math.round(octaves * SCALE_STEPS_PER_OCTAVE));
|
|
1542
|
+
const out: number[] = [];
|
|
1543
|
+
for (let i = 0; i <= steps; i++) out.push(min * (max / min) ** (i / steps));
|
|
1544
|
+
return out;
|
|
1545
|
+
}
|
|
1546
|
+
|
|
1547
|
+
/**
|
|
1548
|
+
* The angles the coarse pass actually walks, evenly dividing the window.
|
|
1549
|
+
*
|
|
1550
|
+
* ⭐ `scaleLadder` above is the shape this follows, and it is the reason the
|
|
1551
|
+
* defect was reachable: that one takes a rung count off its window and divides,
|
|
1552
|
+
* so the rung it reports is the rung it walks. This one used to march
|
|
1553
|
+
* `COARSE_ROTATION_STEP` off the floor and then append the ceiling, which left
|
|
1554
|
+
* two ways for the reported step to be a different number from the applied one —
|
|
1555
|
+
* a window narrower than the constant got its two endpoints and a gap of the
|
|
1556
|
+
* window's own width, and any window whose span is not a whole number of steps
|
|
1557
|
+
* got a short final gap. Both printed `step 15°`.
|
|
1558
|
+
*
|
|
1559
|
+
* ⚠️ The count is a CEILING rather than a rounding, which is not tidiness: a
|
|
1560
|
+
* rounding down would make the applied step wider than `COARSE_ROTATION_STEP`
|
|
1561
|
+
* for a window like 20°, so the constant would stop being an upper bound on the
|
|
1562
|
+
* step. Rounding up cannot coarsen the search — measured against the old ladder,
|
|
1563
|
+
* every window it changes gets at least as many angles as before.
|
|
1564
|
+
*/
|
|
1565
|
+
export function rotationLadder(minDeg: number, maxDeg: number): number[] {
|
|
1566
|
+
const span = maxDeg - minDeg;
|
|
1567
|
+
if (span <= 0) return [minDeg];
|
|
1568
|
+
const steps = Math.ceil(span / COARSE_ROTATION_STEP - 1e-9);
|
|
1569
|
+
const out: number[] = [];
|
|
1570
|
+
for (let i = 0; i <= steps; i++) out.push(minDeg + (span * i) / steps);
|
|
1571
|
+
// A full turn's two endpoints are the same rotation, so it gets one of them.
|
|
1572
|
+
if (span >= 360 - 1e-9) out.pop();
|
|
1573
|
+
return out;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
/** The step a ladder walks, read off the ladder rather than off the constant it was built from. */
|
|
1577
|
+
function ladderStep(degrees: number[]): number {
|
|
1578
|
+
return degrees.length > 1 ? degrees[1] - degrees[0] : 0;
|
|
1579
|
+
}
|
|
1580
|
+
|
|
1581
|
+
/**
|
|
1582
|
+
* The `search` line's rotation clause.
|
|
1583
|
+
*
|
|
1584
|
+
* ⭐ Exported for the same reason `windowEdgeNote` is: `docs/AUTHORING.md`
|
|
1585
|
+
* quotes this line, and a guide that spells a report's own sentence by hand is
|
|
1586
|
+
* a second implementation of it. `CUR47` builds the clause here and looks for it
|
|
1587
|
+
* in the page, so the two go stale together or not at all.
|
|
1588
|
+
*/
|
|
1589
|
+
export function searchRotationClause(rotation: PoseSearch['rotation']): string {
|
|
1590
|
+
// Rounded for the console alone — `search.rotation` in the JSON carries the
|
|
1591
|
+
// ladder unrounded, because a window that divides into thirds has angles no
|
|
1592
|
+
// decimal place holds.
|
|
1593
|
+
return `rotation ${rotation.minDeg}°–${rotation.maxDeg}° in ${rotation.steps} step(s) of ${roundTo(rotation.stepDeg, 3)}°`;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
/**
|
|
1597
|
+
* The sentence a refusal carries when its best placement sits on a WALL of the
|
|
1598
|
+
* search window rather than somewhere inside it.
|
|
1599
|
+
*
|
|
1600
|
+
* ⭐ Exported because the guide quotes it and `CUR48` compares the two: a
|
|
1601
|
+
* message and the document that teaches it are the same interface, and the only
|
|
1602
|
+
* way they cannot drift is for one of them to be built from the other.
|
|
1603
|
+
*
|
|
1604
|
+
* The claim is deliberately weak — *may* lie outside — because that is all that
|
|
1605
|
+
* is known. The search was bounded, the optimum walked to the bound and stopped;
|
|
1606
|
+
* whether the truth is past it or the part simply does not appear in this frame
|
|
1607
|
+
* are two readings this instrument cannot separate. Naming the wall is what lets
|
|
1608
|
+
* an author separate them, by moving the wall.
|
|
1609
|
+
*/
|
|
1610
|
+
export function windowEdgeNote(axis: 'scale' | 'rotation', edge: 'floor' | 'ceiling', at: string, window: string): string {
|
|
1611
|
+
return (
|
|
1612
|
+
`best placement at ${axis} ${at}, ${wallClause(axis, edge, window)} — ` +
|
|
1613
|
+
`the truth may lie ${edge === 'floor' ? 'below' : 'above'} the window`
|
|
1614
|
+
);
|
|
1615
|
+
}
|
|
1616
|
+
|
|
1617
|
+
/**
|
|
1618
|
+
* The wall named on its own — `the ceiling of --scale 0.5,2` — which is the part
|
|
1619
|
+
* of `windowEdgeNote` an ACCEPTED placement's console line carries (issue #737).
|
|
1620
|
+
*
|
|
1621
|
+
* ⭐ One clause, two sentences, so the refusal and the accepted line cannot spell
|
|
1622
|
+
* the same wall two ways. The accepted line carries the wall and not the "truth
|
|
1623
|
+
* may lie" half on purpose: measured over the corpus, a floor holds truths below
|
|
1624
|
+
* the window and parts shrunk into their own region alike, so which side the
|
|
1625
|
+
* truth is on is exactly what an accepted placement does not know.
|
|
1626
|
+
*/
|
|
1627
|
+
export function wallClause(axis: 'scale' | 'rotation', edge: 'floor' | 'ceiling', window: string): string {
|
|
1628
|
+
return `the ${edge} of --${axis} ${window}`;
|
|
1629
|
+
}
|
|
1630
|
+
|
|
1631
|
+
/**
|
|
1632
|
+
* The walls of the window one placement stands on — the single reading both a
|
|
1633
|
+
* refusal and an accepted placement are marked from (issue #737).
|
|
1634
|
+
*
|
|
1635
|
+
* 🔑 Read off the UNROUNDED candidate, because the clamp writes the bound itself
|
|
1636
|
+
* and so "on" is an equality — while the reported `scale` is rounded to five
|
|
1637
|
+
* places and a window need not be: under `--scale 0.0931424…,0.3725…` the report
|
|
1638
|
+
* prints `0.09314`, below its own floor, for a candidate the clamp put exactly on
|
|
1639
|
+
* it. The `1e-9` is float slack on a ladder rung computed as `min + span·i/steps`,
|
|
1640
|
+
* not a notion of "near": the nearest correct placement that settled inside the
|
|
1641
|
+
* window was measured 0.125% off its wall, the polish's last scale step.
|
|
1642
|
+
*
|
|
1643
|
+
* A window with no interior — `min === max` — has no wall to be at: being at the
|
|
1644
|
+
* only value there is says nothing about where the answer is (issue #719). A
|
|
1645
|
+
* window spanning a full turn has none either, and neither has the rotation of a
|
|
1646
|
+
* `rotationFree` part, whose `0°` is a placeholder the search never moved.
|
|
1647
|
+
*/
|
|
1648
|
+
export function placementWalls(
|
|
1649
|
+
scale: number,
|
|
1650
|
+
rotationDeg: number,
|
|
1651
|
+
scaleBounds: { min: number; max: number },
|
|
1652
|
+
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1653
|
+
rotationFree: boolean,
|
|
1654
|
+
): PoseWall[] {
|
|
1655
|
+
const walls: PoseWall[] = [];
|
|
1656
|
+
const at = (value: number, bound: number): boolean => Math.abs(value - bound) <= 1e-9 * Math.max(1, Math.abs(bound));
|
|
1657
|
+
if (scaleBounds.max > scaleBounds.min) {
|
|
1658
|
+
const window = `${scaleBounds.min},${scaleBounds.max}`;
|
|
1659
|
+
if (at(scale, scaleBounds.min)) walls.push({ axis: 'scale', edge: 'floor', window });
|
|
1660
|
+
else if (at(scale, scaleBounds.max)) walls.push({ axis: 'scale', edge: 'ceiling', window });
|
|
1661
|
+
}
|
|
1662
|
+
if (!rotationFree && !rotationBounds.wraps && rotationBounds.max > rotationBounds.min) {
|
|
1663
|
+
const window = `${rotationBounds.min},${rotationBounds.max}`;
|
|
1664
|
+
if (at(rotationDeg, rotationBounds.min)) walls.push({ axis: 'rotation', edge: 'floor', window });
|
|
1665
|
+
else if (at(rotationDeg, rotationBounds.max)) walls.push({ axis: 'rotation', edge: 'ceiling', window });
|
|
1666
|
+
}
|
|
1667
|
+
return walls;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
/** The PNGs in a directory, in name order — the parts, and the order the report lists them. */
|
|
1671
|
+
export function partFiles(imagesDir: string, exclude: string): string[] {
|
|
1672
|
+
const dir = resolve(imagesDir);
|
|
1673
|
+
if (!existsSync(dir)) throw new PoseError(`no parts directory at ${dir}`);
|
|
1674
|
+
if (!statSync(dir).isDirectory()) throw new PoseError(`${dir} is not a directory — --images takes the directory the part PNGs are in`);
|
|
1675
|
+
const excluded = resolve(exclude);
|
|
1676
|
+
const files = readdirSync(dir)
|
|
1677
|
+
.filter((f) => f.toLowerCase().endsWith('.png'))
|
|
1678
|
+
.map((f) => join(dir, f))
|
|
1679
|
+
.filter((f) => resolve(f) !== excluded)
|
|
1680
|
+
.sort();
|
|
1681
|
+
if (files.length === 0) throw new PoseError(`no .png files in ${dir} — there is nothing to place`);
|
|
1682
|
+
return files;
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// ---------------------------------------------------------------------------
|
|
1686
|
+
// the instrument
|
|
1687
|
+
// ---------------------------------------------------------------------------
|
|
1688
|
+
|
|
1689
|
+
export function estimatePose(options: PoseOptions): PoseReport {
|
|
1690
|
+
const framePath = resolve(options.framePath);
|
|
1691
|
+
if (!existsSync(framePath)) throw new PoseError(`no pose frame at ${framePath}`);
|
|
1692
|
+
let frame: Plate;
|
|
1693
|
+
try {
|
|
1694
|
+
frame = readPlate(framePath);
|
|
1695
|
+
} catch (err) {
|
|
1696
|
+
throw new PoseError(`cannot read the pose frame ${framePath}: ${(err as Error).message}`);
|
|
1697
|
+
}
|
|
1698
|
+
const paths =
|
|
1699
|
+
options.parts === undefined
|
|
1700
|
+
? partFiles(options.imagesDir, framePath)
|
|
1701
|
+
: options.parts.map((p) => resolve(p)).filter((p) => p !== framePath);
|
|
1702
|
+
if (paths.length === 0) throw new PoseError(`no parts to place — there is nothing to search for in ${framePath}`);
|
|
1703
|
+
|
|
1704
|
+
const scaleMin = options.scale?.min ?? DEFAULT_SCALE_MIN;
|
|
1705
|
+
const scaleMax = options.scale?.max ?? DEFAULT_SCALE_MAX;
|
|
1706
|
+
const rotMin = options.rotation?.minDeg ?? -180;
|
|
1707
|
+
const rotMax = options.rotation?.maxDeg ?? 180;
|
|
1708
|
+
const maxResidual = options.maxResidual ?? DEFAULT_MAX_RESIDUAL;
|
|
1709
|
+
const scales = scaleLadder(scaleMin, scaleMax);
|
|
1710
|
+
const rotations = rotationLadder(rotMin, rotMax);
|
|
1711
|
+
|
|
1712
|
+
const background = readBackground(frame);
|
|
1713
|
+
const material = materialPlate(frame, background);
|
|
1714
|
+
background.materialShare = roundTo(material.share, 4);
|
|
1715
|
+
const framePyramid = pyramid(material.plate, 6, COARSE_LONG_SIDE);
|
|
1716
|
+
const levels = framePyramid.map((p, i) => levelOf(p, 2 ** i));
|
|
1717
|
+
|
|
1718
|
+
const report: PoseReport = {
|
|
1719
|
+
spec: POSE_SPEC,
|
|
1720
|
+
space:
|
|
1721
|
+
'frame pixels, y down, origin top-left. (x, y) is where the part image\'s own centre lands; rotationDeg is ' +
|
|
1722
|
+
'screen degrees, positive clockwise; scale is frame pixels per part pixel. Reconstruct a part pixel p as ' +
|
|
1723
|
+
'centre + scale * R(rotationDeg) * (p - (width/2, height/2)). src/transform.ts converts to Spine world ' +
|
|
1724
|
+
'(screenToSpineDegrees, cropToSpineY).',
|
|
1725
|
+
images: resolve(options.imagesDir),
|
|
1726
|
+
frame: { path: framePath, width: frame.width, height: frame.height, background },
|
|
1727
|
+
search: {
|
|
1728
|
+
scale: { min: scaleMin, max: scaleMax, steps: scales.length },
|
|
1729
|
+
rotation: {
|
|
1730
|
+
minDeg: rotMin,
|
|
1731
|
+
maxDeg: rotMax,
|
|
1732
|
+
// ⚠️ Neither of these is rounded, and every other number in this report
|
|
1733
|
+
// is. Rounding them would make the reported ladder a near-copy of the
|
|
1734
|
+
// applied one, which is the defect this field exists to close — a window
|
|
1735
|
+
// that divides into thirds has angles no decimal place holds. The console
|
|
1736
|
+
// rounds for display; the record is exact.
|
|
1737
|
+
stepDeg: ladderStep(rotations),
|
|
1738
|
+
steps: rotations.length,
|
|
1739
|
+
degrees: [...rotations],
|
|
1740
|
+
},
|
|
1741
|
+
coarse: {
|
|
1742
|
+
frameLongSide: COARSE_LONG_SIDE,
|
|
1743
|
+
partSpan: COARSE_PART_SPAN,
|
|
1744
|
+
strideFraction: COARSE_STRIDE_FRACTION,
|
|
1745
|
+
framePyramid: framePyramid.length,
|
|
1746
|
+
},
|
|
1747
|
+
maxResidual,
|
|
1748
|
+
ambiguity: { absolute: AMBIGUITY_ABSOLUTE, relative: AMBIGUITY_RELATIVE },
|
|
1749
|
+
},
|
|
1750
|
+
caveats: [
|
|
1751
|
+
'No number here is a score and none of them has a pass bar. The residual says how well the placed part ' +
|
|
1752
|
+
'explains the frame under it, which is a measure of how far to trust the placement.',
|
|
1753
|
+
'Residuals degrade under OCCLUSION. A part drawn behind another has the occluder\'s pixels where its own ' +
|
|
1754
|
+
'should be, so its residual rises at the correct placement; `unexplained` is the share of the part that ' +
|
|
1755
|
+
'disagrees, and a middling residual with a high `unexplained` usually means "right place, seen through ' +
|
|
1756
|
+
'something else" rather than "wrong place". Nothing here solves for depth.',
|
|
1757
|
+
'An `ambiguous` part has two or more placements this instrument cannot separate. Both are reported. ' +
|
|
1758
|
+
'Choosing between them needs something it cannot see — anatomy, the other frame, or a human.',
|
|
1759
|
+
'A `rotationFree` part is self-similar under rotation, so its reported rotation is a placeholder and the ' +
|
|
1760
|
+
'value is yours to choose.',
|
|
1761
|
+
'The search is bounded to the scale and rotation windows named in `search`, with the part anchor placed ' +
|
|
1762
|
+
'only inside the frame canvas. ⚠️ A window that does not contain the true value does NOT reliably ' +
|
|
1763
|
+
'refuse: a part shrunk inside the region it came from still explains those pixels, so the answer is the ' +
|
|
1764
|
+
'best placement available INSIDE the window and its residual can look reasonable. That is why the window ' +
|
|
1765
|
+
'is a reported field — if the numbers surprise you, check it before you trust them. A part whose placement ' +
|
|
1766
|
+
'stopped ON a wall of the window lists it in `walls`, whatever its verdict: a refused one also names it in ' +
|
|
1767
|
+
'`refusal.detail`, an accepted one beside the value on its console line. That value is where the search was ' +
|
|
1768
|
+
'held rather than where it came to rest, and the window is the first thing to move. An empty `walls` is ' +
|
|
1769
|
+
'not evidence the truth is inside the window — a placement can settle inside it on another optimum.',
|
|
1770
|
+
],
|
|
1771
|
+
parts: [],
|
|
1772
|
+
};
|
|
1773
|
+
|
|
1774
|
+
// A window spanning a whole turn contains every angle there is, so nothing is
|
|
1775
|
+
// outside it and nothing has to be held inside it.
|
|
1776
|
+
const rotationBounds = { min: rotMin, max: rotMax, wraps: rotMax - rotMin >= 360 - 1e-9 };
|
|
1777
|
+
for (const path of paths) {
|
|
1778
|
+
report.parts.push(
|
|
1779
|
+
placePart(
|
|
1780
|
+
path,
|
|
1781
|
+
frame,
|
|
1782
|
+
levels,
|
|
1783
|
+
framePyramid,
|
|
1784
|
+
scales,
|
|
1785
|
+
rotations,
|
|
1786
|
+
maxResidual,
|
|
1787
|
+
{ min: scaleMin, max: scaleMax },
|
|
1788
|
+
rotationBounds,
|
|
1789
|
+
options.trace,
|
|
1790
|
+
),
|
|
1791
|
+
);
|
|
1792
|
+
}
|
|
1793
|
+
return report;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
function placePart(
|
|
1797
|
+
path: string,
|
|
1798
|
+
frame: Plate,
|
|
1799
|
+
levels: Level[],
|
|
1800
|
+
plates: Plate[],
|
|
1801
|
+
scales: number[],
|
|
1802
|
+
rotations: number[],
|
|
1803
|
+
maxResidual: number,
|
|
1804
|
+
scaleBounds: { min: number; max: number },
|
|
1805
|
+
rotationBounds: { min: number; max: number; wraps: boolean },
|
|
1806
|
+
trace?: PoseTrace,
|
|
1807
|
+
): PosePart {
|
|
1808
|
+
const scaleMin = scaleBounds.min;
|
|
1809
|
+
/** The scale the sample sets are sized for — the middle of the window, and NOT the scale under test. */
|
|
1810
|
+
const scaleReference = Math.sqrt(scaleBounds.min * scaleBounds.max);
|
|
1811
|
+
const name = basename(path);
|
|
1812
|
+
let part: Plate;
|
|
1813
|
+
try {
|
|
1814
|
+
part = readPlate(path);
|
|
1815
|
+
} catch (err) {
|
|
1816
|
+
return {
|
|
1817
|
+
part: name,
|
|
1818
|
+
path,
|
|
1819
|
+
width: 0,
|
|
1820
|
+
height: 0,
|
|
1821
|
+
refusal: { reason: 'empty-part', detail: `cannot decode ${name}: ${(err as Error).message}` },
|
|
1822
|
+
placement: null,
|
|
1823
|
+
walls: [],
|
|
1824
|
+
alternates: [],
|
|
1825
|
+
ambiguous: false,
|
|
1826
|
+
rotationFree: false,
|
|
1827
|
+
rotationSelfSimilarity: 1,
|
|
1828
|
+
coarse: null,
|
|
1829
|
+
legibility: null,
|
|
1830
|
+
notes: [`${name} could not be decoded, so it was not placed.`],
|
|
1831
|
+
};
|
|
1832
|
+
}
|
|
1833
|
+
const base: PosePart = {
|
|
1834
|
+
part: name,
|
|
1835
|
+
path,
|
|
1836
|
+
width: part.width,
|
|
1837
|
+
height: part.height,
|
|
1838
|
+
refusal: null,
|
|
1839
|
+
placement: null,
|
|
1840
|
+
walls: [],
|
|
1841
|
+
alternates: [],
|
|
1842
|
+
ambiguous: false,
|
|
1843
|
+
rotationFree: false,
|
|
1844
|
+
rotationSelfSimilarity: 1,
|
|
1845
|
+
coarse: null,
|
|
1846
|
+
legibility: null,
|
|
1847
|
+
notes: [],
|
|
1848
|
+
};
|
|
1849
|
+
|
|
1850
|
+
const box = materialBox(part);
|
|
1851
|
+
if (box === null) {
|
|
1852
|
+
base.refusal = { reason: 'empty-part', detail: `${name} is ${part.width}x${part.height} and every pixel of it is transparent` };
|
|
1853
|
+
base.notes.push(`${name} has no material to place.`);
|
|
1854
|
+
return base;
|
|
1855
|
+
}
|
|
1856
|
+
const tw = box.maxX - box.minX;
|
|
1857
|
+
const th = box.maxY - box.minY;
|
|
1858
|
+
const fitsUpright = tw * scaleMin <= frame.width && th * scaleMin <= frame.height;
|
|
1859
|
+
const fitsTurned = th * scaleMin <= frame.width && tw * scaleMin <= frame.height;
|
|
1860
|
+
if (!fitsUpright && !fitsTurned) {
|
|
1861
|
+
base.refusal = {
|
|
1862
|
+
reason: 'larger-than-canvas',
|
|
1863
|
+
detail:
|
|
1864
|
+
`${name}'s material is ${tw}x${th} part px; at the smallest tested scale ${scaleMin} that is ` +
|
|
1865
|
+
`${roundTo(tw * scaleMin, 1)}x${roundTo(th * scaleMin, 1)} frame px, which does not fit a ` +
|
|
1866
|
+
`${frame.width}x${frame.height} canvas at any rotation`,
|
|
1867
|
+
};
|
|
1868
|
+
base.notes.push(`${name} cannot be contained by this frame at any tested scale — lower --scale or check the pair.`);
|
|
1869
|
+
return base;
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
/** Longest side of the part's material, in part pixels — the yardstick "near" is measured in. */
|
|
1873
|
+
const span = Math.max(tw, th);
|
|
1874
|
+
const anchorX = (box.minX + box.maxX) / 2;
|
|
1875
|
+
const anchorY = (box.minY + box.maxY) / 2;
|
|
1876
|
+
|
|
1877
|
+
// Rotation freedom is settled before the search, because a part it applies to
|
|
1878
|
+
// does not need a rotation ladder at all — and searching one would invent a
|
|
1879
|
+
// precise-looking angle for a quantity that has none.
|
|
1880
|
+
const selfSimilarity = rotationSelfSimilarity(part, anchorX, anchorY);
|
|
1881
|
+
const rotationFree = selfSimilarity <= ROTATION_FREE_TOLERANCE;
|
|
1882
|
+
base.rotationFree = rotationFree;
|
|
1883
|
+
base.rotationSelfSimilarity = roundTo(selfSimilarity, 5);
|
|
1884
|
+
const searchRotations = rotationFree ? [0] : rotations;
|
|
1885
|
+
if (rotationFree) {
|
|
1886
|
+
base.notes.push(
|
|
1887
|
+
`${name} is self-similar under rotation (worst self-residual ${roundTo(selfSimilarity, 4)} over 11 probes, ` +
|
|
1888
|
+
`tolerance ${ROTATION_FREE_TOLERANCE}), so rotation is a free degree of freedom — the reported 0° is a ` +
|
|
1889
|
+
'placeholder, not a measurement.',
|
|
1890
|
+
);
|
|
1891
|
+
}
|
|
1892
|
+
|
|
1893
|
+
const partPyramid = pyramid(part, 6, 4);
|
|
1894
|
+
const samplesCache = new Map<string, Samples>();
|
|
1895
|
+
/**
|
|
1896
|
+
* The part's sample set for one search level.
|
|
1897
|
+
*
|
|
1898
|
+
* 🚨 The mip is chosen from the level and the MIDDLE of the scale window, never
|
|
1899
|
+
* from the scale being tried — and that is the whole reason scale is
|
|
1900
|
+
* identifiable at all. Sizing the sample set to each candidate scale looks
|
|
1901
|
+
* obviously right (sample the part as finely as the frame can resolve it) and
|
|
1902
|
+
* collapses the search: a small scale then gets a coarse, few-pixel mip whose
|
|
1903
|
+
* every sample is an average of a large patch, those samples land deep inside
|
|
1904
|
+
* the blob, and the residual goes to nothing. Measured on the fixture: a 22px
|
|
1905
|
+
* head found its optimum at scale 0.39 with residual 0.065, against 0.017 at
|
|
1906
|
+
* the true 1.15 — the search preferred a placement the objective itself scores
|
|
1907
|
+
* worse, because the two were not scored on the same pixels. One sample set per
|
|
1908
|
+
* level puts every candidate scale on the same material, and then a scale that
|
|
1909
|
+
* squeezes six samples into three frame pixels has to explain why they disagree.
|
|
1910
|
+
*/
|
|
1911
|
+
const samplesFor = (levelReduction: number, cap: number): Samples => {
|
|
1912
|
+
const wanted = Math.max(0, Math.round(Math.log2(Math.max(1e-6, levelReduction / scaleReference))));
|
|
1913
|
+
const mip = Math.min(partPyramid.length - 1, wanted);
|
|
1914
|
+
const key = `${mip}:${cap}`;
|
|
1915
|
+
const hit = samplesCache.get(key);
|
|
1916
|
+
if (hit) return hit;
|
|
1917
|
+
const built = buildSamples(partPyramid[mip], 2 ** mip, anchorX, anchorY, cap);
|
|
1918
|
+
samplesCache.set(key, built);
|
|
1919
|
+
return built;
|
|
1920
|
+
};
|
|
1921
|
+
|
|
1922
|
+
// ⭐ Which level the exhaustive pass runs at is a decision about the PART, not
|
|
1923
|
+
// only about the frame. The pyramid stops when the frame fits in
|
|
1924
|
+
// COARSE_LONG_SIDE; this then walks back UP it until the part still spans
|
|
1925
|
+
// COARSE_PART_SPAN pixels there, because a level that has reduced the part to
|
|
1926
|
+
// three pixels cannot say where the part is at any price — and until its short
|
|
1927
|
+
// side still spans COARSE_PART_THICKNESS, which is the same sentence about a
|
|
1928
|
+
// part that is long and thin.
|
|
1929
|
+
let coarseIndex = levels.length - 1;
|
|
1930
|
+
const thickness = Math.min(tw, th);
|
|
1931
|
+
while (
|
|
1932
|
+
coarseIndex > 0 &&
|
|
1933
|
+
((span * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_SPAN ||
|
|
1934
|
+
(thickness * scaleReference) / levels[coarseIndex].reduction < COARSE_PART_THICKNESS)
|
|
1935
|
+
) {
|
|
1936
|
+
coarseIndex--;
|
|
1937
|
+
}
|
|
1938
|
+
const coarse = levels[coarseIndex];
|
|
1939
|
+
const spanAtCoarse = (span * scaleReference) / coarse.reduction;
|
|
1940
|
+
const stride = Math.max(1, Math.round(spanAtCoarse * COARSE_STRIDE_FRACTION));
|
|
1941
|
+
const coarseSamples = samplesFor(coarse.reduction, COARSE_SAMPLES);
|
|
1942
|
+
let candidates: Candidate[] = [];
|
|
1943
|
+
let grid = { reduction: coarse.reduction, cols: 0, rows: 0, stride };
|
|
1944
|
+
for (const scale of scales) {
|
|
1945
|
+
const field = coarseScan(coarse, coarseSamples, scale, searchRotations, stride);
|
|
1946
|
+
grid = { reduction: coarse.reduction, cols: field.cols, rows: field.rows, stride };
|
|
1947
|
+
candidates.push(...localMinima(field, COARSE_SUPPRESSION_CELLS, MINIMA_PER_SCALE));
|
|
1948
|
+
}
|
|
1949
|
+
base.coarse = grid;
|
|
1950
|
+
candidates.sort((a, b) => a.residual - b.residual);
|
|
1951
|
+
if (candidates.length === 0) {
|
|
1952
|
+
base.refusal = { reason: 'no-match', detail: `${name}: the coarse scan found no finite placement in this frame` };
|
|
1953
|
+
base.notes.push(`${name} matched nowhere in this frame.`);
|
|
1954
|
+
return base;
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
// Refine down the pyramid. Every level doubles the coordinates and halves the
|
|
1958
|
+
// steps; the candidate list narrows as it goes so the budget follows the
|
|
1959
|
+
// placements that are still plausible.
|
|
1960
|
+
//
|
|
1961
|
+
// 🚨 One level RE-GRIDS rotation instead of narrowing, for the same reason the
|
|
1962
|
+
// coarse fields are kept apart by scale: a blurred blob does not have a
|
|
1963
|
+
// measurable angle either, and the field keeps only one rotation per cell. On
|
|
1964
|
+
// the fixture the right arm was found at exactly the right PLACE carrying
|
|
1965
|
+
// rotation -122 degrees, which no 7.5 degree local step could ever leave — and
|
|
1966
|
+
// that arm is one half of the two-identical-limbs answer the whole instrument
|
|
1967
|
+
// exists to report. Re-gridding the ladder at the first level with real detail
|
|
1968
|
+
// brought it back at +35.
|
|
1969
|
+
const branchLevel = Math.max(0, coarseIndex - 1);
|
|
1970
|
+
/** How many rotations survive the re-grid at the branch level, per position. */
|
|
1971
|
+
const BRANCH_ROTATIONS = 3;
|
|
1972
|
+
let rotStep = rotationFree ? 0 : COARSE_ROTATION_STEP / 2;
|
|
1973
|
+
for (let li = coarseIndex; li >= 0; li--) {
|
|
1974
|
+
const level = levels[li];
|
|
1975
|
+
const plate = plates[li];
|
|
1976
|
+
const smooth = li !== coarseIndex;
|
|
1977
|
+
const branch = li === branchLevel;
|
|
1978
|
+
const keep = li === coarseIndex ? scales.length * MINIMA_PER_SCALE : REFINE_CANDIDATES;
|
|
1979
|
+
const s = samplesFor(level.reduction, li === 0 ? POLISH_SAMPLES : REFINE_SAMPLES);
|
|
1980
|
+
// The coarse pass only sampled every `stride` pixels, so entering the
|
|
1981
|
+
// refinement the anchor can be half a stride out; the first polish gets a
|
|
1982
|
+
// step big enough to cross that rather than a step that assumes a pixel.
|
|
1983
|
+
const step = { translate: li === coarseIndex ? Math.max(1.5, stride) : 1.5, rotate: rotStep, scale: 0.08 };
|
|
1984
|
+
const floor =
|
|
1985
|
+
li === 0 ? { translate: 0.05, rotate: 0.1, scale: 0.001 } : { translate: 0.25, rotate: 0.5, scale: 0.01 };
|
|
1986
|
+
const seeds: Candidate[] = [];
|
|
1987
|
+
for (const start of candidates.slice(0, keep)) {
|
|
1988
|
+
if (branch && !rotationFree) {
|
|
1989
|
+
const grid: Candidate[] = [];
|
|
1990
|
+
for (const rotDeg of searchRotations) {
|
|
1991
|
+
const probe: Candidate = { ...start, rotDeg, residual: 0 };
|
|
1992
|
+
probe.residual = residualAt(level, plate, s, probe, smooth);
|
|
1993
|
+
grid.push(probe);
|
|
1994
|
+
}
|
|
1995
|
+
grid.sort((a, b) => a.residual - b.residual);
|
|
1996
|
+
// One position and one scale throughout, so this dedupe is a spread over
|
|
1997
|
+
// rotation alone: an angle within 25 degrees of one already kept is the
|
|
1998
|
+
// same basin under a slightly different name.
|
|
1999
|
+
seeds.push(...dedupe(grid, 1, 25, Infinity).slice(0, BRANCH_ROTATIONS));
|
|
2000
|
+
continue;
|
|
2001
|
+
}
|
|
2002
|
+
seeds.push(start);
|
|
2003
|
+
}
|
|
2004
|
+
const paths = seeds.map((): { start: number; steps: PoseTraceStep[] } => ({ start: 0, steps: [] }));
|
|
2005
|
+
candidates = seeds.map((seed, i) =>
|
|
2006
|
+
polish(level, plate, s, seed, step, floor, smooth, scaleBounds, rotationBounds, li === 0 ? POLISH_SCALE_ESCAPES : [], trace === undefined ? undefined : paths[i]),
|
|
2007
|
+
);
|
|
2008
|
+
const polished = candidates.slice();
|
|
2009
|
+
candidates.sort((a, b) => a.residual - b.residual);
|
|
2010
|
+
// ⚠️ Eight branches that walked to one optimum are one candidate, not eight —
|
|
2011
|
+
// and the radius has to scale with the PART rather than be a pixel count.
|
|
2012
|
+
// Narrowing by residual alone lets near-copies of the best hill fill the
|
|
2013
|
+
// budget and crowd the SECOND hill out, which is exactly the answer this
|
|
2014
|
+
// instrument exists to keep: with a fixed one-pixel radius the fixture lost
|
|
2015
|
+
// one of its two identical arms.
|
|
2016
|
+
candidates = dedupe(candidates, Math.max(1, (0.2 * span * scaleReference) / level.reduction), 5, 1.03);
|
|
2017
|
+
if (trace !== undefined) {
|
|
2018
|
+
const ou = part.width / 2 - anchorX;
|
|
2019
|
+
const ov = part.height / 2 - anchorY;
|
|
2020
|
+
const said = (c: Candidate): PoseTraceCandidate => {
|
|
2021
|
+
const cos = Math.cos(c.rotDeg * DEG) * c.scale;
|
|
2022
|
+
const sin = Math.sin(c.rotDeg * DEG) * c.scale;
|
|
2023
|
+
return {
|
|
2024
|
+
x: c.cx * level.reduction + ou * cos - ov * sin,
|
|
2025
|
+
y: c.cy * level.reduction + ou * sin + ov * cos,
|
|
2026
|
+
rotationDeg: normaliseDegrees(c.rotDeg),
|
|
2027
|
+
scale: c.scale,
|
|
2028
|
+
residual: c.residual,
|
|
2029
|
+
};
|
|
2030
|
+
};
|
|
2031
|
+
let probeResidual: number | null = null;
|
|
2032
|
+
if (trace.probe !== undefined) {
|
|
2033
|
+
const q = trace.probe;
|
|
2034
|
+
const cos = Math.cos(q.rotationDeg * DEG) * q.scale;
|
|
2035
|
+
const sin = Math.sin(q.rotationDeg * DEG) * q.scale;
|
|
2036
|
+
const at: Candidate = {
|
|
2037
|
+
cx: (q.x - (ou * cos - ov * sin)) / level.reduction,
|
|
2038
|
+
cy: (q.y - (ou * sin + ov * cos)) / level.reduction,
|
|
2039
|
+
rotDeg: q.rotationDeg,
|
|
2040
|
+
scale: q.scale,
|
|
2041
|
+
residual: 0,
|
|
2042
|
+
};
|
|
2043
|
+
probeResidual = residualAt(level, plate, s, at, smooth);
|
|
2044
|
+
}
|
|
2045
|
+
trace.levels.push({
|
|
2046
|
+
part: name,
|
|
2047
|
+
level: li,
|
|
2048
|
+
reduction: level.reduction,
|
|
2049
|
+
keep,
|
|
2050
|
+
seeds: seeds.map(said),
|
|
2051
|
+
starts: paths.map((p) => p.start),
|
|
2052
|
+
paths: paths.map((p) => p.steps),
|
|
2053
|
+
polished: polished.map(said),
|
|
2054
|
+
out: candidates.map(said),
|
|
2055
|
+
probeResidual,
|
|
2056
|
+
coarseRotations:
|
|
2057
|
+
li === coarseIndex
|
|
2058
|
+
? {
|
|
2059
|
+
ladder: searchRotations.slice(),
|
|
2060
|
+
residuals: seeds.map((seed) =>
|
|
2061
|
+
searchRotations.map((rotDeg) => coarseCellResidual(level, coarseSamples, seed.scale, rotDeg, seed.cx, seed.cy)),
|
|
2062
|
+
),
|
|
2063
|
+
}
|
|
2064
|
+
: null,
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
if (li > 0) {
|
|
2068
|
+
candidates = candidates.map((c) => ({ ...c, cx: c.cx * 2, cy: c.cy * 2 }));
|
|
2069
|
+
// A rotation-free part keeps its step at zero all the way down, so the
|
|
2070
|
+
// polish never touches an angle that means nothing and the report's 0° is
|
|
2071
|
+
// the placeholder it says it is rather than a wandered-to number.
|
|
2072
|
+
if (!rotationFree) rotStep = Math.max(1, rotStep / 2);
|
|
2073
|
+
}
|
|
2074
|
+
}
|
|
2075
|
+
|
|
2076
|
+
// The one rotation family the translation scan cannot see: a part that is its
|
|
2077
|
+
// own mirror after a quarter or a half turn sits in the SAME place at more than
|
|
2078
|
+
// one angle, so the field records only whichever won. Probe them explicitly.
|
|
2079
|
+
//
|
|
2080
|
+
// 🚨 Only the turns the window contains, and this is the other half of #719's
|
|
2081
|
+
// measurement. A quarter turn off is a SEED, not a ladder rung — so under
|
|
2082
|
+
// `--rotation -5,5` it entered the answer from outside a window the report was
|
|
2083
|
+
// calling the search, and the candidate who ran the exam read `rot=91.2°`
|
|
2084
|
+
// under `rotation -5°–5°`. A caller who bounds the rotation has said the part
|
|
2085
|
+
// is not a quarter turn over; the honest response is not to look there rather
|
|
2086
|
+
// than to look and report it.
|
|
2087
|
+
if (!rotationFree && candidates.length > 0) {
|
|
2088
|
+
const primary = candidates[0];
|
|
2089
|
+
const s = samplesFor(1, POLISH_SAMPLES);
|
|
2090
|
+
for (const turn of [90, 180, 270]) {
|
|
2091
|
+
const turned = primary.rotDeg + turn;
|
|
2092
|
+
if (!rotationBounds.wraps && (turned < rotationBounds.min - 1e-9 || turned > rotationBounds.max + 1e-9)) continue;
|
|
2093
|
+
candidates.push(
|
|
2094
|
+
polish(
|
|
2095
|
+
levels[0],
|
|
2096
|
+
plates[0],
|
|
2097
|
+
s,
|
|
2098
|
+
{ ...primary, rotDeg: turned },
|
|
2099
|
+
{ translate: 1.5, rotate: 4, scale: 0.04 },
|
|
2100
|
+
{ translate: 0.05, rotate: 0.1, scale: 0.001 },
|
|
2101
|
+
true,
|
|
2102
|
+
scaleBounds,
|
|
2103
|
+
rotationBounds,
|
|
2104
|
+
[],
|
|
2105
|
+
),
|
|
2106
|
+
);
|
|
2107
|
+
}
|
|
2108
|
+
candidates.sort((a, b) => a.residual - b.residual);
|
|
2109
|
+
}
|
|
2110
|
+
|
|
2111
|
+
// Measured at full resolution over every pixel, then de-duplicated: two
|
|
2112
|
+
// candidates that walked to the same optimum are one answer, not two.
|
|
2113
|
+
const measured = candidates.map((cand) => ({ cand, placement: toPlacement(part, anchorX, anchorY, cand, measure(levels[0], plates[0], part, anchorX, anchorY, cand)) }));
|
|
2114
|
+
measured.sort((a, b) => a.placement.residual - b.placement.residual || b.placement.footprint - a.placement.footprint);
|
|
2115
|
+
trace?.measured.push({
|
|
2116
|
+
part: name,
|
|
2117
|
+
candidates: measured.map((m) => ({
|
|
2118
|
+
x: m.placement.x,
|
|
2119
|
+
y: m.placement.y,
|
|
2120
|
+
rotationDeg: m.placement.rotationDeg,
|
|
2121
|
+
scale: m.placement.scale,
|
|
2122
|
+
residual: m.placement.residual,
|
|
2123
|
+
})),
|
|
2124
|
+
});
|
|
2125
|
+
const distinct: typeof measured = [];
|
|
2126
|
+
for (const m of measured) {
|
|
2127
|
+
const same = distinct.some(
|
|
2128
|
+
(d) =>
|
|
2129
|
+
Math.hypot(d.placement.x - m.placement.x, d.placement.y - m.placement.y) <= Math.max(1.5, 0.03 * span * m.placement.scale) &&
|
|
2130
|
+
Math.abs(normaliseDegrees(d.placement.rotationDeg - m.placement.rotationDeg)) <= 5 &&
|
|
2131
|
+
Math.abs(Math.log(d.placement.scale / m.placement.scale)) <= Math.log(1.05),
|
|
2132
|
+
);
|
|
2133
|
+
if (!same) distinct.push(m);
|
|
2134
|
+
}
|
|
2135
|
+
|
|
2136
|
+
const best = distinct[0].placement;
|
|
2137
|
+
const second = distinct.length > 1 ? distinct[1].placement.residual : null;
|
|
2138
|
+
base.legibility = {
|
|
2139
|
+
width: tw,
|
|
2140
|
+
height: th,
|
|
2141
|
+
span: roundTo(span * best.scale, 1),
|
|
2142
|
+
opaqueShare: roundTo(box.weight / (tw * th), 4),
|
|
2143
|
+
texture: roundTo(partTexture(part), 4),
|
|
2144
|
+
detail: roundTo(partTexture(part) * span, 3),
|
|
2145
|
+
candidates: distinct.length,
|
|
2146
|
+
best: best.residual,
|
|
2147
|
+
next: second,
|
|
2148
|
+
spread: second === null ? null : roundTo(second - best.residual, 5),
|
|
2149
|
+
floor: 'above',
|
|
2150
|
+
reading: null,
|
|
2151
|
+
};
|
|
2152
|
+
// The part's OWN size, not `span`: `span` is scaled by the best placement,
|
|
2153
|
+
// and on the parts this reading exists for that placement is the one in
|
|
2154
|
+
// doubt — a gun cut at 32 px read as 16 px under the half-scale optimum it
|
|
2155
|
+
// wrongly settled on. The floor was measured at scale 1, where the two agree.
|
|
2156
|
+
base.legibility.floor = floorSide(Math.max(tw, th), base.legibility.detail);
|
|
2157
|
+
const margin = Math.max(AMBIGUITY_ABSOLUTE, best.residual * AMBIGUITY_RELATIVE);
|
|
2158
|
+
const close = distinct.slice(1).filter((d) => d.placement.residual - best.residual <= margin);
|
|
2159
|
+
base.placement = best;
|
|
2160
|
+
base.alternates = close.slice(0, MAX_ALTERNATES).map((d) => d.placement);
|
|
2161
|
+
base.ambiguous = close.length > 0;
|
|
2162
|
+
if (base.ambiguous) {
|
|
2163
|
+
base.notes.push(
|
|
2164
|
+
`${name} has ${close.length + 1} placements within ${roundTo(margin, 4)} residual of each other — all of them ` +
|
|
2165
|
+
'are reported and none was picked. Two identical limbs look exactly like this; so does a part that fits ' +
|
|
2166
|
+
'its own silhouette at more than one angle.',
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
// 🔒 Which walls the answer stands on, read once and for every verdict — the
|
|
2170
|
+
// refusal below quotes them and an accepted line carries them (issue #737).
|
|
2171
|
+
// The candidate rather than the rounded placement, and in the window's own
|
|
2172
|
+
// spelling: the clamp holds `rotDeg` inside `170,190` as written, so its
|
|
2173
|
+
// ceiling is 190 there even though the report normalises it to −170°.
|
|
2174
|
+
base.walls = placementWalls(distinct[0].cand.scale, distinct[0].cand.rotDeg, scaleBounds, rotationBounds, rotationFree);
|
|
2175
|
+
if (best.residual > maxResidual) {
|
|
2176
|
+
// ⭐ The wall the answer stopped against, named in the refusal that reports
|
|
2177
|
+
// it (issue #719). A refusal that states only the residual and the threshold
|
|
2178
|
+
// sends an author to the one remedy that cannot work — every part of a frame
|
|
2179
|
+
// rendered below the scale floor came back refused at the floor, and the
|
|
2180
|
+
// window that could not reach the truth was a line further up the report
|
|
2181
|
+
// nobody was told to read.
|
|
2182
|
+
//
|
|
2183
|
+
// A window with no interior — `min === max` — has no wall to be at, so it
|
|
2184
|
+
// gets no sentence: being at the only value there is says nothing about
|
|
2185
|
+
// where the truth is. `placementWalls` holds that rule for both verdicts.
|
|
2186
|
+
const edges = base.walls.map((wall) =>
|
|
2187
|
+
windowEdgeNote(
|
|
2188
|
+
wall.axis,
|
|
2189
|
+
wall.edge,
|
|
2190
|
+
wall.axis === 'scale' ? best.scale.toFixed(3) : `${best.rotationDeg.toFixed(1)}°`,
|
|
2191
|
+
wall.window,
|
|
2192
|
+
),
|
|
2193
|
+
);
|
|
2194
|
+
base.refusal = {
|
|
2195
|
+
reason: 'no-match',
|
|
2196
|
+
detail:
|
|
2197
|
+
`${name}: the best placement found has residual ${best.residual.toFixed(4)}, above --max-residual ${maxResidual}` +
|
|
2198
|
+
(edges.length === 0 ? '' : `; ${edges.join('; ')}`),
|
|
2199
|
+
};
|
|
2200
|
+
base.notes.push(
|
|
2201
|
+
`${name} matches nowhere in this frame well enough to report. The best placement found is still in ` +
|
|
2202
|
+
'`placement` — a refusal names why not to trust it, it does not hide it.',
|
|
2203
|
+
);
|
|
2204
|
+
if (edges.length > 0) {
|
|
2205
|
+
base.notes.push(
|
|
2206
|
+
`${name}'s best placement sits on a wall of the search window, so the window is the first thing to move: ` +
|
|
2207
|
+
`${edges.join('; ')}.`,
|
|
2208
|
+
);
|
|
2209
|
+
}
|
|
2210
|
+
} else if (base.walls.length > 0) {
|
|
2211
|
+
// ⭐ And an ACCEPTED placement on a wall says so too (issue #737), which
|
|
2212
|
+
// #719 had declined on the grounds that a window chosen to bracket the
|
|
2213
|
+
// answer puts correct placements near its edges. Near, measured, is not on:
|
|
2214
|
+
// the refinement is clamped, so a value held by a wall IS the bound, while
|
|
2215
|
+
// correct placements that settled inside stopped short of their wall by at
|
|
2216
|
+
// least the polish's last step. The mark is on the placements whose number
|
|
2217
|
+
// is the window's rather than the picture's, and not on the ones a
|
|
2218
|
+
// well-chosen window merely brackets closely.
|
|
2219
|
+
//
|
|
2220
|
+
// ⚠️ It names the wall and nothing more — not "the truth may lie beyond",
|
|
2221
|
+
// which a refusal says. Measured on the rendered corpus under a window
|
|
2222
|
+
// bracketing every truth, the floor held parts shrunk into their own region
|
|
2223
|
+
// whose truth was inside the window, so which side the truth is on is the one
|
|
2224
|
+
// thing an accepted placement on a wall does not know.
|
|
2225
|
+
base.notes.push(
|
|
2226
|
+
`${name} was accepted, but its placement stopped ON a wall of the search window rather than settling inside ` +
|
|
2227
|
+
'it, so that value is where the search was held and not where it came to rest: ' +
|
|
2228
|
+
`${base.walls.map((wall) => wallClause(wall.axis, wall.edge, wall.window)).join('; ')}.`,
|
|
2229
|
+
);
|
|
2230
|
+
}
|
|
2231
|
+
// ⭐ Which of the two readings a refusal or an ambiguity is (issue #857),
|
|
2232
|
+
// in the refusal's own detail and in a note, from the figures `legibility`
|
|
2233
|
+
// already carries. After the walls on purpose: a wall is the first thing to
|
|
2234
|
+
// move, and this sentence is about what moving it cannot change.
|
|
2235
|
+
if (base.refusal !== null || base.ambiguous) {
|
|
2236
|
+
const verdict = base.refusal !== null ? 'refused' : 'ambiguous';
|
|
2237
|
+
const reading = legibilityReading(base.legibility, verdict);
|
|
2238
|
+
base.legibility.reading = reading;
|
|
2239
|
+
if (base.refusal !== null) base.refusal.detail += `; ${reading}`;
|
|
2240
|
+
base.notes.push(`${name}: ${reading}.`);
|
|
2241
|
+
}
|
|
2242
|
+
if (best.unexplained > 0.25 && best.residual <= maxResidual) {
|
|
2243
|
+
base.notes.push(
|
|
2244
|
+
`${roundTo(best.unexplained * 100, 1)}% of ${name}'s material disagrees with the frame at this placement. ` +
|
|
2245
|
+
'Another part drawn over it is the usual reason; the placement can be right and the residual still high.',
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
if (best.offCanvas > 0.01) {
|
|
2249
|
+
base.notes.push(`${roundTo(best.offCanvas * 100, 1)}% of ${name}'s material falls outside the frame canvas at this placement.`);
|
|
2250
|
+
}
|
|
2251
|
+
return base;
|
|
2252
|
+
}
|
|
2253
|
+
|
|
2254
|
+
// ---------------------------------------------------------------------------
|
|
2255
|
+
// the console report
|
|
2256
|
+
// ---------------------------------------------------------------------------
|
|
2257
|
+
|
|
2258
|
+
/**
|
|
2259
|
+
* One placement as the console prints it, with the wall each value stands on
|
|
2260
|
+
* written beside that value (issue #737) — `scale=2.000 (the ceiling of --scale
|
|
2261
|
+
* 0.5,2)` — so the number and the fact that the window chose it are read
|
|
2262
|
+
* together. Only an accepted part's own placement is passed walls: a refusal
|
|
2263
|
+
* already prints the whole sentence on the line below it.
|
|
2264
|
+
*/
|
|
2265
|
+
function placementLine(p: PosePlacement, walls: readonly PoseWall[] = []): string {
|
|
2266
|
+
const beside = (axis: PoseWall['axis']): string =>
|
|
2267
|
+
walls
|
|
2268
|
+
.filter((wall) => wall.axis === axis)
|
|
2269
|
+
.map((wall) => ` (${wallClause(wall.axis, wall.edge, wall.window)})`)
|
|
2270
|
+
.join('');
|
|
2271
|
+
return (
|
|
2272
|
+
`x=${p.x.toFixed(1).padStart(7)} y=${p.y.toFixed(1).padStart(7)} rot=${p.rotationDeg.toFixed(1).padStart(7)}°${beside('rotation')} ` +
|
|
2273
|
+
`scale=${p.scale.toFixed(3)}${beside('scale')} residual=${p.residual.toFixed(4)} unexplained=${(p.unexplained * 100).toFixed(0).padStart(3)}%`
|
|
2274
|
+
);
|
|
2275
|
+
}
|
|
2276
|
+
|
|
2277
|
+
export function poseLines(report: PoseReport): string[] {
|
|
2278
|
+
const bg = report.frame.background;
|
|
2279
|
+
const bgText =
|
|
2280
|
+
bg.kind === 'colour' && bg.colour !== null
|
|
2281
|
+
? `rgb(${bg.colour.join(', ')}) over ${(bg.borderShare * 100).toFixed(0)}% of the border ring`
|
|
2282
|
+
: bg.kind === 'transparent'
|
|
2283
|
+
? `transparency over ${(bg.borderShare * 100).toFixed(0)}% of the border ring`
|
|
2284
|
+
: 'UNKNOWN — the border ring has no dominant colour, so every pixel counts as material and the silhouette ' +
|
|
2285
|
+
'signal is gone; residuals here are colour agreement only';
|
|
2286
|
+
const lines = [
|
|
2287
|
+
` .. frame ${report.frame.path} (${report.frame.width}x${report.frame.height})`,
|
|
2288
|
+
` .. ground ${bgText}`,
|
|
2289
|
+
` .. parts ${report.images} (${report.parts.length} png)`,
|
|
2290
|
+
` .. search scale ${report.search.scale.min}–${report.search.scale.max} in ${report.search.scale.steps} step(s) · ` +
|
|
2291
|
+
// The step is the ladder's own rather than the constant it was capped at.
|
|
2292
|
+
// Printing the constant here is what issue #719 was: a line that said
|
|
2293
|
+
// `step 15°` over a window ten degrees wide, which no run had ever walked.
|
|
2294
|
+
`${searchRotationClause(report.search.rotation)} · ` +
|
|
2295
|
+
`refuse above residual ${report.search.maxResidual}`,
|
|
2296
|
+
];
|
|
2297
|
+
const width = Math.max(8, ...report.parts.map((p) => p.part.length));
|
|
2298
|
+
for (const part of report.parts) {
|
|
2299
|
+
const label = part.part.padEnd(width);
|
|
2300
|
+
if (part.placement === null) {
|
|
2301
|
+
lines.push(` REFUSE ${label} ${part.refusal?.reason ?? 'unplaced'}: ${part.refusal?.detail ?? ''}`);
|
|
2302
|
+
continue;
|
|
2303
|
+
}
|
|
2304
|
+
const tag = part.refusal !== null ? 'REFUSE' : part.ambiguous ? 'AMBIG ' : 'PLACE ';
|
|
2305
|
+
lines.push(` ${tag} ${label} ${placementLine(part.placement, part.refusal === null ? part.walls : [])}`);
|
|
2306
|
+
if (part.coarse !== null) {
|
|
2307
|
+
lines.push(
|
|
2308
|
+
` ${' '.repeat(width)} found on a ${part.coarse.cols}x${part.coarse.rows} anchor grid, ` +
|
|
2309
|
+
`step ${part.coarse.stride} at ${part.coarse.reduction}x reduction`,
|
|
2310
|
+
);
|
|
2311
|
+
}
|
|
2312
|
+
if (part.rotationFree) lines.push(` ${' '.repeat(width)} rotation is a FREE degree of freedom — the 0° above is a placeholder`);
|
|
2313
|
+
part.alternates.forEach((alt, i) => {
|
|
2314
|
+
lines.push(` ${' '.repeat(width)} alt ${i + 2}: ${placementLine(alt)}`);
|
|
2315
|
+
});
|
|
2316
|
+
if (part.refusal !== null) lines.push(` ${' '.repeat(width)} ${part.refusal.reason}: ${part.refusal.detail}`);
|
|
2317
|
+
else if (part.legibility?.reading) lines.push(` ${' '.repeat(width)} ambiguous: ${part.legibility.reading}`);
|
|
2318
|
+
}
|
|
2319
|
+
lines.push('');
|
|
2320
|
+
lines.push(' .. residuals are a trust signal, not a score — nothing here has a pass bar.');
|
|
2321
|
+
lines.push(' .. they degrade under occlusion: a high `unexplained` on a plausible placement usually means');
|
|
2322
|
+
lines.push(' .. the part is drawn behind something, not that it is in the wrong place.');
|
|
2323
|
+
return lines;
|
|
2324
|
+
}
|