@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/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
+ };