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