@graciousstar/node-red-contrib-vision-tools 1.0.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/ARCHITECTURE.md +875 -0
- package/CHANGELOG.md +524 -0
- package/LICENSE +202 -0
- package/README.md +992 -0
- package/barcode-locate.html +256 -0
- package/barcode-locate.js +156 -0
- package/checkerboard-calibrate.html +167 -0
- package/checkerboard-calibrate.js +282 -0
- package/examples/label-crop-with-line-finder.json +239 -0
- package/golden-compare.html +713 -0
- package/golden-compare.js +1126 -0
- package/icons/checkerboard-calibrate.svg +8 -0
- package/icons/golden-compare.svg +6 -0
- package/label-crop.html +1178 -0
- package/label-crop.js +250 -0
- package/lib/align.js +867 -0
- package/lib/checkerboard.js +331 -0
- package/lib/compare.js +1338 -0
- package/lib/components.js +84 -0
- package/lib/dilate.js +65 -0
- package/lib/inspector.js +188 -0
- package/lib/inspectorCore.js +128 -0
- package/lib/inspectorWorker.js +40 -0
- package/lib/integral.js +76 -0
- package/lib/labelCrop.js +1461 -0
- package/lib/lineFinder.js +765 -0
- package/lib/localAlign.js +360 -0
- package/lib/locate.js +302 -0
- package/lib/nativeSeed.js +292 -0
- package/lib/parallel.js +428 -0
- package/lib/pool.js +250 -0
- package/lib/poolWorker.js +324 -0
- package/lib/scaleFile.js +83 -0
- package/lib/shared.js +87 -0
- package/lib/threshold.js +231 -0
- package/lib/transformFile.js +169 -0
- package/lib/warp.js +216 -0
- package/line-finder.html +1361 -0
- package/line-finder.js +280 -0
- package/package.json +73 -0
package/lib/align.js
ADDED
|
@@ -0,0 +1,867 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recovering the transform that places the golden template inside a
|
|
3
|
+
* camera frame: independent x/y magnification, rotation, translation.
|
|
4
|
+
*
|
|
5
|
+
* The original node searched translation only, which assumes the golden
|
|
6
|
+
* and the frame already share a pixel scale and orientation. That holds
|
|
7
|
+
* for a re-trained golden off the same fixtured rig and nothing else.
|
|
8
|
+
* This node's actual job is comparing a *PDF of a label* against a
|
|
9
|
+
* photograph of that label printed, where the artwork has no reason to
|
|
10
|
+
* match the camera's px-per-mm at all.
|
|
11
|
+
*
|
|
12
|
+
* Scale has to be anisotropic, which is the part that is easy to get
|
|
13
|
+
* wrong. Measured on the real pairs here, the print comes out ~4% longer
|
|
14
|
+
* than the artwork along one axis with the other axis correct - ordinary
|
|
15
|
+
* behaviour for a press whose media feed and print-head axes are not
|
|
16
|
+
* calibrated to each other. A single isotropic scale cannot represent
|
|
17
|
+
* that: the best it can do is split the error, leaving every feature
|
|
18
|
+
* several pixels out toward the ends of the long axis. On body text
|
|
19
|
+
* several pixels is the whole stroke, so ~12% of all pixels disagree and
|
|
20
|
+
* both blemish checks fail on a perfectly good part. Independent mx and
|
|
21
|
+
* my remove it.
|
|
22
|
+
*
|
|
23
|
+
* The model, mapping a golden-working-resolution pixel (gx,gy) into the
|
|
24
|
+
* frame's working canvas - scale first, then rotate, then translate:
|
|
25
|
+
*
|
|
26
|
+
* target_x = ox + cos(theta)*mx*gx - sin(theta)*my*gy
|
|
27
|
+
* target_y = oy + sin(theta)*mx*gx + cos(theta)*my*gy
|
|
28
|
+
*
|
|
29
|
+
* mx/my are "frame px per golden px"; greater than 1 means the frame
|
|
30
|
+
* resolves the part more finely than the golden does.
|
|
31
|
+
*
|
|
32
|
+
* Scoring is mean absolute difference of *ink density*, sampled on a
|
|
33
|
+
* lattice of cells: each golden cell has a precomputed density, and the
|
|
34
|
+
* matching frame density is a box average read in O(1) from a summed-area
|
|
35
|
+
* table over the frame's foreground mask. One candidate therefore costs
|
|
36
|
+
* (number of grid cells), independent of either image's resolution -
|
|
37
|
+
* which is what makes sweeping whole ladders of scale affordable.
|
|
38
|
+
*
|
|
39
|
+
* Small angles are handled by mapping each cell's *center* through the
|
|
40
|
+
* rotation and reading an axis-aligned box there, rather than summing a
|
|
41
|
+
* true rotated rectangle. Over the few degrees this covers it is the
|
|
42
|
+
* cell-center displacement that carries the signal, the shape error
|
|
43
|
+
* within a cell is negligible, and every read stays O(1).
|
|
44
|
+
*
|
|
45
|
+
* The search runs coarse-to-fine, each stage refining only within a
|
|
46
|
+
* bounded neighbourhood of the previous stage's winner:
|
|
47
|
+
*
|
|
48
|
+
* 1. scale sweep - wide ladder of isotropic scale, no rotation, coarse
|
|
49
|
+
* grid, full translation range over the whole frame
|
|
50
|
+
* 2. joint refine - overall scale, stretch and angle together on a
|
|
51
|
+
* medium grid, translation bounded to a few cells
|
|
52
|
+
* around stage 1. Stretch has to be searched here,
|
|
53
|
+
* jointly - see the note in findTransform
|
|
54
|
+
* 3. fine refine - the fine grid, each axis nudged independently,
|
|
55
|
+
* ending in a single-pixel translation sweep
|
|
56
|
+
* 4. polish - alternating sub-percent refinement of mx, my, angle
|
|
57
|
+
* and translation against real pixel disagreement
|
|
58
|
+
*/
|
|
59
|
+
|
|
60
|
+
"use strict";
|
|
61
|
+
|
|
62
|
+
const { buildIntegral } = require("./integral.js");
|
|
63
|
+
|
|
64
|
+
const DEG = Math.PI / 180;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Precompute golden's ink density on a gridW x gridH lattice, plus each
|
|
68
|
+
* cell's center in golden pixel coordinates - the fixed side of every
|
|
69
|
+
* candidate comparison.
|
|
70
|
+
*/
|
|
71
|
+
function buildGoldenSignature(fg, width, height, gridW, gridH) {
|
|
72
|
+
const table = buildIntegral(fg, width, height);
|
|
73
|
+
const { integral, stride } = table;
|
|
74
|
+
const density = new Float32Array(gridW * gridH);
|
|
75
|
+
const centerX = new Float32Array(gridW);
|
|
76
|
+
const centerY = new Float32Array(gridH);
|
|
77
|
+
for (let gy = 0; gy < gridH; gy++) {
|
|
78
|
+
const y0 = Math.floor((gy * height) / gridH);
|
|
79
|
+
const y1 = Math.floor(((gy + 1) * height) / gridH);
|
|
80
|
+
centerY[gy] = (y0 + y1) / 2;
|
|
81
|
+
for (let gx = 0; gx < gridW; gx++) {
|
|
82
|
+
const x0 = Math.floor((gx * width) / gridW);
|
|
83
|
+
const x1 = Math.floor(((gx + 1) * width) / gridW);
|
|
84
|
+
if (gy === 0) centerX[gx] = (x0 + x1) / 2;
|
|
85
|
+
const area = (x1 - x0) * (y1 - y0);
|
|
86
|
+
const sum =
|
|
87
|
+
integral[y1 * stride + x1] -
|
|
88
|
+
integral[y0 * stride + x1] -
|
|
89
|
+
integral[y1 * stride + x0] +
|
|
90
|
+
integral[y0 * stride + x0];
|
|
91
|
+
density[gy * gridW + gx] = area > 0 ? sum / area : 0;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return {
|
|
95
|
+
density,
|
|
96
|
+
centerX,
|
|
97
|
+
centerY,
|
|
98
|
+
gridW,
|
|
99
|
+
gridH,
|
|
100
|
+
cellW: width / gridW,
|
|
101
|
+
cellH: height / gridH,
|
|
102
|
+
width,
|
|
103
|
+
height,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Mean absolute density difference between the golden signature and the
|
|
109
|
+
* frame under one candidate transform. Cells landing outside the frame
|
|
110
|
+
* are skipped, and a candidate that pushes too much of the template
|
|
111
|
+
* off-frame is rejected outright - otherwise a transform that hangs the
|
|
112
|
+
* template over the edge could "win" on a handful of conveniently
|
|
113
|
+
* matching cells.
|
|
114
|
+
*/
|
|
115
|
+
function scoreCandidate(sig, targetTable, tW, tH, mx, my, theta, ox, oy) {
|
|
116
|
+
const cos = Math.cos(theta);
|
|
117
|
+
const sin = Math.sin(theta);
|
|
118
|
+
const halfW = (mx * sig.cellW) / 2;
|
|
119
|
+
const halfH = (my * sig.cellH) / 2;
|
|
120
|
+
const { density, centerX, centerY, gridW, gridH } = sig;
|
|
121
|
+
// This is the innermost loop of the whole node - a few million
|
|
122
|
+
// iterations per frame - so the summed-area lookups are inlined rather
|
|
123
|
+
// than going through blockSum(), and the bounds are clamped before
|
|
124
|
+
// truncation so the per-cell work is four array reads and no calls.
|
|
125
|
+
const integral = targetTable.integral;
|
|
126
|
+
const stride = targetTable.stride;
|
|
127
|
+
const xFromGx = cos * mx;
|
|
128
|
+
const yFromGx = sin * mx;
|
|
129
|
+
const xFromGy = -sin * my;
|
|
130
|
+
const yFromGy = cos * my;
|
|
131
|
+
|
|
132
|
+
let sum = 0;
|
|
133
|
+
let count = 0;
|
|
134
|
+
for (let gy = 0; gy < gridH; gy++) {
|
|
135
|
+
const cy = centerY[gy];
|
|
136
|
+
const rowBase = gy * gridW;
|
|
137
|
+
const baseX = ox + xFromGy * cy;
|
|
138
|
+
const baseY = oy + yFromGy * cy;
|
|
139
|
+
for (let gx = 0; gx < gridW; gx++) {
|
|
140
|
+
const cx = centerX[gx];
|
|
141
|
+
const tx = baseX + xFromGx * cx;
|
|
142
|
+
const ty = baseY + yFromGx * cx;
|
|
143
|
+
if (tx < 0 || ty < 0 || tx >= tW || ty >= tH) continue;
|
|
144
|
+
let x0 = tx + 0.5 - halfW;
|
|
145
|
+
let y0 = ty + 0.5 - halfH;
|
|
146
|
+
let x1 = tx + 0.5 + halfW;
|
|
147
|
+
let y1 = ty + 0.5 + halfH;
|
|
148
|
+
if (x0 < 0) x0 = 0;
|
|
149
|
+
if (y0 < 0) y0 = 0;
|
|
150
|
+
if (x1 > tW) x1 = tW;
|
|
151
|
+
if (y1 > tH) y1 = tH;
|
|
152
|
+
const ix0 = x0 | 0;
|
|
153
|
+
const iy0 = y0 | 0;
|
|
154
|
+
let ix1 = x1 | 0;
|
|
155
|
+
let iy1 = y1 | 0;
|
|
156
|
+
if (ix1 <= ix0) ix1 = ix0 + 1 > tW ? tW : ix0 + 1;
|
|
157
|
+
if (iy1 <= iy0) iy1 = iy0 + 1 > tH ? tH : iy0 + 1;
|
|
158
|
+
const area = (ix1 - ix0) * (iy1 - iy0);
|
|
159
|
+
if (area <= 0) continue;
|
|
160
|
+
const rowTop = iy0 * stride;
|
|
161
|
+
const rowBottom = iy1 * stride;
|
|
162
|
+
const cellSum =
|
|
163
|
+
integral[rowBottom + ix1] -
|
|
164
|
+
integral[rowTop + ix1] -
|
|
165
|
+
integral[rowBottom + ix0] +
|
|
166
|
+
integral[rowTop + ix0];
|
|
167
|
+
const diff = density[rowBase + gx] - cellSum / area;
|
|
168
|
+
sum += diff < 0 ? -diff : diff;
|
|
169
|
+
count++;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
// require most of the template to actually be on-frame
|
|
173
|
+
if (count < gridW * gridH * 0.6) return Infinity;
|
|
174
|
+
return sum / count;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Geometric ladder of magnifications spanning [min,max]. The relative
|
|
179
|
+
* step stays constant, which is the right spacing for a scale - what
|
|
180
|
+
* matters is the ratio, not the absolute difference.
|
|
181
|
+
*
|
|
182
|
+
* Anchored on exactly 1.0 rather than on `min`, so that "the frame and
|
|
183
|
+
* the golden are already at the same scale" is always one of the
|
|
184
|
+
* hypotheses tested exactly. Spacing the ladder from the endpoints
|
|
185
|
+
* instead can miss 1.0 entirely (0.75..1.5 in 11 steps straddles it at
|
|
186
|
+
* 0.9897 and 1.0608), which quietly denies the same-rig case - the common
|
|
187
|
+
* one - the ability to score a perfect match.
|
|
188
|
+
*/
|
|
189
|
+
function scaleLadder(min, max, steps) {
|
|
190
|
+
// A degenerate ladder is a single point at `min`. This matters, not
|
|
191
|
+
// just a guard: the calibrated/pinned path sets scaleSearchMin ==
|
|
192
|
+
// scaleSearchMax, and returning [1] there would silently search at
|
|
193
|
+
// 1.0 instead of the pinned magnification - every frame aligned at
|
|
194
|
+
// the wrong scale.
|
|
195
|
+
if (!(min > 0)) {
|
|
196
|
+
// callers clamp, but 0/negative min would make the ratio below
|
|
197
|
+
// degenerate and the ladder loop grow an unbounded array
|
|
198
|
+
throw new RangeError(`scaleLadder: min must be positive, got ${min}`);
|
|
199
|
+
}
|
|
200
|
+
if (steps <= 1 || max <= min) return [min];
|
|
201
|
+
const ratio = (max / min) ** (1 / (steps - 1));
|
|
202
|
+
const out = [];
|
|
203
|
+
for (let k = Math.ceil(Math.log(min) / Math.log(ratio)); ; k++) {
|
|
204
|
+
const m = ratio ** k;
|
|
205
|
+
if (m > max * 1.0001) break;
|
|
206
|
+
out.push(m);
|
|
207
|
+
}
|
|
208
|
+
return out.length ? out : [min];
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function linSpread(center, halfRange, steps) {
|
|
212
|
+
if (steps <= 1 || halfRange <= 0) return [center];
|
|
213
|
+
const out = [];
|
|
214
|
+
for (let i = 0; i < steps; i++) {
|
|
215
|
+
out.push(center - halfRange + (2 * halfRange * i) / (steps - 1));
|
|
216
|
+
}
|
|
217
|
+
return out;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Offsets stepping outward from `center`, covering +/- halfRange.
|
|
221
|
+
// Built symmetrically about the center rather than walked from one end,
|
|
222
|
+
// so the center itself is ALWAYS evaluated: a refine stage that can miss
|
|
223
|
+
// its own starting point is free to drift away from a position an earlier
|
|
224
|
+
// stage got exactly right, and at stage 1 the center is the nominal
|
|
225
|
+
// "part sits centered in the frame" hypothesis.
|
|
226
|
+
function offsetsAround(center, halfRange, step) {
|
|
227
|
+
// A non-finite or non-positive range/step must not enter the walk:
|
|
228
|
+
// `for (d = step; d <= halfRange; d += step)` with both Infinity is
|
|
229
|
+
// true forever, which freezes the whole event loop. Reachable in
|
|
230
|
+
// practice only via a corrupted trained transform (mx*my*golden
|
|
231
|
+
// overflowing centerX/halfRange), but a hang is too expensive a
|
|
232
|
+
// failure mode to leave to the caller's luck - degrade to searching
|
|
233
|
+
// the exact center, which is always a legal candidate.
|
|
234
|
+
if (
|
|
235
|
+
!(halfRange > 0) ||
|
|
236
|
+
!(step > 0) ||
|
|
237
|
+
!Number.isFinite(halfRange) ||
|
|
238
|
+
!Number.isFinite(step)
|
|
239
|
+
) {
|
|
240
|
+
return [center];
|
|
241
|
+
}
|
|
242
|
+
const out = [center];
|
|
243
|
+
for (let d = step; d <= halfRange; d += step) {
|
|
244
|
+
out.push(center - d, center + d);
|
|
245
|
+
}
|
|
246
|
+
return out;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Sweep translation over a symmetric box around (cx,cy) for every
|
|
251
|
+
* (scale pair, angle) combination, returning the best placement found
|
|
252
|
+
* for each scale pair, ordered best-first. `scales` is a list of [mx,my]
|
|
253
|
+
* pairs.
|
|
254
|
+
*
|
|
255
|
+
* One entry per *scale pair* rather than the k best raw candidates: the
|
|
256
|
+
* k best raw candidates are almost always k translations of the same
|
|
257
|
+
* scale, which is no diversity at all. What the caller needs is
|
|
258
|
+
* genuinely different scale hypotheses to arbitrate between.
|
|
259
|
+
*/
|
|
260
|
+
function sweepRanked(
|
|
261
|
+
sig,
|
|
262
|
+
targetTable,
|
|
263
|
+
tW,
|
|
264
|
+
tH,
|
|
265
|
+
scales,
|
|
266
|
+
angles,
|
|
267
|
+
cx,
|
|
268
|
+
cy,
|
|
269
|
+
halfRange,
|
|
270
|
+
step,
|
|
271
|
+
) {
|
|
272
|
+
const xs = offsetsAround(cx, halfRange, step);
|
|
273
|
+
const ys = offsetsAround(cy, halfRange, step);
|
|
274
|
+
const perPair = [];
|
|
275
|
+
for (const pair of scales) {
|
|
276
|
+
const mx = pair[0];
|
|
277
|
+
const my = pair[1];
|
|
278
|
+
let best = { mx, my, theta: angles[0], ox: cx, oy: cy, score: Infinity };
|
|
279
|
+
for (const theta of angles) {
|
|
280
|
+
for (const oy of ys) {
|
|
281
|
+
for (const ox of xs) {
|
|
282
|
+
const score = scoreCandidate(
|
|
283
|
+
sig,
|
|
284
|
+
targetTable,
|
|
285
|
+
tW,
|
|
286
|
+
tH,
|
|
287
|
+
mx,
|
|
288
|
+
my,
|
|
289
|
+
theta,
|
|
290
|
+
ox,
|
|
291
|
+
oy,
|
|
292
|
+
);
|
|
293
|
+
if (score < best.score) best = { mx, my, theta, ox, oy, score };
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
perPair.push(best);
|
|
298
|
+
}
|
|
299
|
+
perPair.sort((a, b) => a.score - b.score);
|
|
300
|
+
return perPair;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** The single best placement - sweepRanked's winner. */
|
|
304
|
+
function sweep(
|
|
305
|
+
sig,
|
|
306
|
+
targetTable,
|
|
307
|
+
tW,
|
|
308
|
+
tH,
|
|
309
|
+
scales,
|
|
310
|
+
angles,
|
|
311
|
+
cx,
|
|
312
|
+
cy,
|
|
313
|
+
halfRange,
|
|
314
|
+
step,
|
|
315
|
+
) {
|
|
316
|
+
return sweepRanked(
|
|
317
|
+
sig,
|
|
318
|
+
targetTable,
|
|
319
|
+
tW,
|
|
320
|
+
tH,
|
|
321
|
+
scales,
|
|
322
|
+
angles,
|
|
323
|
+
cx,
|
|
324
|
+
cy,
|
|
325
|
+
halfRange,
|
|
326
|
+
step,
|
|
327
|
+
)[0];
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Re-express a candidate at a new scale/angle while keeping the
|
|
332
|
+
* template's *center* pinned to the same point in the frame.
|
|
333
|
+
*
|
|
334
|
+
* The raw (mx, my, theta, ox, oy) parametrization scales and rotates
|
|
335
|
+
* about the template's top-left corner, so nudging a scale by a percent
|
|
336
|
+
* swings the far corner by a large fraction of the template - a
|
|
337
|
+
* refinement step in mx alone would wreck the alignment it is trying to
|
|
338
|
+
* improve, and the search would reject the better scale for the wrong
|
|
339
|
+
* reason. Anchoring at the center decouples the parameters, which is what
|
|
340
|
+
* lets the polish refine scale, aspect and angle in small independent
|
|
341
|
+
* steps.
|
|
342
|
+
*/
|
|
343
|
+
function reanchor(cand, gW, gH, mx, my, theta) {
|
|
344
|
+
const cx = gW / 2;
|
|
345
|
+
const cy = gH / 2;
|
|
346
|
+
const cosOld = Math.cos(cand.theta);
|
|
347
|
+
const sinOld = Math.sin(cand.theta);
|
|
348
|
+
const fixedX = cand.ox + cosOld * cand.mx * cx - sinOld * cand.my * cy;
|
|
349
|
+
const fixedY = cand.oy + sinOld * cand.mx * cx + cosOld * cand.my * cy;
|
|
350
|
+
const cos = Math.cos(theta);
|
|
351
|
+
const sin = Math.sin(theta);
|
|
352
|
+
return {
|
|
353
|
+
mx,
|
|
354
|
+
my,
|
|
355
|
+
theta,
|
|
356
|
+
ox: fixedX - (cos * mx * cx - sin * my * cy),
|
|
357
|
+
oy: fixedY - (sin * mx * cx + cos * my * cy),
|
|
358
|
+
score: Infinity,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Alternating refinement of the two magnifications, the angle and the
|
|
364
|
+
* translation, at shrinking step sizes.
|
|
365
|
+
*
|
|
366
|
+
* The staged search leaves a residual bounded by its ladder spacing, and
|
|
367
|
+
* on a full-page template even a fraction of a percent of scale error is
|
|
368
|
+
* several pixels of drift by the far edge - which reads downstream as a
|
|
369
|
+
* defect outline traced along every printed stroke, indistinguishable
|
|
370
|
+
* from real blemish. It is worth a few dozen extra evaluations to remove.
|
|
371
|
+
*
|
|
372
|
+
* `objective` is supplied by the caller and, unlike the coarse density
|
|
373
|
+
* score driving the staged search, measures the thing that actually
|
|
374
|
+
* matters: how many pixels disagree once the frame is resampled into the
|
|
375
|
+
* golden's grid. The two stop agreeing at this scale - the density score
|
|
376
|
+
* can be improved by a nudge that makes pixel-level stroke alignment
|
|
377
|
+
* worse, because a lattice cell several pixels across cannot see a
|
|
378
|
+
* one-pixel outline. Refining against the cheap proxy right up to the
|
|
379
|
+
* point of measurement is how you get an alignment that scores well and
|
|
380
|
+
* inspects badly.
|
|
381
|
+
*/
|
|
382
|
+
const POLISH_STEPS = [0.02, 0.01, 0.005, 0.0025, 0.00125];
|
|
383
|
+
|
|
384
|
+
/** Translation step, in px, for a given relative step size. Works out as
|
|
385
|
+
* [2, 1, 1, 1, 1] - Math.round(0.5) is 1, and 0.25 and 0.125 round to 0 and
|
|
386
|
+
* are floored to 1. */
|
|
387
|
+
function translationStep(relStep) {
|
|
388
|
+
return Math.max(1, Math.round(relStep * 100));
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/**
|
|
392
|
+
* The candidates one step out from `centre`, in every direction the polish
|
|
393
|
+
* is allowed to move.
|
|
394
|
+
*
|
|
395
|
+
* Built from one fixed centre and never mutating it, which is what makes
|
|
396
|
+
* the whole set safe to score out of order - or on eight different
|
|
397
|
+
* threads. The previous polish regenerated each probe from whatever had
|
|
398
|
+
* been adopted so far *within* the round, so the set could not be batched
|
|
399
|
+
* without changing what it searched.
|
|
400
|
+
*
|
|
401
|
+
* 4 scale + 2 angle + 8 translation = 14; 10 when the magnification is
|
|
402
|
+
* pinned. The centre itself is deliberately not included: it has already
|
|
403
|
+
* been scored, and re-scoring it per round would be a fifteenth evaluation
|
|
404
|
+
* bought for nothing.
|
|
405
|
+
*/
|
|
406
|
+
function neighbourhood(centre, gW, gH, relStep, maxAngleDeg, lockScale) {
|
|
407
|
+
const out = [];
|
|
408
|
+
// each axis independently: moved together they are overall scale,
|
|
409
|
+
// moved apart they are the print stretch this node has to tolerate
|
|
410
|
+
if (!lockScale) {
|
|
411
|
+
for (const factor of [1 - relStep, 1 + relStep]) {
|
|
412
|
+
out.push(reanchor(centre, gW, gH, centre.mx * factor, centre.my, centre.theta));
|
|
413
|
+
out.push(reanchor(centre, gW, gH, centre.mx, centre.my * factor, centre.theta));
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
if (maxAngleDeg > 0) {
|
|
417
|
+
const angleStep = relStep * 25 * DEG;
|
|
418
|
+
for (const delta of [-angleStep, angleStep]) {
|
|
419
|
+
out.push(reanchor(centre, gW, gH, centre.mx, centre.my, centre.theta + delta));
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
const step = translationStep(relStep);
|
|
423
|
+
for (const dy of [-step, 0, step]) {
|
|
424
|
+
for (const dx of [-step, 0, step]) {
|
|
425
|
+
if (dx === 0 && dy === 0) continue;
|
|
426
|
+
out.push({
|
|
427
|
+
mx: centre.mx,
|
|
428
|
+
my: centre.my,
|
|
429
|
+
theta: centre.theta,
|
|
430
|
+
ox: centre.ox + dx,
|
|
431
|
+
oy: centre.oy + dy,
|
|
432
|
+
score: Infinity,
|
|
433
|
+
});
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
return out;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Alternating refinement of the two magnifications, the angle and the
|
|
441
|
+
* translation, at shrinking step sizes.
|
|
442
|
+
*
|
|
443
|
+
* The staged search leaves a residual bounded by its ladder spacing, and
|
|
444
|
+
* on a full-page template even a fraction of a percent of scale error is
|
|
445
|
+
* several pixels of drift by the far edge - which reads downstream as a
|
|
446
|
+
* defect outline traced along every printed stroke, indistinguishable
|
|
447
|
+
* from real blemish. It is worth a few dozen extra evaluations to remove.
|
|
448
|
+
*
|
|
449
|
+
* `scoreBatch` is supplied by the caller and, unlike the coarse density
|
|
450
|
+
* score driving the staged search, measures the thing that actually
|
|
451
|
+
* matters: how many pixels disagree once the frame is resampled into the
|
|
452
|
+
* golden's grid. The two stop agreeing at this scale - the density score
|
|
453
|
+
* can be improved by a nudge that makes pixel-level stroke alignment
|
|
454
|
+
* worse, because a lattice cell several pixels across cannot see a
|
|
455
|
+
* one-pixel outline. Refining against the cheap proxy right up to the
|
|
456
|
+
* point of measurement is how you get an alignment that scores well and
|
|
457
|
+
* inspects badly.
|
|
458
|
+
*
|
|
459
|
+
* It takes a whole neighbourhood at once and returns the scores in the
|
|
460
|
+
* order given, so the evaluations can go to the worker pool. That makes
|
|
461
|
+
* this a pattern search rather than the first-improvement walk it
|
|
462
|
+
* replaced: every probe is measured from the same fixed centre, and the
|
|
463
|
+
* best of them is adopted. The walk compounded improvements *within* a
|
|
464
|
+
* round - it re-derived each probe from whatever had just been accepted -
|
|
465
|
+
* which is both why it needed fewer evaluations and why it could not be
|
|
466
|
+
* batched at all.
|
|
467
|
+
*
|
|
468
|
+
* That difference is why the walk's early break did not survive here. The
|
|
469
|
+
* walk stopped when a whole compounding round improved nothing; the
|
|
470
|
+
* closest equivalent is "this step size improved nothing", and stopping
|
|
471
|
+
* there is measurably worse. A poll only moves along one axis at a time,
|
|
472
|
+
* so it runs out of single-axis improvements well before the alignment has
|
|
473
|
+
* actually converged - and the break then skipped the three finest step
|
|
474
|
+
* sizes entirely. On a clean bench pair that cost a 15% worse residual and
|
|
475
|
+
* put a false background region on a good part, which is precisely the
|
|
476
|
+
* failure this stage exists to prevent.
|
|
477
|
+
*
|
|
478
|
+
* So every step size runs. It costs more evaluations than the walk did,
|
|
479
|
+
* and those are the evaluations the pool now absorbs; measured against a
|
|
480
|
+
* fixed pin, the residual comes out better than the walk's on every
|
|
481
|
+
* fixture tried - clean and defective, searched and pinned.
|
|
482
|
+
*
|
|
483
|
+
* No test in the suite can see this distinction: both forms tie the walk
|
|
484
|
+
* on the spec fixture. It is measured on the bench pair instead.
|
|
485
|
+
*/
|
|
486
|
+
async function polish(scoreBatch, start, gW, gH, maxAngleDeg, lockScale) {
|
|
487
|
+
const best0 = { ...start };
|
|
488
|
+
best0.score = (await scoreBatch([best0]))[0];
|
|
489
|
+
let best = best0;
|
|
490
|
+
|
|
491
|
+
for (const relStep of POLISH_STEPS) {
|
|
492
|
+
for (;;) {
|
|
493
|
+
const cands = neighbourhood(best, gW, gH, relStep, maxAngleDeg, lockScale);
|
|
494
|
+
const scores = await scoreBatch(cands);
|
|
495
|
+
// strict `<`, scanned in index order, so the lowest index wins a
|
|
496
|
+
// tie and the choice cannot depend on how the batch was split
|
|
497
|
+
// across workers
|
|
498
|
+
let pick = -1;
|
|
499
|
+
let pickScore = best.score;
|
|
500
|
+
for (let i = 0; i < cands.length; i++) {
|
|
501
|
+
if (scores[i] < pickScore) {
|
|
502
|
+
pickScore = scores[i];
|
|
503
|
+
pick = i;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (pick < 0) break;
|
|
507
|
+
best = { ...cands[pick], score: pickScore };
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
return best;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* Full coarse-to-fine search.
|
|
515
|
+
*
|
|
516
|
+
* `signatures` holds the golden's three precomputed density lattices
|
|
517
|
+
* ({ coarse, medium, fine } from buildGoldenSignature) - they depend only
|
|
518
|
+
* on the golden, so they are built once when it is cached rather than per
|
|
519
|
+
* frame. `opts`:
|
|
520
|
+
* scaleMin/scaleMax/scaleSteps - stage 1 ladder; set min == max to pin
|
|
521
|
+
* the magnification (the calibrated case)
|
|
522
|
+
* maxAspect/aspectSteps - stage 3 range for my/mx, as a fraction
|
|
523
|
+
* either side of 1 (0 disables)
|
|
524
|
+
* maxAngleDeg/angleSteps - stage 2 rotation range; 0 disables
|
|
525
|
+
* slackPx - translation slack beyond the pure size
|
|
526
|
+
* difference, in frame px
|
|
527
|
+
* objective - optional pixel-level scorer for stage 4
|
|
528
|
+
*
|
|
529
|
+
* Returns { mx, my, theta, thetaDeg, ox, oy, score }.
|
|
530
|
+
*/
|
|
531
|
+
async function findTransform(gW, gH, targetFg, tW, tH, signatures, opts) {
|
|
532
|
+
// `buildTable` arrives the same way `objectiveBatch` does - through
|
|
533
|
+
// opts rather than an import - so this module keeps knowing nothing
|
|
534
|
+
// about the worker pool, and the serial twin stays the default for any
|
|
535
|
+
// caller outside this package.
|
|
536
|
+
const targetTable = opts.buildTable
|
|
537
|
+
? await opts.buildTable(targetFg, tW, tH)
|
|
538
|
+
: buildIntegral(targetFg, tW, tH);
|
|
539
|
+
|
|
540
|
+
// One scorer for the three places that need pixel-level evidence.
|
|
541
|
+
// `objectiveBatch` is preferred - it is what lets the polish hand a
|
|
542
|
+
// whole neighbourhood to the worker pool - but the single-candidate
|
|
543
|
+
// `objective` stays supported, because it is the documented shape for
|
|
544
|
+
// any caller outside this package.
|
|
545
|
+
const scoreBatch = opts.objectiveBatch
|
|
546
|
+
? (cands) => opts.objectiveBatch(cands)
|
|
547
|
+
: opts.objective
|
|
548
|
+
? async (cands) =>
|
|
549
|
+
cands.map((c) => opts.objective(c.mx, c.my, c.theta, c.ox, c.oy))
|
|
550
|
+
: null;
|
|
551
|
+
|
|
552
|
+
// ---- pinned magnification: the rig's standoff and the press's stretch
|
|
553
|
+
// do not change between frames, so once they have been measured there
|
|
554
|
+
// is nothing to search for. Only where the part sits, and how square
|
|
555
|
+
// it sits, vary. Re-solving the constant every frame is not merely
|
|
556
|
+
// wasted time: it hands the search a chance to be wrong, and on a
|
|
557
|
+
// badly printed label - exactly when the evidence is poorest - it
|
|
558
|
+
// takes it.
|
|
559
|
+
if (opts.pinnedScale) {
|
|
560
|
+
const { mx, my } = opts.pinnedScale;
|
|
561
|
+
|
|
562
|
+
// A seed replaces the four sweeps' *guess*, never their verdict:
|
|
563
|
+
// polish still refines it against real pixels and still produces the
|
|
564
|
+
// score. The magnifications stay the trained ones - a seed is not
|
|
565
|
+
// allowed to re-open the constant that pinning exists to fix - so
|
|
566
|
+
// only the angle and the placement come from it, reanchored about
|
|
567
|
+
// the golden's centre so substituting mx/my cannot shift the frame.
|
|
568
|
+
if (opts.seed && scoreBatch) {
|
|
569
|
+
const seeded = reanchor(
|
|
570
|
+
{ ...opts.seed, score: Infinity },
|
|
571
|
+
gW,
|
|
572
|
+
gH,
|
|
573
|
+
mx,
|
|
574
|
+
my,
|
|
575
|
+
opts.seed.theta,
|
|
576
|
+
);
|
|
577
|
+
const polished = await polish(
|
|
578
|
+
scoreBatch,
|
|
579
|
+
seeded,
|
|
580
|
+
gW,
|
|
581
|
+
gH,
|
|
582
|
+
opts.maxAngleDeg,
|
|
583
|
+
true,
|
|
584
|
+
);
|
|
585
|
+
return {
|
|
586
|
+
mx: polished.mx,
|
|
587
|
+
my: polished.my,
|
|
588
|
+
theta: polished.theta,
|
|
589
|
+
thetaDeg: polished.theta / DEG,
|
|
590
|
+
ox: polished.ox,
|
|
591
|
+
oy: polished.oy,
|
|
592
|
+
score: polished.score,
|
|
593
|
+
pinned: true,
|
|
594
|
+
seeded: true,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const centerX = (tW - mx * gW) / 2;
|
|
599
|
+
const centerY = (tH - my * gH) / 2;
|
|
600
|
+
const halfRange =
|
|
601
|
+
Math.max(Math.abs(centerX), Math.abs(centerY)) + opts.slackPx;
|
|
602
|
+
const angles = linSpread(0, opts.maxAngleDeg * DEG, opts.angleSteps);
|
|
603
|
+
|
|
604
|
+
let pinned = sweep(
|
|
605
|
+
signatures.coarse,
|
|
606
|
+
targetTable,
|
|
607
|
+
tW,
|
|
608
|
+
tH,
|
|
609
|
+
[[mx, my]],
|
|
610
|
+
[0],
|
|
611
|
+
centerX,
|
|
612
|
+
centerY,
|
|
613
|
+
halfRange,
|
|
614
|
+
Math.max(1, Math.round(mx * signatures.coarse.cellW)),
|
|
615
|
+
);
|
|
616
|
+
const mediumReach = Math.max(2, Math.round(mx * signatures.coarse.cellW));
|
|
617
|
+
pinned = sweep(
|
|
618
|
+
signatures.medium,
|
|
619
|
+
targetTable,
|
|
620
|
+
tW,
|
|
621
|
+
tH,
|
|
622
|
+
[[mx, my]],
|
|
623
|
+
angles,
|
|
624
|
+
pinned.ox,
|
|
625
|
+
pinned.oy,
|
|
626
|
+
mediumReach,
|
|
627
|
+
Math.max(1, Math.round(mediumReach / 3)),
|
|
628
|
+
);
|
|
629
|
+
// The angle is left to the polish, which judges it on real pixels.
|
|
630
|
+
// Re-sweeping it here on the fine grid was tried and is worse: with
|
|
631
|
+
// the scale pinned there is no longer a scale nudge to keep the
|
|
632
|
+
// density proxy honest, so it happily buys a small rotation that
|
|
633
|
+
// lines ink up cell-wise while making the actual overlap worse. On
|
|
634
|
+
// the demo capture that turned a clean part into nine false regions.
|
|
635
|
+
const fineReach = Math.max(2, Math.round(mx * signatures.medium.cellW));
|
|
636
|
+
pinned = sweep(
|
|
637
|
+
signatures.fine,
|
|
638
|
+
targetTable,
|
|
639
|
+
tW,
|
|
640
|
+
tH,
|
|
641
|
+
[[mx, my]],
|
|
642
|
+
[pinned.theta],
|
|
643
|
+
pinned.ox,
|
|
644
|
+
pinned.oy,
|
|
645
|
+
fineReach,
|
|
646
|
+
Math.max(1, Math.round(fineReach / 2)),
|
|
647
|
+
);
|
|
648
|
+
pinned = sweep(
|
|
649
|
+
signatures.fine,
|
|
650
|
+
targetTable,
|
|
651
|
+
tW,
|
|
652
|
+
tH,
|
|
653
|
+
[[mx, my]],
|
|
654
|
+
[pinned.theta],
|
|
655
|
+
pinned.ox,
|
|
656
|
+
pinned.oy,
|
|
657
|
+
3,
|
|
658
|
+
1,
|
|
659
|
+
);
|
|
660
|
+
if (scoreBatch) {
|
|
661
|
+
pinned = await polish(scoreBatch, pinned, gW, gH, opts.maxAngleDeg, true);
|
|
662
|
+
}
|
|
663
|
+
return {
|
|
664
|
+
mx: pinned.mx,
|
|
665
|
+
my: pinned.my,
|
|
666
|
+
theta: pinned.theta,
|
|
667
|
+
thetaDeg: pinned.theta / DEG,
|
|
668
|
+
ox: pinned.ox,
|
|
669
|
+
oy: pinned.oy,
|
|
670
|
+
score: pinned.score,
|
|
671
|
+
pinned: true,
|
|
672
|
+
};
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// ---- stage 1: wide scale ladder, isotropic, no rotation, whole frame
|
|
676
|
+
const coarse = signatures.coarse;
|
|
677
|
+
const scales = scaleLadder(opts.scaleMin, opts.scaleMax, opts.scaleSteps);
|
|
678
|
+
let best = {
|
|
679
|
+
mx: scales[0],
|
|
680
|
+
my: scales[0],
|
|
681
|
+
theta: 0,
|
|
682
|
+
ox: 0,
|
|
683
|
+
oy: 0,
|
|
684
|
+
score: Infinity,
|
|
685
|
+
};
|
|
686
|
+
let ranked = [best];
|
|
687
|
+
for (const m of scales) {
|
|
688
|
+
// centered on the nominal placement for this magnification, reaching
|
|
689
|
+
// out over the whole margin the frame has plus the jitter slack
|
|
690
|
+
const centerX = (tW - m * gW) / 2;
|
|
691
|
+
const centerY = (tH - m * gH) / 2;
|
|
692
|
+
const halfRange =
|
|
693
|
+
Math.max(Math.abs(centerX), Math.abs(centerY)) + opts.slackPx;
|
|
694
|
+
// one full cell per step: this stage only has to get within a cell,
|
|
695
|
+
// and halving the step would quadruple a sweep that is paid once per
|
|
696
|
+
// rung of the ladder
|
|
697
|
+
const step = Math.max(1, Math.round(m * coarse.cellW));
|
|
698
|
+
const hit = sweep(
|
|
699
|
+
coarse,
|
|
700
|
+
targetTable,
|
|
701
|
+
tW,
|
|
702
|
+
tH,
|
|
703
|
+
[[m, m]],
|
|
704
|
+
[0],
|
|
705
|
+
centerX,
|
|
706
|
+
centerY,
|
|
707
|
+
halfRange,
|
|
708
|
+
step,
|
|
709
|
+
);
|
|
710
|
+
if (hit.score < best.score) best = hit;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
const ladderRatio = scales.length > 1 ? scales[1] / scales[0] : 1.05;
|
|
714
|
+
|
|
715
|
+
// ---- stage 2: scale, stretch and rotation together on a medium grid.
|
|
716
|
+
//
|
|
717
|
+
// The aspect ratio has to enter HERE rather than in a later stage. Held
|
|
718
|
+
// isotropic, the best-scoring scale on a stretched print lands between
|
|
719
|
+
// the two true axis scales - so committing to one isotropic rung first
|
|
720
|
+
// and only then looking for stretch starts the split from a wrong
|
|
721
|
+
// centre, and a later stage searching a narrow band around it can no
|
|
722
|
+
// longer reach the right answer. Searching them jointly costs one
|
|
723
|
+
// medium-grid sweep and removes the whole failure mode.
|
|
724
|
+
const medium = signatures.medium;
|
|
725
|
+
{
|
|
726
|
+
const reach = Math.max(2, Math.round(best.mx * coarse.cellW));
|
|
727
|
+
const step = Math.max(1, Math.round(reach / 3));
|
|
728
|
+
const aspects = linSpread(1, opts.maxAspect, opts.aspectSteps);
|
|
729
|
+
const refineScales = [];
|
|
730
|
+
for (const m of [best.mx / ladderRatio, best.mx, best.mx * ladderRatio]) {
|
|
731
|
+
for (const aspect of aspects) refineScales.push([m, m * aspect]);
|
|
732
|
+
}
|
|
733
|
+
// Scale and stretch jointly, then angle - rather than one sweep over
|
|
734
|
+
// the product of all three. Scale and stretch have to be joint
|
|
735
|
+
// because they trade off directly against each other; the angle does
|
|
736
|
+
// not interact with either at these magnitudes (a couple of degrees
|
|
737
|
+
// looks nothing like a few percent of stretch), and splitting it out
|
|
738
|
+
// turns a product into a sum: about a third of the candidates.
|
|
739
|
+
ranked = sweepRanked(
|
|
740
|
+
medium,
|
|
741
|
+
targetTable,
|
|
742
|
+
tW,
|
|
743
|
+
tH,
|
|
744
|
+
refineScales,
|
|
745
|
+
[0],
|
|
746
|
+
best.ox,
|
|
747
|
+
best.oy,
|
|
748
|
+
reach,
|
|
749
|
+
step,
|
|
750
|
+
);
|
|
751
|
+
const angles = linSpread(0, opts.maxAngleDeg * DEG, opts.angleSteps);
|
|
752
|
+
if (angles.length > 1) {
|
|
753
|
+
ranked = ranked.map((cand) =>
|
|
754
|
+
sweep(
|
|
755
|
+
medium,
|
|
756
|
+
targetTable,
|
|
757
|
+
tW,
|
|
758
|
+
tH,
|
|
759
|
+
[[cand.mx, cand.my]],
|
|
760
|
+
angles,
|
|
761
|
+
cand.ox,
|
|
762
|
+
cand.oy,
|
|
763
|
+
reach,
|
|
764
|
+
step,
|
|
765
|
+
),
|
|
766
|
+
);
|
|
767
|
+
}
|
|
768
|
+
best = ranked[0];
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ---- stage 3: fine grid, each axis nudged independently.
|
|
772
|
+
//
|
|
773
|
+
// Run for the top few stage-2 hypotheses rather than only the winner.
|
|
774
|
+
// Stage 2 ranks by the density proxy, which counts ink per cell and so
|
|
775
|
+
// cannot tell a correctly scaled match from one a few percent off that
|
|
776
|
+
// happens to land its ink in the same cells. On a dense label the two
|
|
777
|
+
// score within noise of each other, and picking wrong there is
|
|
778
|
+
// unrecoverable: every later stage searches a narrow band around the
|
|
779
|
+
// choice. The symptom is a whole frame failing at ~0.5% residual scale
|
|
780
|
+
// error - about 10px of drift across the label, total disagreement on
|
|
781
|
+
// 1-2px strokes - while a far better placement existed all along.
|
|
782
|
+
const fine = signatures.fine;
|
|
783
|
+
const refineOne = (cand) => {
|
|
784
|
+
const angleStepDeg =
|
|
785
|
+
opts.angleSteps > 1 ? (2 * opts.maxAngleDeg) / (opts.angleSteps - 1) : 0;
|
|
786
|
+
const angles = linSpread(
|
|
787
|
+
cand.theta,
|
|
788
|
+
(angleStepDeg / 2) * DEG,
|
|
789
|
+
angleStepDeg > 0 ? 3 : 1,
|
|
790
|
+
);
|
|
791
|
+
const aspectStep =
|
|
792
|
+
opts.aspectSteps > 1 ? opts.maxAspect / (opts.aspectSteps - 1) : 0.01;
|
|
793
|
+
const nudges = [1 - aspectStep, 1, 1 + aspectStep];
|
|
794
|
+
const refineScales = [];
|
|
795
|
+
for (const nx of nudges) {
|
|
796
|
+
for (const ny of nudges) refineScales.push([cand.mx * nx, cand.my * ny]);
|
|
797
|
+
}
|
|
798
|
+
const reach = Math.max(2, Math.round(cand.mx * medium.cellW));
|
|
799
|
+
const out = sweep(
|
|
800
|
+
fine,
|
|
801
|
+
targetTable,
|
|
802
|
+
tW,
|
|
803
|
+
tH,
|
|
804
|
+
refineScales,
|
|
805
|
+
angles,
|
|
806
|
+
cand.ox,
|
|
807
|
+
cand.oy,
|
|
808
|
+
reach,
|
|
809
|
+
Math.max(1, Math.round(reach / 2)),
|
|
810
|
+
);
|
|
811
|
+
return sweep(
|
|
812
|
+
fine,
|
|
813
|
+
targetTable,
|
|
814
|
+
tW,
|
|
815
|
+
tH,
|
|
816
|
+
[[out.mx, out.my]],
|
|
817
|
+
[out.theta],
|
|
818
|
+
out.ox,
|
|
819
|
+
out.oy,
|
|
820
|
+
3,
|
|
821
|
+
1,
|
|
822
|
+
);
|
|
823
|
+
};
|
|
824
|
+
|
|
825
|
+
{
|
|
826
|
+
const keep = Math.max(1, Math.min(opts.rankedCandidates || 1, ranked.length));
|
|
827
|
+
const refined = [];
|
|
828
|
+
for (let i = 0; i < keep; i++) refined.push(refineOne(ranked[i]));
|
|
829
|
+
|
|
830
|
+
// Arbitrate on real pixel disagreement, not the proxy that got us
|
|
831
|
+
// here. One objective call per candidate is cheap next to a sweep,
|
|
832
|
+
// and it is the only measure that can actually separate them.
|
|
833
|
+
best = refined[0];
|
|
834
|
+
if (scoreBatch && refined.length > 1) {
|
|
835
|
+
// one batch, not one call per candidate: these are independent and
|
|
836
|
+
// there may be up to alignCandidates of them
|
|
837
|
+
const scores = await scoreBatch(refined);
|
|
838
|
+
let bestObj = Infinity;
|
|
839
|
+
for (let i = 0; i < refined.length; i++) {
|
|
840
|
+
if (scores[i] < bestObj) {
|
|
841
|
+
bestObj = scores[i];
|
|
842
|
+
best = refined[i];
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ---- stage 4: sub-percent polish against real pixel disagreement
|
|
849
|
+
if (scoreBatch) {
|
|
850
|
+
best = await polish(scoreBatch, best, gW, gH, opts.maxAngleDeg, false);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return { ...best, thetaDeg: best.theta / DEG };
|
|
854
|
+
}
|
|
855
|
+
|
|
856
|
+
module.exports = {
|
|
857
|
+
buildGoldenSignature,
|
|
858
|
+
neighbourhood,
|
|
859
|
+
scoreCandidate,
|
|
860
|
+
scaleLadder,
|
|
861
|
+
linSpread,
|
|
862
|
+
offsetsAround,
|
|
863
|
+
sweep,
|
|
864
|
+
reanchor,
|
|
865
|
+
polish,
|
|
866
|
+
findTransform,
|
|
867
|
+
};
|