@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/compare.js
ADDED
|
@@ -0,0 +1,1338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Golden-template AOI comparison:
|
|
3
|
+
*
|
|
4
|
+
* - Print position is measured and checked against a tolerance band
|
|
5
|
+
* (not silently corrected away) - a real position defect fails the
|
|
6
|
+
* part on its own, independent of blemish detection.
|
|
7
|
+
* - Blemish detection is two independent checks with independent
|
|
8
|
+
* tolerances, mirroring their Blemish Print / Blemish Background
|
|
9
|
+
* tools: "print" = golden has ink the target is missing (even after a
|
|
10
|
+
* small dilation tolerance); "background" = target has ink the golden
|
|
11
|
+
* never has (even after its own tolerance). A pixel cannot be both
|
|
12
|
+
* (see computeExtraInkDefect/computeMissingInkDefect below), so the
|
|
13
|
+
* two checks are genuinely independent.
|
|
14
|
+
*
|
|
15
|
+
* Unlike their encoder-triggered line-scan rig (which frames every label
|
|
16
|
+
* near-identically), the golden here is normally the *PDF artwork for the
|
|
17
|
+
* label* and the frame a photograph of that label printed. Nothing about
|
|
18
|
+
* those two shares a coordinate system: the render DPI is unrelated to
|
|
19
|
+
* the camera's px-per-mm, raw captures include background beyond the
|
|
20
|
+
* label, a part can sit slightly off square, and the press stretches the
|
|
21
|
+
* print along its media-feed axis relative to the artwork. So the frame
|
|
22
|
+
* is decoded preserving its own aspect ratio (never stretched to golden's
|
|
23
|
+
* dimensions) and lib/align.js recovers independent x/y magnification,
|
|
24
|
+
* rotation and translation. lib/warp.js then resamples that region into
|
|
25
|
+
* golden's frame for the pixel diff.
|
|
26
|
+
*
|
|
27
|
+
* The recovered transform does double duty: it is what gets diffed
|
|
28
|
+
* against, *and* its deviation from nominal is reported and gated as the
|
|
29
|
+
* position check. Nominal is "centered in whatever margin the frame has,
|
|
30
|
+
* square to it" - there is no separate trained-nominal capture step,
|
|
31
|
+
* unlike their trained nominal from label-edge geometry.
|
|
32
|
+
*
|
|
33
|
+
* Kept independent of Node-RED (plain functions over Buffers/typed
|
|
34
|
+
* arrays, mm/px passed in as a plain number) so it can be exercised from
|
|
35
|
+
* a standalone script without booting the runtime.
|
|
36
|
+
*
|
|
37
|
+
* Coordinate space: all pixel coordinates in the returned result (region
|
|
38
|
+
* boxes, the heat map images) are in the *working resolution*
|
|
39
|
+
* (`golden.width` x `golden.height`, i.e. `workingSize`-downscaled), not
|
|
40
|
+
* the original camera resolution.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
"use strict";
|
|
44
|
+
|
|
45
|
+
const sharp = require("sharp");
|
|
46
|
+
const { dilate } = require("./dilate.js");
|
|
47
|
+
const { buildIntegral, buildIntegral64, blockSum } = require("./integral.js");
|
|
48
|
+
const {
|
|
49
|
+
thresholdForeground,
|
|
50
|
+
thresholdFgFixed,
|
|
51
|
+
otsuThreshold,
|
|
52
|
+
} = require("./threshold.js");
|
|
53
|
+
const { refineLocally } = require("./localAlign.js");
|
|
54
|
+
const { toShared } = require("./shared.js");
|
|
55
|
+
const {
|
|
56
|
+
dilateParallel,
|
|
57
|
+
defectParallel,
|
|
58
|
+
binarizeParallel,
|
|
59
|
+
warpParallel,
|
|
60
|
+
refineLocallyParallel,
|
|
61
|
+
objectiveBatchParallel,
|
|
62
|
+
buildIntegralParallel,
|
|
63
|
+
buildGrayTableParallel,
|
|
64
|
+
} = require("./parallel.js");
|
|
65
|
+
const { buildGoldenSignature, findTransform } = require("./align.js");
|
|
66
|
+
const { warpGray } = require("./warp.js");
|
|
67
|
+
const nativeSeed = require("./nativeSeed.js");
|
|
68
|
+
|
|
69
|
+
// Long edges (in cells) of the three lattices the transform search runs
|
|
70
|
+
// on. Stage 1 sweeps the whole frame at COARSE and must stay cheap
|
|
71
|
+
// because its cost is multiplied by the whole scale ladder; MEDIUM and
|
|
72
|
+
// FINE only ever refine inside a bounded neighbourhood, so they can
|
|
73
|
+
// afford more cells. FINE only has to land within a few pixels: the
|
|
74
|
+
// polish stage that follows it refines against actual pixel disagreement,
|
|
75
|
+
// so buying accuracy here with a denser lattice costs real time for
|
|
76
|
+
// something the next stage does better and cheaper.
|
|
77
|
+
const COARSE_GRID = 24;
|
|
78
|
+
const MEDIUM_GRID = 64;
|
|
79
|
+
const FINE_GRID = 96;
|
|
80
|
+
|
|
81
|
+
// Ceiling on the target's working canvas, as a multiple of workingSize on
|
|
82
|
+
// the long edge. Without calibration the canvas is sized by golden's own
|
|
83
|
+
// native->working scale, which is unbounded when a small golden is paired
|
|
84
|
+
// with a big sensor (a 400px template against a 23MP frame would ask for
|
|
85
|
+
// a 23MP working canvas). Since the transform search recovers the
|
|
86
|
+
// magnification anyway, shrinking an over-large canvas costs a little
|
|
87
|
+
// resolution and nothing else - far better than the memory and time an
|
|
88
|
+
// uncapped canvas would take.
|
|
89
|
+
// The polish objective's canvas: enough pixels to rank sub-percent
|
|
90
|
+
// transform nudges, and no more. See prepareGolden.
|
|
91
|
+
//
|
|
92
|
+
// 320 rather than something more generous because the polish is the
|
|
93
|
+
// single largest cost in a frame and it responds almost linearly: on a
|
|
94
|
+
// 1844x2656 golden, dropping from a 640 canvas (divisor 4) to this one
|
|
95
|
+
// (divisor 8) took the search from 590ms to 255ms and, if anything,
|
|
96
|
+
// found the demo scratch better - four regions at density 0.172 instead
|
|
97
|
+
// of two at 0.156. Halving it again is where it breaks: at divisor 16
|
|
98
|
+
// the objective can no longer rank candidates and a clean part comes
|
|
99
|
+
// back with 1207 false regions. There is no gentle degradation here, so
|
|
100
|
+
// do not tune this down without re-running a clean part.
|
|
101
|
+
const OBJECTIVE_LONG_EDGE = 320;
|
|
102
|
+
// Registration grading. Correctly paired labels measured 0.02-0.05 here,
|
|
103
|
+
// so "good" sits just above that; the mismatch ratio floor is an order of
|
|
104
|
+
// magnitude above the worst genuinely bad part on hand (0.006/0.011).
|
|
105
|
+
const GRADE_GOOD = 0.06;
|
|
106
|
+
const MISMATCH_RATIO = 0.02;
|
|
107
|
+
const TARGET_CANVAS_LONG_EDGE_LIMIT = 2.5;
|
|
108
|
+
|
|
109
|
+
function now() {
|
|
110
|
+
return performance.now();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Golden and target MUST come through the same decode path with the same
|
|
114
|
+
// resize semantics: a golden decoded via fit:"inside" and a target
|
|
115
|
+
// decoded via fit:"fill" land on slightly different resampling even at
|
|
116
|
+
// identical output dimensions, which shows up as a scatter of phantom
|
|
117
|
+
// defect pixels when an image is compared against itself. So both sides
|
|
118
|
+
// compute their output size explicitly (goldenWorkingSize /
|
|
119
|
+
// computeTargetWorkingSize) and hand it to this one function.
|
|
120
|
+
/**
|
|
121
|
+
* `raw`, when given, is { width, height, channels } describing pixels that
|
|
122
|
+
* arrive with no container around them - a PDF renderer or a camera SDK
|
|
123
|
+
* handing over its framebuffer directly. There is nothing in such a buffer
|
|
124
|
+
* for sharp to infer a geometry from, so it has to be told; without it the
|
|
125
|
+
* decode fails with "unsupported image format", which is a confusing way
|
|
126
|
+
* to learn that the geometry went missing.
|
|
127
|
+
*
|
|
128
|
+
* Worth taking when it is on offer: on a 4096x5500 frame, PNG decode is
|
|
129
|
+
* ~300ms of a ~1.35s inspection, and it is the single largest serial cost
|
|
130
|
+
* left. Raw input removes it outright.
|
|
131
|
+
*/
|
|
132
|
+
async function decodeGray(buffer, width, height, raw) {
|
|
133
|
+
const { data, info } = await sharp(buffer, raw ? { raw } : undefined)
|
|
134
|
+
.removeAlpha()
|
|
135
|
+
.grayscale()
|
|
136
|
+
.resize(width, height, { fit: "fill" })
|
|
137
|
+
.raw()
|
|
138
|
+
.toBuffer({ resolveWithObject: true });
|
|
139
|
+
if (info.channels !== 1) {
|
|
140
|
+
throw new Error(`expected a single grayscale channel, got ${info.channels}`);
|
|
141
|
+
}
|
|
142
|
+
return data;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Golden's working canvas: long edge scaled to workingSize, aspect ratio
|
|
146
|
+
// preserved, never enlarged (an already-small golden stays native -
|
|
147
|
+
// upscaling invents no detail and only costs time).
|
|
148
|
+
function goldenWorkingSize(nativeWidth, nativeHeight, workingSize) {
|
|
149
|
+
const scale = Math.min(1, workingSize / Math.max(nativeWidth, nativeHeight));
|
|
150
|
+
return {
|
|
151
|
+
width: Math.max(1, Math.round(nativeWidth * scale)),
|
|
152
|
+
height: Math.max(1, Math.round(nativeHeight * scale)),
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* How large should the target's working canvas be, preserving its own
|
|
158
|
+
* native aspect ratio (never stretched)?
|
|
159
|
+
*
|
|
160
|
+
* With a calibrated scale (cfg.mmPerPixelNative + golden.mmPerWorkingPx),
|
|
161
|
+
* this converts the frame's native px directly to golden's physical
|
|
162
|
+
* scale - the geometrically correct answer, independent of either image's
|
|
163
|
+
* pixel dimensions. This is the reliable path; calibrate for production
|
|
164
|
+
* use.
|
|
165
|
+
*
|
|
166
|
+
* Without calibration it falls back to golden's own native->working
|
|
167
|
+
* scale: the assumption that both came off the same rig, so one native
|
|
168
|
+
* pixel spans the same distance in each. That is exactly right for a
|
|
169
|
+
* re-trained golden, and it makes the degenerate case exact - the same
|
|
170
|
+
* image against itself yields the same canvas, hence a zero-offset,
|
|
171
|
+
* zero-defect result.
|
|
172
|
+
*
|
|
173
|
+
* Either way this only sets the canvas the search runs on; it does not
|
|
174
|
+
* have to be right. The magnification the search recovers absorbs the
|
|
175
|
+
* error, which is what lets an artwork golden work at all. A canvas that
|
|
176
|
+
* would exceed TARGET_CANVAS_LONG_EDGE_LIMIT is scaled down to fit.
|
|
177
|
+
*/
|
|
178
|
+
function computeTargetWorkingSize(nativeWidth, nativeHeight, golden, cfg) {
|
|
179
|
+
// Identical native framing (the degenerate "target *is* the golden"
|
|
180
|
+
// case, and the same-camera-same-crop case) must produce byte-identical
|
|
181
|
+
// working canvases, so take golden's own decoded size rather than
|
|
182
|
+
// re-deriving it and risking an off-by-one from a second rounding.
|
|
183
|
+
if (
|
|
184
|
+
nativeWidth === golden.nativeWidth &&
|
|
185
|
+
nativeHeight === golden.nativeHeight
|
|
186
|
+
) {
|
|
187
|
+
return { width: golden.width, height: golden.height };
|
|
188
|
+
}
|
|
189
|
+
let scale;
|
|
190
|
+
if (cfg.mmPerPixelNative != null && golden.mmPerWorkingPx != null) {
|
|
191
|
+
scale = cfg.mmPerPixelNative / golden.mmPerWorkingPx;
|
|
192
|
+
} else if (golden.nativeWidth != null) {
|
|
193
|
+
// golden's *realized* scale, which is 1.0 when golden was small
|
|
194
|
+
// enough that goldenWorkingSize left it at native size. Deriving
|
|
195
|
+
// the target's scale from cfg.workingSize instead would upscale the
|
|
196
|
+
// frame while golden stayed put, so even an identical image would
|
|
197
|
+
// be compared against a magnified copy of itself.
|
|
198
|
+
scale =
|
|
199
|
+
Math.max(golden.width, golden.height) /
|
|
200
|
+
Math.max(golden.nativeWidth, golden.nativeHeight);
|
|
201
|
+
} else {
|
|
202
|
+
scale = cfg.workingSize / Math.max(nativeWidth, nativeHeight);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const limit = cfg.workingSize * TARGET_CANVAS_LONG_EDGE_LIMIT;
|
|
206
|
+
const longEdge = Math.max(nativeWidth, nativeHeight) * scale;
|
|
207
|
+
if (longEdge > limit) scale *= limit / longEdge;
|
|
208
|
+
|
|
209
|
+
return {
|
|
210
|
+
width: Math.max(1, Math.round(nativeWidth * scale)),
|
|
211
|
+
height: Math.max(1, Math.round(nativeHeight * scale)),
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Drop every flagged pixel where either image was too close to its own
|
|
216
|
+
// ink level to have decided anything (see thresholdForeground). A defect
|
|
217
|
+
// claim is a claim about *both* images - "ink here, none there" - so
|
|
218
|
+
// ambiguity on either side voids it, not just on the side that carries
|
|
219
|
+
// the ink. Either mask may be null, meaning inkMargin is off.
|
|
220
|
+
function dropAmbiguous(defect, count, a, b) {
|
|
221
|
+
if ((a === null || a === undefined) && (b === null || b === undefined)) {
|
|
222
|
+
return count;
|
|
223
|
+
}
|
|
224
|
+
const n = defect.length;
|
|
225
|
+
for (let i = 0; i < n; i++) {
|
|
226
|
+
if (!defect[i]) continue;
|
|
227
|
+
if (
|
|
228
|
+
(a !== null && a !== undefined && a[i]) ||
|
|
229
|
+
(b !== null && b !== undefined && b[i])
|
|
230
|
+
) {
|
|
231
|
+
defect[i] = 0;
|
|
232
|
+
count--;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
return count;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* How well the two images registered, and whether they look like the same
|
|
240
|
+
* label at all.
|
|
241
|
+
*
|
|
242
|
+
* The alignment residual on its own grades registration: on this project's
|
|
243
|
+
* samples a correctly paired label lands at 0.02-0.05, a badly printed one
|
|
244
|
+
* around 0.10, and artwork for a *different product* around 0.18.
|
|
245
|
+
*
|
|
246
|
+
* That last case deserves saying out loud, because everything downstream
|
|
247
|
+
* reports it as a catastrophe: comparing one product's artwork against
|
|
248
|
+
* another's photograph produced 1145 print regions and a 6% defect ratio,
|
|
249
|
+
* which reads as a spectacularly bad print rather than the wrong golden.
|
|
250
|
+
* The distinguishing signal is not the size of the disagreement but its
|
|
251
|
+
* *shape*: a defective part disagrees in one direction and in places,
|
|
252
|
+
* while two different labels disagree in both directions and everywhere.
|
|
253
|
+
*
|
|
254
|
+
* So a mismatch is only claimed when the registration is poor **and** both
|
|
255
|
+
* blemish checks are saturated. Requiring both is what keeps a genuinely
|
|
256
|
+
* bad print out of it - NOK_009, the worst real part here, registers at
|
|
257
|
+
* 0.10 but its two ratios are 0.006 and 0.011, an order of magnitude below
|
|
258
|
+
* a true mismatch and lopsided besides.
|
|
259
|
+
*/
|
|
260
|
+
function gradeMatch(score, printBlemish, backgroundBlemish, cfg) {
|
|
261
|
+
const mismatchScore = cfg.mismatchScore != null ? cfg.mismatchScore : 0.15;
|
|
262
|
+
const bothSaturated = Math.min(
|
|
263
|
+
printBlemish.defectRatio,
|
|
264
|
+
backgroundBlemish.defectRatio,
|
|
265
|
+
);
|
|
266
|
+
const suspected =
|
|
267
|
+
mismatchScore > 0 &&
|
|
268
|
+
score >= mismatchScore &&
|
|
269
|
+
bothSaturated >= MISMATCH_RATIO;
|
|
270
|
+
return {
|
|
271
|
+
score,
|
|
272
|
+
// registration only - says nothing about whether the part is good
|
|
273
|
+
grade:
|
|
274
|
+
score < GRADE_GOOD ? "good" : score < mismatchScore ? "marginal" : "poor",
|
|
275
|
+
mismatchSuspected: suspected,
|
|
276
|
+
reason: suspected
|
|
277
|
+
? `alignment residual ${score.toFixed(3)} with both blemish checks saturated ` +
|
|
278
|
+
`(print ${printBlemish.defectRatio.toFixed(4)}, background ` +
|
|
279
|
+
`${backgroundBlemish.defectRatio.toFixed(4)}) - this looks like a different ` +
|
|
280
|
+
`label rather than a defective one`
|
|
281
|
+
: null,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// "Blemish Print": golden expects ink the target doesn't have, even after
|
|
286
|
+
// dilating the target's ink by printTolerance px.
|
|
287
|
+
function computeMissingInkDefect(
|
|
288
|
+
goldenFg,
|
|
289
|
+
targetFgDilated,
|
|
290
|
+
goldenAmbiguous,
|
|
291
|
+
targetAmbiguous,
|
|
292
|
+
) {
|
|
293
|
+
const n = goldenFg.length;
|
|
294
|
+
const defect = new Uint8Array(n);
|
|
295
|
+
let count = 0;
|
|
296
|
+
for (let i = 0; i < n; i++) {
|
|
297
|
+
const d = goldenFg[i] & ~targetFgDilated[i] & 1;
|
|
298
|
+
defect[i] = d;
|
|
299
|
+
count += d;
|
|
300
|
+
}
|
|
301
|
+
count = dropAmbiguous(defect, count, goldenAmbiguous, targetAmbiguous);
|
|
302
|
+
return { defect, count };
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// "Blemish Background": target has ink the golden never does, even after
|
|
306
|
+
// dilating the golden's ink by backgroundTolerance px.
|
|
307
|
+
//
|
|
308
|
+
// The ambiguity exclusion is what makes this usable against artwork. Two
|
|
309
|
+
// distinct things trip it on a good part: a screened tint that is white
|
|
310
|
+
// in the PDF prints heavy enough to cross the photo's level by a hair
|
|
311
|
+
// (ambiguous on the target side), and a solid grey panel that sits within
|
|
312
|
+
// a few levels of the *artwork's* own level, which Otsu moves around as
|
|
313
|
+
// the working resolution changes (ambiguous on the golden side). Neither
|
|
314
|
+
// is a blemish. A real mark is far from both levels and survives.
|
|
315
|
+
function computeExtraInkDefect(
|
|
316
|
+
alignedTargetFg,
|
|
317
|
+
goldenFgDilated,
|
|
318
|
+
targetAmbiguous,
|
|
319
|
+
goldenAmbiguous,
|
|
320
|
+
) {
|
|
321
|
+
const n = alignedTargetFg.length;
|
|
322
|
+
const defect = new Uint8Array(n);
|
|
323
|
+
let count = 0;
|
|
324
|
+
for (let i = 0; i < n; i++) {
|
|
325
|
+
const d = alignedTargetFg[i] & ~goldenFgDilated[i] & 1;
|
|
326
|
+
defect[i] = d;
|
|
327
|
+
count += d;
|
|
328
|
+
}
|
|
329
|
+
count = dropAmbiguous(defect, count, targetAmbiguous, goldenAmbiguous);
|
|
330
|
+
return { defect, count };
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
function buildHeatmapGrid(defect, width, height, blockSize) {
|
|
334
|
+
const table = buildIntegral(defect, width, height);
|
|
335
|
+
const gridW = Math.ceil(width / blockSize);
|
|
336
|
+
const gridH = Math.ceil(height / blockSize);
|
|
337
|
+
const density = new Float32Array(gridW * gridH);
|
|
338
|
+
for (let gy = 0; gy < gridH; gy++) {
|
|
339
|
+
const y0 = gy * blockSize;
|
|
340
|
+
const y1 = Math.min(height, y0 + blockSize);
|
|
341
|
+
for (let gx = 0; gx < gridW; gx++) {
|
|
342
|
+
const x0 = gx * blockSize;
|
|
343
|
+
const x1 = Math.min(width, x0 + blockSize);
|
|
344
|
+
const area = (x1 - x0) * (y1 - y0);
|
|
345
|
+
density[gy * gridW + gx] =
|
|
346
|
+
area > 0 ? blockSum(table, x0, y0, x1, y1) / area : 0;
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
return { density, gridW, gridH };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// 4-connected flood fill over flagged grid cells -> bounding boxes in
|
|
353
|
+
// working-resolution pixel coordinates, sorted worst-first.
|
|
354
|
+
function findRegions(
|
|
355
|
+
density,
|
|
356
|
+
gridW,
|
|
357
|
+
gridH,
|
|
358
|
+
blockThreshold,
|
|
359
|
+
blockSize,
|
|
360
|
+
width,
|
|
361
|
+
height,
|
|
362
|
+
) {
|
|
363
|
+
const n = gridW * gridH;
|
|
364
|
+
const flagged = new Uint8Array(n);
|
|
365
|
+
for (let i = 0; i < n; i++) flagged[i] = density[i] >= blockThreshold ? 1 : 0;
|
|
366
|
+
const visited = new Uint8Array(n);
|
|
367
|
+
const regions = [];
|
|
368
|
+
const stack = [];
|
|
369
|
+
|
|
370
|
+
for (let start = 0; start < n; start++) {
|
|
371
|
+
if (!flagged[start] || visited[start]) continue;
|
|
372
|
+
visited[start] = 1;
|
|
373
|
+
stack.push(start);
|
|
374
|
+
let minX = gridW;
|
|
375
|
+
let minY = gridH;
|
|
376
|
+
let maxX = -1;
|
|
377
|
+
let maxY = -1;
|
|
378
|
+
let sum = 0;
|
|
379
|
+
let count = 0;
|
|
380
|
+
let maxDensity = 0;
|
|
381
|
+
|
|
382
|
+
while (stack.length) {
|
|
383
|
+
const idx = stack.pop();
|
|
384
|
+
const gx = idx % gridW;
|
|
385
|
+
const gy = (idx / gridW) | 0;
|
|
386
|
+
if (gx < minX) minX = gx;
|
|
387
|
+
if (gx > maxX) maxX = gx;
|
|
388
|
+
if (gy < minY) minY = gy;
|
|
389
|
+
if (gy > maxY) maxY = gy;
|
|
390
|
+
sum += density[idx];
|
|
391
|
+
count++;
|
|
392
|
+
if (density[idx] > maxDensity) maxDensity = density[idx];
|
|
393
|
+
|
|
394
|
+
if (gx > 0 && flagged[idx - 1] && !visited[idx - 1]) {
|
|
395
|
+
visited[idx - 1] = 1;
|
|
396
|
+
stack.push(idx - 1);
|
|
397
|
+
}
|
|
398
|
+
if (gx < gridW - 1 && flagged[idx + 1] && !visited[idx + 1]) {
|
|
399
|
+
visited[idx + 1] = 1;
|
|
400
|
+
stack.push(idx + 1);
|
|
401
|
+
}
|
|
402
|
+
if (gy > 0 && flagged[idx - gridW] && !visited[idx - gridW]) {
|
|
403
|
+
visited[idx - gridW] = 1;
|
|
404
|
+
stack.push(idx - gridW);
|
|
405
|
+
}
|
|
406
|
+
if (gy < gridH - 1 && flagged[idx + gridW] && !visited[idx + gridW]) {
|
|
407
|
+
visited[idx + gridW] = 1;
|
|
408
|
+
stack.push(idx + gridW);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const x0 = minX * blockSize;
|
|
413
|
+
const y0 = minY * blockSize;
|
|
414
|
+
const x1 = Math.min(width, (maxX + 1) * blockSize);
|
|
415
|
+
const y1 = Math.min(height, (maxY + 1) * blockSize);
|
|
416
|
+
regions.push({
|
|
417
|
+
x: x0,
|
|
418
|
+
y: y0,
|
|
419
|
+
w: x1 - x0,
|
|
420
|
+
h: y1 - y0,
|
|
421
|
+
density: maxDensity,
|
|
422
|
+
avgDensity: sum / count,
|
|
423
|
+
cells: count,
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
regions.sort((a, b) => b.density - a.density);
|
|
428
|
+
return regions;
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
async function renderHeatmap(
|
|
432
|
+
targetGray,
|
|
433
|
+
width,
|
|
434
|
+
height,
|
|
435
|
+
density,
|
|
436
|
+
gridW,
|
|
437
|
+
gridH,
|
|
438
|
+
blockSize,
|
|
439
|
+
blockThreshold,
|
|
440
|
+
) {
|
|
441
|
+
const rgb = Buffer.alloc(width * height * 3);
|
|
442
|
+
for (let y = 0; y < height; y++) {
|
|
443
|
+
const gy = Math.min(gridH - 1, (y / blockSize) | 0);
|
|
444
|
+
const gRowBase = gy * gridW;
|
|
445
|
+
for (let x = 0; x < width; x++) {
|
|
446
|
+
const gx = Math.min(gridW - 1, (x / blockSize) | 0);
|
|
447
|
+
const d = density[gRowBase + gx];
|
|
448
|
+
const gray = targetGray[y * width + x];
|
|
449
|
+
const i = (y * width + x) * 3;
|
|
450
|
+
if (d >= blockThreshold) {
|
|
451
|
+
const alpha = Math.min(1, d);
|
|
452
|
+
rgb[i] = Math.round(gray * (1 - alpha) + 255 * alpha);
|
|
453
|
+
rgb[i + 1] = Math.round(gray * (1 - alpha));
|
|
454
|
+
rgb[i + 2] = Math.round(gray * (1 - alpha));
|
|
455
|
+
} else {
|
|
456
|
+
rgb[i] = rgb[i + 1] = rgb[i + 2] = gray;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
return sharp(rgb, { raw: { width, height, channels: 3 } })
|
|
461
|
+
.png()
|
|
462
|
+
.toBuffer();
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
// visualize a grayscale (0-255) buffer as-is
|
|
466
|
+
function renderGrayPng(gray, width, height) {
|
|
467
|
+
return sharp(gray, { raw: { width, height, channels: 1 } })
|
|
468
|
+
.png()
|
|
469
|
+
.toBuffer();
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// visualize a binary (0/1) mask as black/white
|
|
473
|
+
function renderMaskPng(mask, width, height) {
|
|
474
|
+
const vis = Buffer.alloc(width * height);
|
|
475
|
+
for (let i = 0; i < mask.length; i++) vis[i] = mask[i] ? 255 : 0;
|
|
476
|
+
return sharp(vis, { raw: { width, height, channels: 1 } })
|
|
477
|
+
.png()
|
|
478
|
+
.toBuffer();
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// Shared by printBlemish/backgroundBlemish: block-summarize a defect mask
|
|
482
|
+
// into a heat-map grid, flood-fill into regions, and score pass/fail.
|
|
483
|
+
async function buildBlemishResult(
|
|
484
|
+
defect,
|
|
485
|
+
defectCount,
|
|
486
|
+
width,
|
|
487
|
+
height,
|
|
488
|
+
targetGray,
|
|
489
|
+
cfg,
|
|
490
|
+
wantHeatmap,
|
|
491
|
+
) {
|
|
492
|
+
const { density, gridW, gridH } = buildHeatmapGrid(
|
|
493
|
+
defect,
|
|
494
|
+
width,
|
|
495
|
+
height,
|
|
496
|
+
cfg.blockSize,
|
|
497
|
+
);
|
|
498
|
+
const regions = findRegions(
|
|
499
|
+
density,
|
|
500
|
+
gridW,
|
|
501
|
+
gridH,
|
|
502
|
+
cfg.blockThreshold,
|
|
503
|
+
cfg.blockSize,
|
|
504
|
+
width,
|
|
505
|
+
height,
|
|
506
|
+
);
|
|
507
|
+
const defectRatio = defectCount / (width * height);
|
|
508
|
+
const worstDensity = regions.length ? regions[0].density : 0;
|
|
509
|
+
const pass = worstDensity < cfg.failThreshold && defectRatio < cfg.failRatio;
|
|
510
|
+
const heatmap = wantHeatmap
|
|
511
|
+
? await renderHeatmap(
|
|
512
|
+
targetGray,
|
|
513
|
+
width,
|
|
514
|
+
height,
|
|
515
|
+
density,
|
|
516
|
+
gridW,
|
|
517
|
+
gridH,
|
|
518
|
+
cfg.blockSize,
|
|
519
|
+
cfg.blockThreshold,
|
|
520
|
+
)
|
|
521
|
+
: null;
|
|
522
|
+
return { defectRatio, regions, pass, heatmap };
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
/**
|
|
526
|
+
* Position check: is the measured placement within tolerance of nominal
|
|
527
|
+
* (centered in the available margin, square to the frame)? dxPx/dyPx are
|
|
528
|
+
* already the deviation from that by the time this is called, expressed
|
|
529
|
+
* in golden working pixels. Uses mm if the rig has been calibrated
|
|
530
|
+
* (golden.mmPerWorkingPx != null), else falls back to a pixel tolerance.
|
|
531
|
+
*
|
|
532
|
+
* Rotation is gated separately rather than folded into the same number: a
|
|
533
|
+
* part that is square but offset and a part that is centered but skewed
|
|
534
|
+
* are different faults with different causes on the line, so collapsing
|
|
535
|
+
* them would throw away the more actionable half.
|
|
536
|
+
*
|
|
537
|
+
* `stretchPercent` - how far the two recovered magnifications differ - is
|
|
538
|
+
* reported but deliberately not gated. Some stretch is simply what the
|
|
539
|
+
* press does, and its normal value depends on media and machine, so a
|
|
540
|
+
* default threshold would be a guess that fails good parts. It is worth
|
|
541
|
+
* watching, though: a stretch that moves is a press drifting.
|
|
542
|
+
*/
|
|
543
|
+
function evaluatePosition(
|
|
544
|
+
dxPx,
|
|
545
|
+
dyPx,
|
|
546
|
+
angleDeg,
|
|
547
|
+
scaleX,
|
|
548
|
+
scaleY,
|
|
549
|
+
mmPerWorkingPx,
|
|
550
|
+
cfg,
|
|
551
|
+
) {
|
|
552
|
+
const anglePass = Math.abs(angleDeg) <= cfg.positionToleranceAngleDeg;
|
|
553
|
+
const stretchPercent = (scaleY / scaleX - 1) * 100;
|
|
554
|
+
const common = {
|
|
555
|
+
dxPx,
|
|
556
|
+
dyPx,
|
|
557
|
+
angleDeg,
|
|
558
|
+
anglePass,
|
|
559
|
+
scale: Math.sqrt(scaleX * scaleY),
|
|
560
|
+
scaleX,
|
|
561
|
+
scaleY,
|
|
562
|
+
stretchPercent,
|
|
563
|
+
};
|
|
564
|
+
if (mmPerWorkingPx != null) {
|
|
565
|
+
const dxMm = dxPx * mmPerWorkingPx;
|
|
566
|
+
const dyMm = dyPx * mmPerWorkingPx;
|
|
567
|
+
const pass =
|
|
568
|
+
Math.abs(dxMm) <= cfg.positionToleranceXMm &&
|
|
569
|
+
Math.abs(dyMm) <= cfg.positionToleranceYMm &&
|
|
570
|
+
anglePass;
|
|
571
|
+
return { ...common, dxMm, dyMm, pass };
|
|
572
|
+
}
|
|
573
|
+
const pass =
|
|
574
|
+
Math.abs(dxPx) <= cfg.positionToleranceXPx &&
|
|
575
|
+
Math.abs(dyPx) <= cfg.positionToleranceYPx &&
|
|
576
|
+
anglePass;
|
|
577
|
+
return { ...common, dxMm: null, dyMm: null, pass };
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/**
|
|
581
|
+
* Area-average a grayscale image down to outW x outH.
|
|
582
|
+
*
|
|
583
|
+
* The alignment polish compares this against the frame resampled by
|
|
584
|
+
* warpGray's area-average path, and the two sides must be built by the
|
|
585
|
+
* *same* operator or the comparison acquires a bias that has nothing to
|
|
586
|
+
* do with alignment. Downsampling golden's ink mask by majority vote
|
|
587
|
+
* instead - superficially the more natural choice for a binary image -
|
|
588
|
+
* is not the same operator as "average the greys, then threshold", and
|
|
589
|
+
* the difference is enough to move the objective's minimum a pixel off
|
|
590
|
+
* the true transform. An image compared against itself would then be
|
|
591
|
+
* reported as a pixel out of position.
|
|
592
|
+
*/
|
|
593
|
+
function decimateGrayBy2(gray, width, height, outW, outH) {
|
|
594
|
+
// grey table, so the Float64 accumulator: a Uint32 table over a large
|
|
595
|
+
// grey canvas wraps (see buildIntegral64) and the polish objective
|
|
596
|
+
// would be scored against garbage sums.
|
|
597
|
+
const table = buildIntegral64(gray, width, height);
|
|
598
|
+
const out = new Uint8Array(outW * outH);
|
|
599
|
+
// Exactly 2x2 boxes rather than a proportional mapping: the frame's
|
|
600
|
+
// side of the comparison is generated from the transform, so the two
|
|
601
|
+
// grids only line up if this side's decimation factor is exactly the
|
|
602
|
+
// factor that mapping assumes. An odd trailing row or column of golden
|
|
603
|
+
// simply goes unused - the objective ranks candidate transforms, it
|
|
604
|
+
// does not have to see every pixel.
|
|
605
|
+
for (let y = 0; y < outH; y++) {
|
|
606
|
+
const y0 = 2 * y;
|
|
607
|
+
const y1 = Math.min(height, y0 + 2);
|
|
608
|
+
for (let x = 0; x < outW; x++) {
|
|
609
|
+
const x0 = 2 * x;
|
|
610
|
+
const x1 = Math.min(width, x0 + 2);
|
|
611
|
+
const area = (x1 - x0) * (y1 - y0);
|
|
612
|
+
out[y * outW + x] =
|
|
613
|
+
area > 0 ? Math.round(blockSum(table, x0, y0, x1, y1) / area) : 255;
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
return out;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function gridFor(width, height, longEdge) {
|
|
620
|
+
const scale = Math.min(1, longEdge / Math.max(width, height));
|
|
621
|
+
return {
|
|
622
|
+
w: Math.max(2, Math.round(width * scale)),
|
|
623
|
+
h: Math.max(2, Math.round(height * scale)),
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
/**
|
|
628
|
+
* Preprocess and cache a golden reference. Call once per golden image, not
|
|
629
|
+
* per frame - this is the expensive-but-amortized half of the pipeline.
|
|
630
|
+
* The three density signatures the transform search compares against are
|
|
631
|
+
* built here too, so the per-frame cost is only the target side.
|
|
632
|
+
*
|
|
633
|
+
* cfg.mmPerPixelNative (optional): mm-per-pixel measured by
|
|
634
|
+
* checkerboard-calibrate, at that calibration photo's own native
|
|
635
|
+
* resolution. Rescaled here to golden's working resolution via the ratio
|
|
636
|
+
* of the calibration photo's native size to the golden's working size
|
|
637
|
+
* (cfg.calibrationNativeWidth/Height, falling back to the golden's own
|
|
638
|
+
* native size for files written before the geometry was recorded), since
|
|
639
|
+
* mm/px is a property of the fixed physical rig, not of any particular
|
|
640
|
+
* downscale.
|
|
641
|
+
*/
|
|
642
|
+
async function prepareGolden(buffer, cfg) {
|
|
643
|
+
const nativeMeta = cfg.raw
|
|
644
|
+
? { width: cfg.raw.width, height: cfg.raw.height }
|
|
645
|
+
: await sharp(buffer).metadata();
|
|
646
|
+
const { width, height } = goldenWorkingSize(
|
|
647
|
+
nativeMeta.width,
|
|
648
|
+
nativeMeta.height,
|
|
649
|
+
cfg.workingSize,
|
|
650
|
+
);
|
|
651
|
+
const pixels = await decodeGray(buffer, width, height, cfg.raw);
|
|
652
|
+
const {
|
|
653
|
+
fg,
|
|
654
|
+
level,
|
|
655
|
+
ambiguous: fgAmbiguous,
|
|
656
|
+
} = thresholdForeground(pixels, width, height, cfg);
|
|
657
|
+
const fgDilatedBackground = dilate(fg, width, height, cfg.backgroundTolerance);
|
|
658
|
+
|
|
659
|
+
const coarse = gridFor(width, height, COARSE_GRID);
|
|
660
|
+
const medium = gridFor(width, height, MEDIUM_GRID);
|
|
661
|
+
const fine = gridFor(width, height, FINE_GRID);
|
|
662
|
+
const signatures = {
|
|
663
|
+
coarse: buildGoldenSignature(fg, width, height, coarse.w, coarse.h),
|
|
664
|
+
medium: buildGoldenSignature(fg, width, height, medium.w, medium.h),
|
|
665
|
+
fine: buildGoldenSignature(fg, width, height, fine.w, fine.h),
|
|
666
|
+
};
|
|
667
|
+
|
|
668
|
+
// Decimated ink mask for the alignment polish to score candidates
|
|
669
|
+
// against. Built the same way the polish builds the frame's side -
|
|
670
|
+
// average the greys, then threshold - see decimateGrayBy2.
|
|
671
|
+
//
|
|
672
|
+
// A *fixed canvas*, not a fixed fraction of the golden. The polish only
|
|
673
|
+
// has to rank sub-percent transform nudges, and how many pixels that
|
|
674
|
+
// takes depends on the label, not on the working size; tying it to
|
|
675
|
+
// workingSize made every objective call four times dearer at 3072 than
|
|
676
|
+
// at 1024 for no gain, and the polish is the single largest cost in the
|
|
677
|
+
// frame. The divisor stays a power of two so decimation is exact 2x2
|
|
678
|
+
// box averaging, with no resampling error creeping into the objective.
|
|
679
|
+
let objDivisor = 1;
|
|
680
|
+
while (Math.max(width, height) / (objDivisor * 2) >= OBJECTIVE_LONG_EDGE)
|
|
681
|
+
objDivisor *= 2;
|
|
682
|
+
if (objDivisor < 2) objDivisor = 2;
|
|
683
|
+
// A per-pixel mode has no single level to reuse here, so the objective
|
|
684
|
+
// falls back to one global level on both sides; it only has to rank
|
|
685
|
+
// candidate transforms consistently, not reproduce the real ink mask.
|
|
686
|
+
const objectiveLevel = level != null ? level : otsuThreshold(pixels);
|
|
687
|
+
let objGray = pixels;
|
|
688
|
+
let objWidth = width;
|
|
689
|
+
let objHeight = height;
|
|
690
|
+
for (let d = 1; d < objDivisor; d *= 2) {
|
|
691
|
+
const w = Math.max(1, objWidth >> 1);
|
|
692
|
+
const h = Math.max(1, objHeight >> 1);
|
|
693
|
+
objGray = decimateGrayBy2(objGray, objWidth, objHeight, w, h);
|
|
694
|
+
objWidth = w;
|
|
695
|
+
objHeight = h;
|
|
696
|
+
}
|
|
697
|
+
const fgObj = new Uint8Array(objGray.length);
|
|
698
|
+
for (let i = 0; i < objGray.length; i++)
|
|
699
|
+
fgObj[i] = objGray[i] < objectiveLevel ? 1 : 0;
|
|
700
|
+
|
|
701
|
+
// Debug stages are rendered only when asked for: three PNG encodes of
|
|
702
|
+
// the full golden, retained on the cached golden for the node's whole
|
|
703
|
+
// lifetime - an unconditional render taxes every frame of every flow
|
|
704
|
+
// that never looks at them. cfg.debugStages is baked into the golden
|
|
705
|
+
// cache key in golden-compare.js so a flip of the flag re-prepares.
|
|
706
|
+
const stages = cfg.debugStages
|
|
707
|
+
? {
|
|
708
|
+
goldenGray: await renderGrayPng(pixels, width, height),
|
|
709
|
+
goldenFg: await renderMaskPng(fg, width, height),
|
|
710
|
+
goldenFgDilatedBackground: await renderMaskPng(
|
|
711
|
+
fgDilatedBackground,
|
|
712
|
+
width,
|
|
713
|
+
height,
|
|
714
|
+
),
|
|
715
|
+
}
|
|
716
|
+
: null;
|
|
717
|
+
|
|
718
|
+
let mmPerWorkingPx = null;
|
|
719
|
+
if (cfg.mmPerPixelNative != null) {
|
|
720
|
+
// mmPerPixelNative was measured on the calibration photo, so the
|
|
721
|
+
// conversion is expressed against *that* photo's native size, not the
|
|
722
|
+
// golden's: the golden's own native resolution cancels out of the
|
|
723
|
+
// ratio (calibration native / golden native) * (golden native / golden
|
|
724
|
+
// working). Files written before the calibration recorded its own
|
|
725
|
+
// geometry fall back to the golden's native size - the assumption
|
|
726
|
+
// that golden and calibration photo are the same resolution.
|
|
727
|
+
const nativeMaxDim =
|
|
728
|
+
cfg.calibrationNativeWidth != null && cfg.calibrationNativeHeight != null
|
|
729
|
+
? Math.max(cfg.calibrationNativeWidth, cfg.calibrationNativeHeight)
|
|
730
|
+
: Math.max(nativeMeta.width, nativeMeta.height);
|
|
731
|
+
const workingMaxDim = Math.max(width, height);
|
|
732
|
+
mmPerWorkingPx = cfg.mmPerPixelNative * (nativeMaxDim / workingMaxDim);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// The golden is prepared once and then read by every frame, including
|
|
736
|
+
// from worker threads. Left on ordinary buffers, each frame copied all
|
|
737
|
+
// of it into shared memory again - toShared() on golden.gray, .fg,
|
|
738
|
+
// .fgAmbiguous (twice) and .fgDilatedBackground, ~4ms a frame at 4.9MP,
|
|
739
|
+
// to re-share something that has not changed since it was cached.
|
|
740
|
+
// Sharing it here makes those calls the identity.
|
|
741
|
+
//
|
|
742
|
+
// The `x ? ... : null` is not defensive noise: fgAmbiguous is null
|
|
743
|
+
// whenever inkMargin is 0, and toShared(null) throws.
|
|
744
|
+
const share = (x) => (x ? toShared(x) : null);
|
|
745
|
+
return {
|
|
746
|
+
width,
|
|
747
|
+
height,
|
|
748
|
+
// native (pre-downscale) size, so the target can be brought to
|
|
749
|
+
// golden's *realized* working scale - see computeTargetWorkingSize
|
|
750
|
+
nativeWidth: nativeMeta.width,
|
|
751
|
+
nativeHeight: nativeMeta.height,
|
|
752
|
+
gray: share(pixels),
|
|
753
|
+
fg: share(fg),
|
|
754
|
+
// withheld from both checks' evidence, not from alignment
|
|
755
|
+
fgAmbiguous: share(fgAmbiguous),
|
|
756
|
+
fgObj: share(fgObj),
|
|
757
|
+
objWidth,
|
|
758
|
+
objHeight,
|
|
759
|
+
objDivisor,
|
|
760
|
+
objectiveLevel,
|
|
761
|
+
fgDilatedBackground: share(fgDilatedBackground),
|
|
762
|
+
thresholdLevel: level,
|
|
763
|
+
signatures,
|
|
764
|
+
stages,
|
|
765
|
+
mmPerWorkingPx,
|
|
766
|
+
};
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/**
|
|
770
|
+
* Compare one camera frame against a prepared golden reference. Safe to
|
|
771
|
+
* call repeatedly against the same `golden` object (the hot path).
|
|
772
|
+
*/
|
|
773
|
+
async function compareFrame(buffer, golden, cfg) {
|
|
774
|
+
const t0 = now();
|
|
775
|
+
// The full native prototype also owns decode. PNG inflate is the largest
|
|
776
|
+
// serial cost on high-compression rejects; OpenCV can decode directly to
|
|
777
|
+
// grayscale and avoids entering sharp at all. Any native decode/resize
|
|
778
|
+
// failure falls back to the established sharp path.
|
|
779
|
+
const nativeDecoded =
|
|
780
|
+
cfg.nativeFastAlign && nativeSeed.available()
|
|
781
|
+
? await nativeSeed.decodeGray(buffer, cfg.targetRaw)
|
|
782
|
+
: null;
|
|
783
|
+
let targetMeta;
|
|
784
|
+
if (nativeDecoded) {
|
|
785
|
+
targetMeta = { width: nativeDecoded.width, height: nativeDecoded.height };
|
|
786
|
+
} else if (cfg.targetRaw) {
|
|
787
|
+
targetMeta = { width: cfg.targetRaw.width, height: cfg.targetRaw.height };
|
|
788
|
+
} else {
|
|
789
|
+
targetMeta = await sharp(buffer).metadata();
|
|
790
|
+
}
|
|
791
|
+
const targetWorking = computeTargetWorkingSize(
|
|
792
|
+
targetMeta.width,
|
|
793
|
+
targetMeta.height,
|
|
794
|
+
golden,
|
|
795
|
+
cfg,
|
|
796
|
+
);
|
|
797
|
+
const nativeGray = nativeDecoded
|
|
798
|
+
? await nativeSeed.resizeGray(
|
|
799
|
+
nativeDecoded,
|
|
800
|
+
targetWorking.width,
|
|
801
|
+
targetWorking.height,
|
|
802
|
+
)
|
|
803
|
+
: null;
|
|
804
|
+
const targetGray =
|
|
805
|
+
nativeGray ||
|
|
806
|
+
(await decodeGray(
|
|
807
|
+
buffer,
|
|
808
|
+
targetWorking.width,
|
|
809
|
+
targetWorking.height,
|
|
810
|
+
cfg.targetRaw,
|
|
811
|
+
));
|
|
812
|
+
const decodeMs = now() - t0;
|
|
813
|
+
|
|
814
|
+
const t1 = now();
|
|
815
|
+
// Aggressive prototype: let OpenCV solve the full affine transform and
|
|
816
|
+
// return the already-warped golden-sized grayscale frame. Try it before
|
|
817
|
+
// thresholding: on success the full-resolution target mask is another
|
|
818
|
+
// large allocation and scan that no later stage needs.
|
|
819
|
+
const tNative = now();
|
|
820
|
+
let nativeAligned =
|
|
821
|
+
cfg.nativeFastAlign && nativeSeed.available()
|
|
822
|
+
? await nativeSeed.alignFrame(
|
|
823
|
+
golden.gray,
|
|
824
|
+
golden.width,
|
|
825
|
+
golden.height,
|
|
826
|
+
targetGray,
|
|
827
|
+
targetWorking.width,
|
|
828
|
+
targetWorking.height,
|
|
829
|
+
{
|
|
830
|
+
scale: cfg.nativeFastAlignScale,
|
|
831
|
+
eccRefine: cfg.nativeFastEccRefine,
|
|
832
|
+
},
|
|
833
|
+
)
|
|
834
|
+
: null;
|
|
835
|
+
const nativeAlignMs = now() - tNative;
|
|
836
|
+
let nativeFallback = null;
|
|
837
|
+
if (nativeAligned) {
|
|
838
|
+
nativeFallback = nativeSeed.validateAlignment(
|
|
839
|
+
nativeAligned.transform,
|
|
840
|
+
cfg.pinnedScale,
|
|
841
|
+
cfg.maxAngleDeg,
|
|
842
|
+
);
|
|
843
|
+
if (nativeFallback) nativeAligned = null;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
// The JS search needs the frame mask. OpenCV does not; it only needs the
|
|
847
|
+
// scalar ink level later when the aligned canvas is thresholded. Preserve
|
|
848
|
+
// targetFg under debugStages because it is a documented debug image.
|
|
849
|
+
let targetFg = null;
|
|
850
|
+
let targetLevel = null;
|
|
851
|
+
if (nativeAligned) {
|
|
852
|
+
if (cfg.thresholdMode === "fixed") targetLevel = cfg.threshold;
|
|
853
|
+
if (cfg.debugStages) {
|
|
854
|
+
const thresholded = thresholdForeground(
|
|
855
|
+
targetGray,
|
|
856
|
+
targetWorking.width,
|
|
857
|
+
targetWorking.height,
|
|
858
|
+
cfg,
|
|
859
|
+
);
|
|
860
|
+
targetFg = thresholded.fg;
|
|
861
|
+
targetLevel = thresholded.level;
|
|
862
|
+
}
|
|
863
|
+
} else {
|
|
864
|
+
const thresholded = thresholdForeground(
|
|
865
|
+
targetGray,
|
|
866
|
+
targetWorking.width,
|
|
867
|
+
targetWorking.height,
|
|
868
|
+
cfg,
|
|
869
|
+
);
|
|
870
|
+
targetFg = thresholded.fg;
|
|
871
|
+
targetLevel = thresholded.level;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// One summed-area table over the frame's greys, shared by every
|
|
875
|
+
// candidate warp in the polish stage and by the final full-resolution
|
|
876
|
+
// warp below. The native fast path needs neither consumer.
|
|
877
|
+
const tTable = now();
|
|
878
|
+
const grayTable = nativeAligned
|
|
879
|
+
? null
|
|
880
|
+
: await buildGrayTableParallel(
|
|
881
|
+
targetGray,
|
|
882
|
+
targetWorking.width,
|
|
883
|
+
targetWorking.height,
|
|
884
|
+
cfg.workers,
|
|
885
|
+
);
|
|
886
|
+
const tableMs = nativeAligned ? 0 : now() - tTable;
|
|
887
|
+
|
|
888
|
+
// One shared copy of the frame's greys for every JS stage that dispatches
|
|
889
|
+
// to the pool. OpenCV consumed the original view directly and has already
|
|
890
|
+
// produced the only aligned canvas the fast path needs.
|
|
891
|
+
const sharedTargetGray = nativeAligned ? targetGray : toShared(targetGray);
|
|
892
|
+
|
|
893
|
+
// The polish objective: resample the frame into a half-resolution
|
|
894
|
+
// golden grid under the candidate transform, threshold it at the
|
|
895
|
+
// frame's own level, and count pixels that disagree with golden's ink.
|
|
896
|
+
// A fixed level (rather than re-deriving one per candidate) keeps the
|
|
897
|
+
// comparison between candidates honest - otherwise a transform could
|
|
898
|
+
// improve its score by shifting the level rather than the alignment.
|
|
899
|
+
let objectiveLevel = 0;
|
|
900
|
+
if (!nativeAligned) {
|
|
901
|
+
objectiveLevel =
|
|
902
|
+
targetLevel != null ? targetLevel : otsuThreshold(targetGray);
|
|
903
|
+
}
|
|
904
|
+
const D = golden.objDivisor;
|
|
905
|
+
// Half-resolution pixel x covers golden pixels 2x and 2x+1, so an
|
|
906
|
+
// objective pixel x covers golden pixels Dx .. Dx+D-1 and its centre
|
|
907
|
+
// sits at golden index Dx + (D-1)/2. The transform has to be
|
|
908
|
+
// re-expressed for that grid - magnifications scaled by D, origin
|
|
909
|
+
// shifted by the half-cell through the same rotation - rather than
|
|
910
|
+
// simply scaled, which would sample half a cell off.
|
|
911
|
+
//
|
|
912
|
+
// Done here rather than inside the scorer so that both the serial and
|
|
913
|
+
// the batched forms score candidates in identical coordinates: the
|
|
914
|
+
// worker kernel deliberately does no conversion of its own.
|
|
915
|
+
const toObjectiveGrid = (c) => {
|
|
916
|
+
const cos = Math.cos(c.theta);
|
|
917
|
+
const sin = Math.sin(c.theta);
|
|
918
|
+
const half = (D - 1) / 2;
|
|
919
|
+
return {
|
|
920
|
+
mx: c.mx * D,
|
|
921
|
+
my: c.my * D,
|
|
922
|
+
theta: c.theta,
|
|
923
|
+
ox: c.ox + half * (cos * c.mx - sin * c.my),
|
|
924
|
+
oy: c.oy + half * (sin * c.mx + cos * c.my),
|
|
925
|
+
};
|
|
926
|
+
};
|
|
927
|
+
|
|
928
|
+
// The serial scorer stays the reference implementation: resample the
|
|
929
|
+
// frame into the decimated golden grid under the candidate transform,
|
|
930
|
+
// threshold it at the frame's own level, and count pixels that disagree
|
|
931
|
+
// with golden's ink.
|
|
932
|
+
const scoreOne = (g) => {
|
|
933
|
+
const gray = warpGray(
|
|
934
|
+
sharedTargetGray,
|
|
935
|
+
targetWorking.width,
|
|
936
|
+
targetWorking.height,
|
|
937
|
+
g.mx,
|
|
938
|
+
g.my,
|
|
939
|
+
g.theta,
|
|
940
|
+
g.ox,
|
|
941
|
+
g.oy,
|
|
942
|
+
golden.objWidth,
|
|
943
|
+
golden.objHeight,
|
|
944
|
+
255,
|
|
945
|
+
grayTable,
|
|
946
|
+
);
|
|
947
|
+
let mismatch = 0;
|
|
948
|
+
for (let i = 0; i < gray.length; i++) {
|
|
949
|
+
const fgPixel = gray[i] < objectiveLevel ? 1 : 0;
|
|
950
|
+
if (fgPixel !== golden.fgObj[i]) mismatch++;
|
|
951
|
+
}
|
|
952
|
+
return mismatch / gray.length;
|
|
953
|
+
};
|
|
954
|
+
|
|
955
|
+
// The frame descriptor the batched scorer needs. Built once per frame:
|
|
956
|
+
// toShared() on a 17.5MP frame costs ~3ms, and the polish asks for ten
|
|
957
|
+
// to twenty batches, so building it per batch would cost more than the
|
|
958
|
+
// parallelism saves.
|
|
959
|
+
const objectiveFrame = {
|
|
960
|
+
gray: sharedTargetGray,
|
|
961
|
+
width: targetWorking.width,
|
|
962
|
+
height: targetWorking.height,
|
|
963
|
+
table: grayTable,
|
|
964
|
+
outW: golden.objWidth,
|
|
965
|
+
outH: golden.objHeight,
|
|
966
|
+
level: objectiveLevel,
|
|
967
|
+
fgObj: golden.fgObj,
|
|
968
|
+
};
|
|
969
|
+
|
|
970
|
+
const objectiveBatch = async (cands) => {
|
|
971
|
+
const grid = cands.map(toObjectiveGrid);
|
|
972
|
+
const par = await objectiveBatchParallel(grid, objectiveFrame, cfg.workers);
|
|
973
|
+
return par || grid.map(scoreOne);
|
|
974
|
+
};
|
|
975
|
+
|
|
976
|
+
// PROTOTYPE, off unless nativeAlignSeed is set and the optional engine
|
|
977
|
+
// is installed: hand the pinned search a starting point measured by
|
|
978
|
+
// ORB+ECC instead of one found by sweeping. Falls back to the sweeps
|
|
979
|
+
// on anything unexpected - a missing engine, a failed alignment, or a
|
|
980
|
+
// seed outside the physically plausible range.
|
|
981
|
+
let seed = null;
|
|
982
|
+
if (
|
|
983
|
+
!nativeAligned &&
|
|
984
|
+
cfg.nativeAlignSeed &&
|
|
985
|
+
cfg.pinnedScale &&
|
|
986
|
+
nativeSeed.available()
|
|
987
|
+
) {
|
|
988
|
+
seed = await nativeSeed.seedTransform(
|
|
989
|
+
golden.gray,
|
|
990
|
+
golden.width,
|
|
991
|
+
golden.height,
|
|
992
|
+
targetGray,
|
|
993
|
+
targetWorking.width,
|
|
994
|
+
targetWorking.height,
|
|
995
|
+
);
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
const tSearch = now();
|
|
999
|
+
let transform;
|
|
1000
|
+
if (nativeAligned) {
|
|
1001
|
+
transform = {
|
|
1002
|
+
...nativeAligned.transform,
|
|
1003
|
+
thetaDeg: (nativeAligned.transform.theta * 180) / Math.PI,
|
|
1004
|
+
score: NaN,
|
|
1005
|
+
pinned: false,
|
|
1006
|
+
native: true,
|
|
1007
|
+
};
|
|
1008
|
+
} else {
|
|
1009
|
+
transform = await findTransform(
|
|
1010
|
+
golden.width,
|
|
1011
|
+
golden.height,
|
|
1012
|
+
targetFg,
|
|
1013
|
+
targetWorking.width,
|
|
1014
|
+
targetWorking.height,
|
|
1015
|
+
golden.signatures,
|
|
1016
|
+
{
|
|
1017
|
+
scaleMin: cfg.scaleSearchMin,
|
|
1018
|
+
scaleMax: cfg.scaleSearchMax,
|
|
1019
|
+
scaleSteps: cfg.scaleSearchSteps,
|
|
1020
|
+
maxAspect: cfg.maxAspect,
|
|
1021
|
+
aspectSteps: cfg.aspectSteps,
|
|
1022
|
+
// how many stage-2 scale hypotheses survive to be judged on
|
|
1023
|
+
// pixels rather than on the coarse density proxy
|
|
1024
|
+
rankedCandidates: cfg.alignCandidates,
|
|
1025
|
+
// a trained transform pins magnification and stretch, leaving
|
|
1026
|
+
// only the per-part unknowns (where it sits, how square) to solve
|
|
1027
|
+
pinnedScale: cfg.pinnedScale || null,
|
|
1028
|
+
maxAngleDeg: cfg.maxAngleDeg,
|
|
1029
|
+
angleSteps: cfg.angleSteps,
|
|
1030
|
+
slackPx: cfg.alignSearch,
|
|
1031
|
+
objectiveBatch,
|
|
1032
|
+
// the frame's summed-area table is per-frame work over the whole
|
|
1033
|
+
// canvas; the pool is otherwise idle while it is built
|
|
1034
|
+
buildTable: (fg, w, h) =>
|
|
1035
|
+
buildIntegralParallel(fg, w, h, cfg.workers),
|
|
1036
|
+
seed,
|
|
1037
|
+
},
|
|
1038
|
+
);
|
|
1039
|
+
if (nativeFallback) transform.nativeFallback = nativeFallback;
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
const searchMs = nativeAligned ? nativeAlignMs : now() - tSearch;
|
|
1043
|
+
|
|
1044
|
+
const tWarp = now();
|
|
1045
|
+
let alignedTargetGray = nativeAligned
|
|
1046
|
+
? nativeAligned.gray
|
|
1047
|
+
: await warpParallel(
|
|
1048
|
+
sharedTargetGray,
|
|
1049
|
+
targetWorking.width,
|
|
1050
|
+
targetWorking.height,
|
|
1051
|
+
transform.mx,
|
|
1052
|
+
transform.my,
|
|
1053
|
+
transform.theta,
|
|
1054
|
+
transform.ox,
|
|
1055
|
+
transform.oy,
|
|
1056
|
+
golden.width,
|
|
1057
|
+
golden.height,
|
|
1058
|
+
255,
|
|
1059
|
+
grayTable,
|
|
1060
|
+
cfg.workers,
|
|
1061
|
+
);
|
|
1062
|
+
// The global transform places the label; it cannot place all of it.
|
|
1063
|
+
// What is left is a non-smooth field - most of the frame sub-pixel,
|
|
1064
|
+
// some regions several px out - which no higher-order global model
|
|
1065
|
+
// reaches. See lib/localAlign.js.
|
|
1066
|
+
const warpMs = nativeAligned ? 0 : now() - tWarp;
|
|
1067
|
+
|
|
1068
|
+
const tLocal = now();
|
|
1069
|
+
let localAlign = null;
|
|
1070
|
+
if (cfg.localAlign) {
|
|
1071
|
+
const refined =
|
|
1072
|
+
(await refineLocallyParallel(
|
|
1073
|
+
golden.gray,
|
|
1074
|
+
alignedTargetGray,
|
|
1075
|
+
golden.width,
|
|
1076
|
+
golden.height,
|
|
1077
|
+
cfg,
|
|
1078
|
+
)) ||
|
|
1079
|
+
refineLocally(
|
|
1080
|
+
golden.gray,
|
|
1081
|
+
alignedTargetGray,
|
|
1082
|
+
golden.width,
|
|
1083
|
+
golden.height,
|
|
1084
|
+
cfg,
|
|
1085
|
+
);
|
|
1086
|
+
alignedTargetGray = refined.gray;
|
|
1087
|
+
localAlign = refined;
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
const localAlignMs = now() - tLocal;
|
|
1091
|
+
|
|
1092
|
+
const tThresh = now();
|
|
1093
|
+
// Sauvola has no single level to hand the workers, so it stays serial.
|
|
1094
|
+
let alignedTargetFg;
|
|
1095
|
+
let alignedTargetAmbiguous;
|
|
1096
|
+
const globalLevel =
|
|
1097
|
+
cfg.thresholdMode === "sauvola"
|
|
1098
|
+
? null
|
|
1099
|
+
: cfg.thresholdMode === "otsu"
|
|
1100
|
+
? otsuThreshold(alignedTargetGray)
|
|
1101
|
+
: cfg.threshold;
|
|
1102
|
+
// The native path never needed to threshold the unaligned 22MP frame.
|
|
1103
|
+
// Report the level actually used on its aligned canvas instead.
|
|
1104
|
+
if (nativeAligned && targetLevel == null) targetLevel = globalLevel;
|
|
1105
|
+
const binarized =
|
|
1106
|
+
globalLevel === null
|
|
1107
|
+
? null
|
|
1108
|
+
: await binarizeParallel(
|
|
1109
|
+
alignedTargetGray,
|
|
1110
|
+
golden.width,
|
|
1111
|
+
golden.height,
|
|
1112
|
+
globalLevel,
|
|
1113
|
+
cfg.inkMargin > 0 ? cfg.inkMargin : 0,
|
|
1114
|
+
cfg.workers,
|
|
1115
|
+
);
|
|
1116
|
+
if (binarized) {
|
|
1117
|
+
alignedTargetFg = binarized.fg;
|
|
1118
|
+
alignedTargetAmbiguous = binarized.ambiguous;
|
|
1119
|
+
} else {
|
|
1120
|
+
const serial = thresholdForeground(
|
|
1121
|
+
alignedTargetGray,
|
|
1122
|
+
golden.width,
|
|
1123
|
+
golden.height,
|
|
1124
|
+
cfg,
|
|
1125
|
+
);
|
|
1126
|
+
alignedTargetFg = serial.fg;
|
|
1127
|
+
alignedTargetAmbiguous = serial.ambiguous;
|
|
1128
|
+
}
|
|
1129
|
+
if (nativeAligned) {
|
|
1130
|
+
// imageAlign does not expose ECC's correlation. Use the actual full-size
|
|
1131
|
+
// post-alignment mask disagreement instead; this is deliberately allowed
|
|
1132
|
+
// to differ from the JS polish's 320px pre-local-refinement objective.
|
|
1133
|
+
let mismatch = 0;
|
|
1134
|
+
for (let i = 0; i < alignedTargetFg.length; i++) {
|
|
1135
|
+
if (alignedTargetFg[i] !== golden.fg[i]) mismatch++;
|
|
1136
|
+
}
|
|
1137
|
+
transform.score = mismatch / alignedTargetFg.length;
|
|
1138
|
+
const maxNativeScore = 0.15;
|
|
1139
|
+
if (transform.score > maxNativeScore) {
|
|
1140
|
+
const attemptedMs = now() - t0;
|
|
1141
|
+
const fallback = await compareFrame(buffer, golden, {
|
|
1142
|
+
...cfg,
|
|
1143
|
+
nativeFastAlign: false,
|
|
1144
|
+
nativeAlignSeed: false,
|
|
1145
|
+
});
|
|
1146
|
+
fallback.transform.nativeFallback =
|
|
1147
|
+
`OpenCV score ${transform.score.toFixed(4)} exceeds ${maxNativeScore.toFixed(2)}`;
|
|
1148
|
+
fallback.timings.nativeFallbackMs = attemptedMs;
|
|
1149
|
+
fallback.timings.totalMs += attemptedMs;
|
|
1150
|
+
return fallback;
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
const thresholdMs = now() - tThresh;
|
|
1154
|
+
const alignMs = now() - t1;
|
|
1155
|
+
|
|
1156
|
+
// nominal = the same magnification and squareness, centered in
|
|
1157
|
+
// whatever margin the frame has; degrades to (0,0) when the frame and
|
|
1158
|
+
// golden are already the same size at m = 1.
|
|
1159
|
+
const nominalOx = (targetWorking.width - transform.mx * golden.width) / 2;
|
|
1160
|
+
const nominalOy = (targetWorking.height - transform.my * golden.height) / 2;
|
|
1161
|
+
// reported in golden working px, so it shares units with the region
|
|
1162
|
+
// boxes and converts to mm with the one mmPerWorkingPx factor
|
|
1163
|
+
const dxPx = Math.round((transform.ox - nominalOx) / transform.mx);
|
|
1164
|
+
const dyPx = Math.round((transform.oy - nominalOy) / transform.my);
|
|
1165
|
+
const position = evaluatePosition(
|
|
1166
|
+
dxPx,
|
|
1167
|
+
dyPx,
|
|
1168
|
+
transform.thetaDeg,
|
|
1169
|
+
transform.mx,
|
|
1170
|
+
transform.my,
|
|
1171
|
+
golden.mmPerWorkingPx,
|
|
1172
|
+
cfg,
|
|
1173
|
+
);
|
|
1174
|
+
|
|
1175
|
+
const t2 = now();
|
|
1176
|
+
const targetFgDilatedPrint = await dilateParallel(
|
|
1177
|
+
alignedTargetFg,
|
|
1178
|
+
golden.width,
|
|
1179
|
+
golden.height,
|
|
1180
|
+
cfg.printTolerance,
|
|
1181
|
+
cfg.workers,
|
|
1182
|
+
);
|
|
1183
|
+
const printPar = await defectParallel(
|
|
1184
|
+
golden.fg,
|
|
1185
|
+
targetFgDilatedPrint,
|
|
1186
|
+
golden.fgAmbiguous,
|
|
1187
|
+
alignedTargetAmbiguous,
|
|
1188
|
+
golden.width,
|
|
1189
|
+
golden.height,
|
|
1190
|
+
cfg.workers,
|
|
1191
|
+
);
|
|
1192
|
+
const { defect: printDefect, count: printDefectCount } =
|
|
1193
|
+
printPar ||
|
|
1194
|
+
computeMissingInkDefect(
|
|
1195
|
+
golden.fg,
|
|
1196
|
+
targetFgDilatedPrint,
|
|
1197
|
+
golden.fgAmbiguous,
|
|
1198
|
+
alignedTargetAmbiguous,
|
|
1199
|
+
);
|
|
1200
|
+
const backgroundPar = await defectParallel(
|
|
1201
|
+
alignedTargetFg,
|
|
1202
|
+
golden.fgDilatedBackground,
|
|
1203
|
+
alignedTargetAmbiguous,
|
|
1204
|
+
golden.fgAmbiguous,
|
|
1205
|
+
golden.width,
|
|
1206
|
+
golden.height,
|
|
1207
|
+
cfg.workers,
|
|
1208
|
+
);
|
|
1209
|
+
const { defect: backgroundDefect, count: backgroundDefectCount } =
|
|
1210
|
+
backgroundPar ||
|
|
1211
|
+
computeExtraInkDefect(
|
|
1212
|
+
alignedTargetFg,
|
|
1213
|
+
golden.fgDilatedBackground,
|
|
1214
|
+
alignedTargetAmbiguous,
|
|
1215
|
+
golden.fgAmbiguous,
|
|
1216
|
+
);
|
|
1217
|
+
const diffMs = now() - t2;
|
|
1218
|
+
|
|
1219
|
+
const t3 = now();
|
|
1220
|
+
const printBlemish = await buildBlemishResult(
|
|
1221
|
+
printDefect,
|
|
1222
|
+
printDefectCount,
|
|
1223
|
+
golden.width,
|
|
1224
|
+
golden.height,
|
|
1225
|
+
alignedTargetGray,
|
|
1226
|
+
cfg,
|
|
1227
|
+
cfg.outputPrintHeatmap,
|
|
1228
|
+
);
|
|
1229
|
+
const backgroundBlemish = await buildBlemishResult(
|
|
1230
|
+
backgroundDefect,
|
|
1231
|
+
backgroundDefectCount,
|
|
1232
|
+
golden.width,
|
|
1233
|
+
golden.height,
|
|
1234
|
+
alignedTargetGray,
|
|
1235
|
+
cfg,
|
|
1236
|
+
cfg.outputBackgroundHeatmap,
|
|
1237
|
+
);
|
|
1238
|
+
const heatmapMs = now() - t3;
|
|
1239
|
+
|
|
1240
|
+
let stages = null;
|
|
1241
|
+
const t4 = now();
|
|
1242
|
+
if (cfg.debugStages) {
|
|
1243
|
+
const { width, height } = golden;
|
|
1244
|
+
stages = {
|
|
1245
|
+
...golden.stages,
|
|
1246
|
+
// full pre-warp canvas, so you can see where the match landed
|
|
1247
|
+
// within the whole frame - generally a different size than
|
|
1248
|
+
// golden's (that's the point)
|
|
1249
|
+
targetGray: await renderGrayPng(
|
|
1250
|
+
targetGray,
|
|
1251
|
+
targetWorking.width,
|
|
1252
|
+
targetWorking.height,
|
|
1253
|
+
),
|
|
1254
|
+
targetFg: await renderMaskPng(
|
|
1255
|
+
targetFg,
|
|
1256
|
+
targetWorking.width,
|
|
1257
|
+
targetWorking.height,
|
|
1258
|
+
),
|
|
1259
|
+
// golden-sized from here on: the matched region, resampled
|
|
1260
|
+
targetGrayAligned: await renderGrayPng(alignedTargetGray, width, height),
|
|
1261
|
+
targetFgAligned: await renderMaskPng(alignedTargetFg, width, height),
|
|
1262
|
+
targetFgDilatedPrint: await renderMaskPng(
|
|
1263
|
+
targetFgDilatedPrint,
|
|
1264
|
+
width,
|
|
1265
|
+
height,
|
|
1266
|
+
),
|
|
1267
|
+
printDefect: await renderMaskPng(printDefect, width, height),
|
|
1268
|
+
backgroundDefect: await renderMaskPng(backgroundDefect, width, height),
|
|
1269
|
+
};
|
|
1270
|
+
}
|
|
1271
|
+
const stagesMs = now() - t4;
|
|
1272
|
+
|
|
1273
|
+
const pass = position.pass && printBlemish.pass && backgroundBlemish.pass;
|
|
1274
|
+
const match = gradeMatch(
|
|
1275
|
+
transform.score,
|
|
1276
|
+
printBlemish,
|
|
1277
|
+
backgroundBlemish,
|
|
1278
|
+
cfg,
|
|
1279
|
+
);
|
|
1280
|
+
|
|
1281
|
+
return {
|
|
1282
|
+
pass,
|
|
1283
|
+
match,
|
|
1284
|
+
position,
|
|
1285
|
+
printBlemish,
|
|
1286
|
+
backgroundBlemish,
|
|
1287
|
+
stages,
|
|
1288
|
+
width: golden.width,
|
|
1289
|
+
height: golden.height,
|
|
1290
|
+
transform: {
|
|
1291
|
+
pinned: !!transform.pinned,
|
|
1292
|
+
native: !!transform.native,
|
|
1293
|
+
nativeFallback: transform.nativeFallback || null,
|
|
1294
|
+
// PROTOTYPE: true when the pinned search started from a native
|
|
1295
|
+
// ORB+ECC seed rather than the staged sweeps. Reported because a
|
|
1296
|
+
// flag you cannot see the effect of is a flag you cannot trust.
|
|
1297
|
+
seeded: !!transform.seeded,
|
|
1298
|
+
scaleX: transform.mx,
|
|
1299
|
+
scaleY: transform.my,
|
|
1300
|
+
scale: Math.sqrt(transform.mx * transform.my),
|
|
1301
|
+
stretchPercent: (transform.my / transform.mx - 1) * 100,
|
|
1302
|
+
angleDeg: transform.thetaDeg,
|
|
1303
|
+
ox: transform.ox,
|
|
1304
|
+
oy: transform.oy,
|
|
1305
|
+
score: transform.score,
|
|
1306
|
+
},
|
|
1307
|
+
thresholds: { golden: golden.thresholdLevel, target: targetLevel },
|
|
1308
|
+
localAlign: localAlign ? localAlign.stats : null,
|
|
1309
|
+
targetWorking,
|
|
1310
|
+
timings: {
|
|
1311
|
+
decodeMs,
|
|
1312
|
+
alignMs,
|
|
1313
|
+
// the align bucket broken out - it dominates, and its parts
|
|
1314
|
+
// respond to completely different settings
|
|
1315
|
+
nativeAlignMs: cfg.nativeFastAlign ? nativeAlignMs : 0,
|
|
1316
|
+
tableMs,
|
|
1317
|
+
searchMs,
|
|
1318
|
+
warpMs,
|
|
1319
|
+
localAlignMs,
|
|
1320
|
+
thresholdMs,
|
|
1321
|
+
diffMs,
|
|
1322
|
+
heatmapMs,
|
|
1323
|
+
stagesMs,
|
|
1324
|
+
totalMs: decodeMs + alignMs + diffMs + heatmapMs + stagesMs,
|
|
1325
|
+
},
|
|
1326
|
+
};
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
module.exports = {
|
|
1330
|
+
prepareGolden,
|
|
1331
|
+
compareFrame,
|
|
1332
|
+
// exported for the standalone test script / unit testing
|
|
1333
|
+
decodeGray,
|
|
1334
|
+
goldenWorkingSize,
|
|
1335
|
+
computeTargetWorkingSize,
|
|
1336
|
+
evaluatePosition,
|
|
1337
|
+
thresholdFg: thresholdFgFixed,
|
|
1338
|
+
};
|