@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
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Native OpenCV alignment experiments.
|
|
3
|
+
*
|
|
4
|
+
* Two paths share the same bridge call:
|
|
5
|
+
*
|
|
6
|
+
* - seedTransform() preserves the conservative prototype: OpenCV only gives
|
|
7
|
+
* the JS pixel polish a starting point.
|
|
8
|
+
* - alignFrame() is the deliberately aggressive prototype: OpenCV solves the
|
|
9
|
+
* affine transform and returns the already-warped golden-sized grayscale
|
|
10
|
+
* frame, allowing compareFrame to skip both summed-area tables, every JS
|
|
11
|
+
* search/polish round, and the final JS global warp.
|
|
12
|
+
*
|
|
13
|
+
* Both paths are optional and return null on any native failure so the normal
|
|
14
|
+
* JS implementation remains the fallback.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
"use strict";
|
|
18
|
+
|
|
19
|
+
const SEED_SCALE_MIN = 0.05;
|
|
20
|
+
const SEED_SCALE_MAX = 100;
|
|
21
|
+
const FAST_ALIGN_SCALE = 0.35;
|
|
22
|
+
|
|
23
|
+
const ENGINE_PATH =
|
|
24
|
+
"@rosepetal/node-red-contrib-image-tools/node-red-contrib-image-tools/lib/cpp-bridge.js";
|
|
25
|
+
|
|
26
|
+
let engine;
|
|
27
|
+
function loadEngine() {
|
|
28
|
+
if (engine !== undefined) return engine;
|
|
29
|
+
try {
|
|
30
|
+
// eslint-disable-next-line global-require
|
|
31
|
+
engine = require(ENGINE_PATH);
|
|
32
|
+
if (typeof engine.imageAlign !== "function") engine = null;
|
|
33
|
+
} catch {
|
|
34
|
+
engine = null;
|
|
35
|
+
}
|
|
36
|
+
return engine;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function available() {
|
|
40
|
+
return loadEngine() !== null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function _setEngine(fake) {
|
|
44
|
+
engine = fake;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function _resetEngine() {
|
|
48
|
+
engine = undefined;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function image(data, width, height, channels = 1) {
|
|
52
|
+
return {
|
|
53
|
+
data: Buffer.from(data.buffer, data.byteOffset, data.byteLength),
|
|
54
|
+
width,
|
|
55
|
+
height,
|
|
56
|
+
channels,
|
|
57
|
+
colorSpace: channels === 1 ? "GRAY" : channels === 4 ? "RGBA" : "RGB",
|
|
58
|
+
dtype: "uint8",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function rawResult(reply) {
|
|
63
|
+
const out = reply && reply.image;
|
|
64
|
+
if (!out || !out.data || !ArrayBuffer.isView(out.data)) return null;
|
|
65
|
+
const bytes = out.width * out.height * out.channels;
|
|
66
|
+
if (out.data.byteLength < bytes) return null;
|
|
67
|
+
return {
|
|
68
|
+
data: new Uint8Array(out.data.buffer, out.data.byteOffset, bytes),
|
|
69
|
+
width: out.width,
|
|
70
|
+
height: out.height,
|
|
71
|
+
channels: out.channels,
|
|
72
|
+
colorSpace: out.colorSpace,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Decode through OpenCV so the fast path does not pay sharp's serial PNG inflate. */
|
|
77
|
+
async function decodeGray(input, raw) {
|
|
78
|
+
const cv = loadEngine();
|
|
79
|
+
if (cv === null || typeof cv.colorConvert !== "function") return null;
|
|
80
|
+
const source = raw
|
|
81
|
+
? image(input, raw.width, raw.height, raw.channels)
|
|
82
|
+
: Buffer.from(input.buffer, input.byteOffset, input.byteLength);
|
|
83
|
+
try {
|
|
84
|
+
const out = rawResult(await cv.colorConvert(source, "GRAY", "raw"));
|
|
85
|
+
return out && out.channels === 1 ? out : null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function resizeGray(raw, width, height) {
|
|
92
|
+
if (raw.width === width && raw.height === height) return raw.data;
|
|
93
|
+
const cv = loadEngine();
|
|
94
|
+
if (cv === null || typeof cv.resize !== "function") return null;
|
|
95
|
+
try {
|
|
96
|
+
const out = rawResult(
|
|
97
|
+
await cv.resize(
|
|
98
|
+
image(raw.data, raw.width, raw.height),
|
|
99
|
+
"num",
|
|
100
|
+
width,
|
|
101
|
+
"num",
|
|
102
|
+
height,
|
|
103
|
+
"raw",
|
|
104
|
+
),
|
|
105
|
+
);
|
|
106
|
+
return out && out.channels === 1 && out.width === width && out.height === height
|
|
107
|
+
? out.data
|
|
108
|
+
: null;
|
|
109
|
+
} catch {
|
|
110
|
+
return null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function decodeTransform(reply, gW, gH, tW, tH) {
|
|
115
|
+
if (!reply || !reply.success || !reply.transformMatrix) return null;
|
|
116
|
+
const m = reply.transformMatrix.matrix2x3;
|
|
117
|
+
if (!Array.isArray(m) || m.length !== 6) return null;
|
|
118
|
+
|
|
119
|
+
// imageAlign first normalises the target to the reference dimensions.
|
|
120
|
+
// Its matrix maps reference coordinates into that normalised target, so
|
|
121
|
+
// scale each matrix row back into the target working canvas before
|
|
122
|
+
// decomposing it.
|
|
123
|
+
const fx = tW / gW;
|
|
124
|
+
const fy = tH / gH;
|
|
125
|
+
const a = m[0] * fx;
|
|
126
|
+
const b = m[1] * fx;
|
|
127
|
+
const ox = m[2] * fx;
|
|
128
|
+
const c = m[3] * fy;
|
|
129
|
+
const d = m[4] * fy;
|
|
130
|
+
const oy = m[5] * fy;
|
|
131
|
+
const mx = Math.hypot(a, c);
|
|
132
|
+
const my = Math.hypot(b, d);
|
|
133
|
+
const theta = Math.atan2(c, a);
|
|
134
|
+
|
|
135
|
+
if (![mx, my, theta, ox, oy].every(Number.isFinite)) return null;
|
|
136
|
+
if (mx < SEED_SCALE_MIN || mx > SEED_SCALE_MAX) return null;
|
|
137
|
+
if (my < SEED_SCALE_MIN || my > SEED_SCALE_MAX) return null;
|
|
138
|
+
return { mx, my, theta, ox, oy };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Keep the fast solve inside the geometry the inspection configuration says
|
|
143
|
+
* is physically possible. OpenCV affine is intentionally freer than the JS
|
|
144
|
+
* model; without this gate it can explain artwork differences as scale/shear.
|
|
145
|
+
*/
|
|
146
|
+
function validateAlignment(transform, pinnedScale, maxAngleDeg, scaleTolerance = 0.03) {
|
|
147
|
+
if (!transform) return "OpenCV returned no transform";
|
|
148
|
+
const angleDeg = Math.abs((transform.theta * 180) / Math.PI);
|
|
149
|
+
if (Number.isFinite(maxAngleDeg) && angleDeg > maxAngleDeg + 1e-9) {
|
|
150
|
+
return `OpenCV angle ${angleDeg.toFixed(2)}deg exceeds ${maxAngleDeg.toFixed(2)}deg`;
|
|
151
|
+
}
|
|
152
|
+
if (pinnedScale) {
|
|
153
|
+
const dx = Math.abs(transform.mx / pinnedScale.mx - 1);
|
|
154
|
+
const dy = Math.abs(transform.my / pinnedScale.my - 1);
|
|
155
|
+
if (dx > scaleTolerance || dy > scaleTolerance) {
|
|
156
|
+
return (
|
|
157
|
+
`OpenCV scale drift ${(dx * 100).toFixed(2)}% x / ` +
|
|
158
|
+
`${(dy * 100).toFixed(2)}% y exceeds ${(scaleTolerance * 100).toFixed(1)}%`
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
return null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Run OpenCV affine alignment and return both its transform and its full-size
|
|
167
|
+
* aligned grayscale image. `scale` controls only the internal alignment copy;
|
|
168
|
+
* the returned image is always gW x gH.
|
|
169
|
+
*/
|
|
170
|
+
async function alignFrame(
|
|
171
|
+
goldenGray,
|
|
172
|
+
gW,
|
|
173
|
+
gH,
|
|
174
|
+
targetGray,
|
|
175
|
+
tW,
|
|
176
|
+
tH,
|
|
177
|
+
options = {},
|
|
178
|
+
) {
|
|
179
|
+
const cv = loadEngine();
|
|
180
|
+
if (cv === null) return null;
|
|
181
|
+
const scale = Number.isFinite(options.scale)
|
|
182
|
+
? Math.max(0.05, Math.min(1, options.scale))
|
|
183
|
+
: FAST_ALIGN_SCALE;
|
|
184
|
+
const iterations = Number.isFinite(options.iterations)
|
|
185
|
+
? Math.max(1, Math.round(options.iterations))
|
|
186
|
+
: 30;
|
|
187
|
+
const epsilon = Number.isFinite(options.epsilon)
|
|
188
|
+
? Math.max(1e-6, options.epsilon)
|
|
189
|
+
: 1e-3;
|
|
190
|
+
const eccRefine = options.eccRefine === "always" ? "always" : "auto";
|
|
191
|
+
|
|
192
|
+
let reply;
|
|
193
|
+
try {
|
|
194
|
+
reply = await cv.imageAlign(
|
|
195
|
+
image(goldenGray, gW, gH),
|
|
196
|
+
image(targetGray, tW, tH),
|
|
197
|
+
scale,
|
|
198
|
+
iterations,
|
|
199
|
+
epsilon,
|
|
200
|
+
"raw",
|
|
201
|
+
90,
|
|
202
|
+
false,
|
|
203
|
+
true,
|
|
204
|
+
null,
|
|
205
|
+
"affine",
|
|
206
|
+
"features+ecc",
|
|
207
|
+
eccRefine,
|
|
208
|
+
"orb",
|
|
209
|
+
);
|
|
210
|
+
} catch {
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const transform = decodeTransform(reply, gW, gH, tW, tH);
|
|
215
|
+
const out = reply && reply.image;
|
|
216
|
+
if (!transform || !out || !out.data) return null;
|
|
217
|
+
if (out.width !== gW || out.height !== gH || out.channels !== 1) return null;
|
|
218
|
+
const data = out.data;
|
|
219
|
+
if (!ArrayBuffer.isView(data) || data.byteLength < gW * gH) return null;
|
|
220
|
+
const gray = new Uint8Array(data.buffer, data.byteOffset, gW * gH);
|
|
221
|
+
return {
|
|
222
|
+
transform,
|
|
223
|
+
gray: blankOutsideSource(gray, gW, gH, transform, tW, tH),
|
|
224
|
+
timing: reply.timing || null,
|
|
225
|
+
scale,
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* OpenCV's warp fills everything that maps outside the frame with 0, and 0 is
|
|
231
|
+
* the darkest possible ink. Where the golden's canvas reaches past the edge of
|
|
232
|
+
* the photo - which it does on every frame whose label is not fully inside the
|
|
233
|
+
* shot - that fill lands in the background check as a solid, full-density bar
|
|
234
|
+
* of ink the part does not have, and fails it.
|
|
235
|
+
*
|
|
236
|
+
* lib/warp.js fills the same region with 255 for exactly this reason: blank
|
|
237
|
+
* substrate reads as "no ink here", which the *print* check flags as missing
|
|
238
|
+
* ink (true - the frame does not show it) instead of the background check
|
|
239
|
+
* flagging it as extra ink (false - the frame shows nothing at all there).
|
|
240
|
+
* The native path has no border-value parameter to pass, so restore the
|
|
241
|
+
* convention here rather than let the two alignment paths disagree about what
|
|
242
|
+
* an uncovered pixel means.
|
|
243
|
+
*/
|
|
244
|
+
function blankOutsideSource(gray, gW, gH, transform, tW, tH) {
|
|
245
|
+
const { mx, my, theta, ox, oy } = transform;
|
|
246
|
+
const cos = Math.cos(theta);
|
|
247
|
+
const sin = Math.sin(theta);
|
|
248
|
+
// target = (ox,oy) + R(theta) * diag(mx,my) * golden, the same mapping
|
|
249
|
+
// warpGray samples under.
|
|
250
|
+
const xFromGx = cos * mx;
|
|
251
|
+
const yFromGx = sin * mx;
|
|
252
|
+
const xFromGy = -sin * my;
|
|
253
|
+
const yFromGy = cos * my;
|
|
254
|
+
|
|
255
|
+
const out = new Uint8Array(gW * gH);
|
|
256
|
+
for (let y = 0; y < gH; y++) {
|
|
257
|
+
const baseX = ox + xFromGy * y;
|
|
258
|
+
const baseY = oy + yFromGy * y;
|
|
259
|
+
const row = y * gW;
|
|
260
|
+
for (let x = 0; x < gW; x++) {
|
|
261
|
+
const tx = baseX + xFromGx * x;
|
|
262
|
+
const ty = baseY + yFromGx * x;
|
|
263
|
+
out[row + x] =
|
|
264
|
+
tx >= 0 && tx < tW && ty >= 0 && ty < tH ? gray[row + x] : 255;
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
return out;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** Conservative seed path retained for side-by-side benchmarking. */
|
|
271
|
+
async function seedTransform(goldenGray, gW, gH, targetGray, tW, tH) {
|
|
272
|
+
const aligned = await alignFrame(goldenGray, gW, gH, targetGray, tW, tH, {
|
|
273
|
+
scale: 1,
|
|
274
|
+
iterations: 50,
|
|
275
|
+
epsilon: 1e-4,
|
|
276
|
+
});
|
|
277
|
+
return aligned ? aligned.transform : null;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
module.exports = {
|
|
281
|
+
available,
|
|
282
|
+
decodeGray,
|
|
283
|
+
resizeGray,
|
|
284
|
+
alignFrame,
|
|
285
|
+
seedTransform,
|
|
286
|
+
validateAlignment,
|
|
287
|
+
SEED_SCALE_MIN,
|
|
288
|
+
SEED_SCALE_MAX,
|
|
289
|
+
FAST_ALIGN_SCALE,
|
|
290
|
+
_setEngine,
|
|
291
|
+
_resetEngine,
|
|
292
|
+
};
|
package/lib/parallel.js
ADDED
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Parallel forms of the per-pixel stages, each falling back to its serial
|
|
3
|
+
* twin whenever the pool is unavailable, the image is small enough that
|
|
4
|
+
* dispatch would cost more than it saves, or the caller asked for one
|
|
5
|
+
* worker.
|
|
6
|
+
*
|
|
7
|
+
* The fallbacks are not a nicety. The serial implementations remain the
|
|
8
|
+
* reference — the tests assert these agree with them byte for byte —
|
|
9
|
+
* because a divergence would surface as a defect that appears or
|
|
10
|
+
* disappears depending on the core count of the machine.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const { dilate } = require("./dilate.js");
|
|
16
|
+
const { buildIntegral, buildIntegral64 } = require("./integral.js");
|
|
17
|
+
const { warpGray } = require("./warp.js");
|
|
18
|
+
const { smoothField, fieldStats } = require("./localAlign.js");
|
|
19
|
+
const { runRanges, shouldParallelise, poolSize } = require("./pool.js");
|
|
20
|
+
const {
|
|
21
|
+
allocU8,
|
|
22
|
+
allocU32,
|
|
23
|
+
allocF32,
|
|
24
|
+
allocF64,
|
|
25
|
+
toShared,
|
|
26
|
+
HAS_SAB,
|
|
27
|
+
} = require("./shared.js");
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Summed-area table over a binary mask: row prefix sums in parallel, then
|
|
31
|
+
* the column accumulation in parallel.
|
|
32
|
+
*
|
|
33
|
+
* The serial `buildIntegral` fuses both into one pass, which is the right
|
|
34
|
+
* shape for one thread and the wrong shape for several: the fused loop
|
|
35
|
+
* reads the row above as it writes, so no two rows can run at once.
|
|
36
|
+
* Split into two passes each dimension is independent, and the arithmetic
|
|
37
|
+
* is unchanged - Uint32 addition is exact and associative modulo 2^32, so
|
|
38
|
+
* the table is byte-identical to the serial one either way.
|
|
39
|
+
*
|
|
40
|
+
* Worth doing because this is per-frame work on the full frame canvas:
|
|
41
|
+
* ~99ms of a 918ms align on a 4096x5500 frame, run on the main path while
|
|
42
|
+
* the pool sits idle.
|
|
43
|
+
*/
|
|
44
|
+
async function buildIntegralParallel(src, width, height, workers) {
|
|
45
|
+
if (!shouldParallelise(width * height, workers)) {
|
|
46
|
+
return buildIntegral(src, width, height);
|
|
47
|
+
}
|
|
48
|
+
const stride = width + 1;
|
|
49
|
+
const s = toShared(src);
|
|
50
|
+
const integral = allocU32(stride * (height + 1));
|
|
51
|
+
const rows = runRanges(
|
|
52
|
+
"integralRows",
|
|
53
|
+
{ src: s.buffer, out: integral.buffer, width, stride },
|
|
54
|
+
height,
|
|
55
|
+
workers,
|
|
56
|
+
);
|
|
57
|
+
if (rows === null) return buildIntegral(src, width, height);
|
|
58
|
+
await rows;
|
|
59
|
+
// the column pass reads what every row-pass worker wrote, so this
|
|
60
|
+
// await is a real barrier, exactly as in dilateParallel
|
|
61
|
+
await runRanges(
|
|
62
|
+
"integralCols",
|
|
63
|
+
{ out: integral.buffer, height, stride },
|
|
64
|
+
width,
|
|
65
|
+
workers,
|
|
66
|
+
);
|
|
67
|
+
return { integral, stride, width, height };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The grey (Float64) summed-area table, split the same way. This is the
|
|
72
|
+
* second per-frame table: `buildIntegralParallel` covers the binary mask
|
|
73
|
+
* the transform search reads, this one covers the greys that every
|
|
74
|
+
* candidate warp in the polish and the final full-resolution warp read.
|
|
75
|
+
* ~110ms per frame on a 4096x5500 canvas, also built while the pool idles.
|
|
76
|
+
*/
|
|
77
|
+
async function buildGrayTableParallel(src, width, height, workers) {
|
|
78
|
+
if (!shouldParallelise(width * height, workers)) {
|
|
79
|
+
return buildIntegral64(src, width, height);
|
|
80
|
+
}
|
|
81
|
+
const stride = width + 1;
|
|
82
|
+
const s = toShared(src);
|
|
83
|
+
const integral = allocF64(stride * (height + 1));
|
|
84
|
+
const rows = runRanges(
|
|
85
|
+
"integralRows64",
|
|
86
|
+
{ src: s.buffer, out: integral.buffer, width, stride },
|
|
87
|
+
height,
|
|
88
|
+
workers,
|
|
89
|
+
);
|
|
90
|
+
if (rows === null) return buildIntegral64(src, width, height);
|
|
91
|
+
await rows;
|
|
92
|
+
await runRanges(
|
|
93
|
+
"integralCols64",
|
|
94
|
+
{ out: integral.buffer, height, stride },
|
|
95
|
+
width,
|
|
96
|
+
workers,
|
|
97
|
+
);
|
|
98
|
+
return { integral, stride, width, height };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Separable dilation: rows in parallel, then columns in parallel. */
|
|
102
|
+
async function dilateParallel(src, width, height, radius, workers) {
|
|
103
|
+
if (radius <= 0 || !shouldParallelise(width * height, workers)) {
|
|
104
|
+
return dilate(src, width, height, radius);
|
|
105
|
+
}
|
|
106
|
+
const s = toShared(src);
|
|
107
|
+
const tmp = allocU8(width * height);
|
|
108
|
+
const out = allocU8(width * height);
|
|
109
|
+
const rows = runRanges(
|
|
110
|
+
"dilateRows",
|
|
111
|
+
{ src: s.buffer, tmp: tmp.buffer, width, height, radius },
|
|
112
|
+
height,
|
|
113
|
+
workers,
|
|
114
|
+
);
|
|
115
|
+
if (rows === null) return dilate(src, width, height, radius);
|
|
116
|
+
await rows;
|
|
117
|
+
// the column pass reads what every row-pass worker wrote, so the two
|
|
118
|
+
// dispatches cannot be merged - this await is a real barrier
|
|
119
|
+
await runRanges(
|
|
120
|
+
"dilateCols",
|
|
121
|
+
{ tmp: tmp.buffer, out: out.buffer, width, height, radius },
|
|
122
|
+
width,
|
|
123
|
+
workers,
|
|
124
|
+
);
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* defect = a AND NOT b, cleared wherever either image was too close to
|
|
130
|
+
* its own ink level to have decided anything. Either ambiguity mask may
|
|
131
|
+
* be null.
|
|
132
|
+
*/
|
|
133
|
+
async function defectParallel(
|
|
134
|
+
a,
|
|
135
|
+
b,
|
|
136
|
+
ambiguousA,
|
|
137
|
+
ambiguousB,
|
|
138
|
+
width,
|
|
139
|
+
height,
|
|
140
|
+
workers,
|
|
141
|
+
) {
|
|
142
|
+
const n = width * height;
|
|
143
|
+
if (!shouldParallelise(n, workers)) return null;
|
|
144
|
+
|
|
145
|
+
const sa = toShared(a);
|
|
146
|
+
const sb = toShared(b);
|
|
147
|
+
const sAmbA = ambiguousA ? toShared(ambiguousA) : null;
|
|
148
|
+
const sAmbB = ambiguousB ? toShared(ambiguousB) : null;
|
|
149
|
+
const out = allocU8(n);
|
|
150
|
+
// one counter slot per worker rather than an atomic in the inner loop.
|
|
151
|
+
// Sized from the pool, not a fixed 64: a worker index past the end of
|
|
152
|
+
// a typed array is a silently ignored write (no RangeError), so with a
|
|
153
|
+
// pool larger than the slot count the defect count came up short with
|
|
154
|
+
// no error anywhere.
|
|
155
|
+
const slots = allocU32(Math.max(1, poolSize(workers)));
|
|
156
|
+
const p = runRanges(
|
|
157
|
+
"defect",
|
|
158
|
+
{
|
|
159
|
+
a: sa.buffer,
|
|
160
|
+
b: sb.buffer,
|
|
161
|
+
ambiguousA: sAmbA ? sAmbA.buffer : null,
|
|
162
|
+
ambiguousB: sAmbB ? sAmbB.buffer : null,
|
|
163
|
+
out: out.buffer,
|
|
164
|
+
counts: slots.buffer,
|
|
165
|
+
width,
|
|
166
|
+
},
|
|
167
|
+
height,
|
|
168
|
+
workers,
|
|
169
|
+
);
|
|
170
|
+
if (p === null) return null;
|
|
171
|
+
await p;
|
|
172
|
+
let count = 0;
|
|
173
|
+
for (let i = 0; i < slots.length; i++) count += slots[i];
|
|
174
|
+
return { defect: out, count };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** Grey -> ink mask against one global level, plus the ambiguity band. */
|
|
178
|
+
async function binarizeParallel(gray, width, height, level, margin, workers) {
|
|
179
|
+
const n = width * height;
|
|
180
|
+
if (!shouldParallelise(n, workers)) return null;
|
|
181
|
+
const g = toShared(gray);
|
|
182
|
+
const fg = allocU8(n);
|
|
183
|
+
const ambiguous = margin > 0 ? allocU8(n) : null;
|
|
184
|
+
const p = runRanges(
|
|
185
|
+
"binarize",
|
|
186
|
+
{
|
|
187
|
+
gray: g.buffer,
|
|
188
|
+
fg: fg.buffer,
|
|
189
|
+
ambiguous: ambiguous ? ambiguous.buffer : null,
|
|
190
|
+
level,
|
|
191
|
+
margin,
|
|
192
|
+
width,
|
|
193
|
+
},
|
|
194
|
+
height,
|
|
195
|
+
workers,
|
|
196
|
+
);
|
|
197
|
+
if (p === null) return null;
|
|
198
|
+
await p;
|
|
199
|
+
return { fg, ambiguous };
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
module.exports = {
|
|
203
|
+
buildIntegralParallel,
|
|
204
|
+
buildGrayTableParallel,
|
|
205
|
+
dilateParallel,
|
|
206
|
+
defectParallel,
|
|
207
|
+
binarizeParallel,
|
|
208
|
+
warpParallel,
|
|
209
|
+
refineLocallyParallel,
|
|
210
|
+
objectiveBatchParallel,
|
|
211
|
+
};
|
|
212
|
+
|
|
213
|
+
// The polish objective splits by *candidate*, not by rows, so the row-split
|
|
214
|
+
// threshold is the wrong economics: one candidate is a whole warp of the
|
|
215
|
+
// objective canvas, on the order of a millisecond, where one row is
|
|
216
|
+
// microseconds. Using MIN_PIXELS_TO_SPLIT here sent the entire polish
|
|
217
|
+
// serial for any golden past roughly 2.5:1 - a legitimate label shape - at
|
|
218
|
+
// the pattern search's full serial cost. 100k total pixels is ~10k per
|
|
219
|
+
// candidate on a 10-wide pinned batch, around half a millisecond each,
|
|
220
|
+
// which comfortably clears a dispatch.
|
|
221
|
+
const MIN_OBJECTIVE_PIXELS = 100000;
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Score a whole neighbourhood of candidate transforms at once, one
|
|
225
|
+
* contiguous run of candidates per worker.
|
|
226
|
+
*
|
|
227
|
+
* Returns null - meaning "score these yourself" - rather than throwing,
|
|
228
|
+
* because the serial scorer in lib/compare.js stays the reference
|
|
229
|
+
* implementation and every caller already has it to hand.
|
|
230
|
+
*
|
|
231
|
+
* `frame` is { gray, width, height, table, outW, outH, level, fgObj }, all
|
|
232
|
+
* already expressed on the objective's own decimated grid: this function
|
|
233
|
+
* does no coordinate conversion, so what it scores is exactly what the
|
|
234
|
+
* serial scorer scores.
|
|
235
|
+
*/
|
|
236
|
+
async function objectiveBatchParallel(cands, frame, workers) {
|
|
237
|
+
const n = cands.length;
|
|
238
|
+
// A single candidate is the polish's opening evaluation. Splitting one
|
|
239
|
+
// warp across eight workers costs a dispatch to save nothing.
|
|
240
|
+
if (n < 2) return null;
|
|
241
|
+
if (!frame || !frame.table) return null;
|
|
242
|
+
if (!HAS_SAB || workers === 1) return null;
|
|
243
|
+
if (n * frame.outW * frame.outH < MIN_OBJECTIVE_PIXELS) return null;
|
|
244
|
+
|
|
245
|
+
const gray = toShared(frame.gray);
|
|
246
|
+
const fgObj = toShared(frame.fgObj);
|
|
247
|
+
const integral = toShared(frame.table.integral);
|
|
248
|
+
|
|
249
|
+
// five parameters per candidate, flat, so the dispatch carries numbers
|
|
250
|
+
// rather than a structured clone of an array of objects
|
|
251
|
+
const params = allocF64(n * 5);
|
|
252
|
+
for (let i = 0; i < n; i++) {
|
|
253
|
+
const c = cands[i];
|
|
254
|
+
params[i * 5] = c.mx;
|
|
255
|
+
params[i * 5 + 1] = c.my;
|
|
256
|
+
params[i * 5 + 2] = c.theta;
|
|
257
|
+
params[i * 5 + 3] = c.ox;
|
|
258
|
+
params[i * 5 + 4] = c.oy;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// NaN, not zero. A score of 0 is "a perfect match" - it beats every
|
|
262
|
+
// other candidate and passes the part - so a dispatch that silently
|
|
263
|
+
// failed to write would steer the alignment rather than fail. Any
|
|
264
|
+
// surviving NaN below is that bug, made loud.
|
|
265
|
+
const scores = allocF64(n);
|
|
266
|
+
scores.fill(NaN);
|
|
267
|
+
|
|
268
|
+
const p = runRanges(
|
|
269
|
+
"objective",
|
|
270
|
+
{
|
|
271
|
+
gray: gray.buffer,
|
|
272
|
+
fgObj: fgObj.buffer,
|
|
273
|
+
sat: integral.buffer,
|
|
274
|
+
satStride: frame.table.stride,
|
|
275
|
+
srcW: frame.width,
|
|
276
|
+
srcH: frame.height,
|
|
277
|
+
outW: frame.outW,
|
|
278
|
+
outH: frame.outH,
|
|
279
|
+
level: frame.level,
|
|
280
|
+
params: params.buffer,
|
|
281
|
+
scores: scores.buffer,
|
|
282
|
+
},
|
|
283
|
+
n,
|
|
284
|
+
workers,
|
|
285
|
+
);
|
|
286
|
+
// getPool returns null on a host that resolves to one worker even though
|
|
287
|
+
// the pixel gate passed - a 2-core machine, where defaultSize() is 1.
|
|
288
|
+
// Without this the zero-filled scores would come back as a perfect match
|
|
289
|
+
// for every candidate.
|
|
290
|
+
if (p === null) return null;
|
|
291
|
+
await p;
|
|
292
|
+
|
|
293
|
+
const out = new Array(n);
|
|
294
|
+
for (let i = 0; i < n; i++) {
|
|
295
|
+
if (Number.isNaN(scores[i])) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`objective batch left candidate ${i} of ${n} unscored - a dispatch did not write its range`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
out[i] = scores[i];
|
|
301
|
+
}
|
|
302
|
+
return out;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/** Resample the frame into golden's grid, rows split across the pool. */
|
|
306
|
+
async function warpParallel(
|
|
307
|
+
src,
|
|
308
|
+
srcW,
|
|
309
|
+
srcH,
|
|
310
|
+
mx,
|
|
311
|
+
my,
|
|
312
|
+
theta,
|
|
313
|
+
ox,
|
|
314
|
+
oy,
|
|
315
|
+
outW,
|
|
316
|
+
outH,
|
|
317
|
+
fill,
|
|
318
|
+
table,
|
|
319
|
+
workers,
|
|
320
|
+
) {
|
|
321
|
+
const serial = () =>
|
|
322
|
+
warpGray(src, srcW, srcH, mx, my, theta, ox, oy, outW, outH, fill, table);
|
|
323
|
+
if (!shouldParallelise(outW * outH, workers)) return serial();
|
|
324
|
+
// only the area-average path has a table to share; the bilinear path
|
|
325
|
+
// reads the source directly and is rare here anyway
|
|
326
|
+
if (!(mx > 1.001 || my > 1.001) || !table) return serial();
|
|
327
|
+
|
|
328
|
+
const s = toShared(src);
|
|
329
|
+
const integral = toShared(table.integral);
|
|
330
|
+
const out = allocU8(outW * outH);
|
|
331
|
+
out.fill(fill == null ? 255 : fill);
|
|
332
|
+
const p = runRanges(
|
|
333
|
+
"warp",
|
|
334
|
+
{
|
|
335
|
+
src: s.buffer,
|
|
336
|
+
out: out.buffer,
|
|
337
|
+
srcW,
|
|
338
|
+
srcH,
|
|
339
|
+
mx,
|
|
340
|
+
my,
|
|
341
|
+
theta,
|
|
342
|
+
ox,
|
|
343
|
+
oy,
|
|
344
|
+
outW,
|
|
345
|
+
sat: integral.buffer,
|
|
346
|
+
satStride: table.stride,
|
|
347
|
+
},
|
|
348
|
+
outH,
|
|
349
|
+
workers,
|
|
350
|
+
);
|
|
351
|
+
if (p === null) return serial();
|
|
352
|
+
await p;
|
|
353
|
+
return out;
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Per-tile refinement with both halves split: the field build over tile
|
|
358
|
+
* rows, then - after a barrier, since the median filter reads the whole
|
|
359
|
+
* field - the resampling over image rows.
|
|
360
|
+
*/
|
|
361
|
+
async function refineLocallyParallel(
|
|
362
|
+
goldenGray,
|
|
363
|
+
alignedTargetGray,
|
|
364
|
+
width,
|
|
365
|
+
height,
|
|
366
|
+
cfg,
|
|
367
|
+
) {
|
|
368
|
+
const tile = Math.max(8, cfg.localAlignTile | 0);
|
|
369
|
+
const maxOffset = Math.max(1, cfg.localAlignMax | 0);
|
|
370
|
+
const minStdDev =
|
|
371
|
+
cfg.localAlignMinStdDev != null ? cfg.localAlignMinStdDev : 12;
|
|
372
|
+
const opts = { tile, maxOffset, minStdDev };
|
|
373
|
+
if (!shouldParallelise(width * height, cfg.workers)) return null;
|
|
374
|
+
|
|
375
|
+
const g = toShared(goldenGray);
|
|
376
|
+
const t = toShared(alignedTargetGray);
|
|
377
|
+
const gridW = Math.max(1, Math.ceil(width / tile));
|
|
378
|
+
const gridH = Math.max(1, Math.ceil(height / tile));
|
|
379
|
+
const raw = {
|
|
380
|
+
fx: allocF32(gridW * gridH),
|
|
381
|
+
fy: allocF32(gridW * gridH),
|
|
382
|
+
valid: allocU8(gridW * gridH),
|
|
383
|
+
gridW,
|
|
384
|
+
gridH,
|
|
385
|
+
};
|
|
386
|
+
const build = runRanges(
|
|
387
|
+
"localField",
|
|
388
|
+
{
|
|
389
|
+
golden: g.buffer,
|
|
390
|
+
target: t.buffer,
|
|
391
|
+
fx: raw.fx.buffer,
|
|
392
|
+
fy: raw.fy.buffer,
|
|
393
|
+
valid: raw.valid.buffer,
|
|
394
|
+
width,
|
|
395
|
+
height,
|
|
396
|
+
gridW,
|
|
397
|
+
tile,
|
|
398
|
+
maxOffset,
|
|
399
|
+
minStdDev,
|
|
400
|
+
},
|
|
401
|
+
gridH,
|
|
402
|
+
cfg.workers,
|
|
403
|
+
);
|
|
404
|
+
if (build === null) return null;
|
|
405
|
+
await build;
|
|
406
|
+
|
|
407
|
+
// small enough that splitting it would cost more than it saves
|
|
408
|
+
const field = smoothField(raw);
|
|
409
|
+
|
|
410
|
+
const out = allocU8(width * height);
|
|
411
|
+
await runRanges(
|
|
412
|
+
"localApply",
|
|
413
|
+
{
|
|
414
|
+
target: t.buffer,
|
|
415
|
+
out: out.buffer,
|
|
416
|
+
fx: field.fx.buffer,
|
|
417
|
+
fy: field.fy.buffer,
|
|
418
|
+
width,
|
|
419
|
+
height,
|
|
420
|
+
gridW,
|
|
421
|
+
gridH,
|
|
422
|
+
tile,
|
|
423
|
+
},
|
|
424
|
+
height,
|
|
425
|
+
cfg.workers,
|
|
426
|
+
);
|
|
427
|
+
return { gray: out, field, tile, stats: fieldStats(field) };
|
|
428
|
+
}
|