@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/pool.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A persistent worker-thread pool for the per-pixel stages.
|
|
3
|
+
*
|
|
4
|
+
* Everything after decode used to run on one core while the rest of the
|
|
5
|
+
* machine sat idle: on a 16-core host, ~630ms of an 850ms frame was
|
|
6
|
+
* single-threaded JavaScript. (`sharp` already decodes and resizes on all
|
|
7
|
+
* cores, which is why decode is not in here.)
|
|
8
|
+
*
|
|
9
|
+
* The pool is created once and reused. That is not an optimisation
|
|
10
|
+
* detail, it is the whole feasibility argument: spawning eight workers
|
|
11
|
+
* costs ~60ms, which would wipe out the ~40ms a frame's parallel stages
|
|
12
|
+
* save. Measured on the tile matcher, a warm pool takes 48ms of work down
|
|
13
|
+
* to 8ms with eight workers.
|
|
14
|
+
*
|
|
15
|
+
* Buffers are passed as SharedArrayBuffers, so a dispatch ships a handful
|
|
16
|
+
* of numbers and a memory handle rather than tens of megabytes.
|
|
17
|
+
*
|
|
18
|
+
* Callers must be able to do without it: `shouldParallelise` is false for
|
|
19
|
+
* small images, for a pool of one, and when SharedArrayBuffer is missing,
|
|
20
|
+
* and every kernel here has a serial twin that stays the reference
|
|
21
|
+
* implementation.
|
|
22
|
+
*
|
|
23
|
+
* Two invariants hold the concurrent case together, because Node-RED never
|
|
24
|
+
* awaits a node's input handler and two frames overlap freely inside
|
|
25
|
+
* compareFrame:
|
|
26
|
+
*
|
|
27
|
+
* - **A dispatch is settled by id, never by "the next reply".** See
|
|
28
|
+
* spawn(). Getting this wrong did not fail loudly; it returned a frame
|
|
29
|
+
* aligned against a half-written buffer.
|
|
30
|
+
* - **The pool is never torn down because a different size was asked
|
|
31
|
+
* for.** See getPool(). It grows and stays grown.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
const os = require("node:os");
|
|
35
|
+
const path = require("node:path");
|
|
36
|
+
const { Worker } = require("node:worker_threads");
|
|
37
|
+
const { HAS_SAB } = require("./shared.js");
|
|
38
|
+
|
|
39
|
+
const WORKER_PATH = path.join(__dirname, "poolWorker.js");
|
|
40
|
+
|
|
41
|
+
// Below this many pixels the dispatch costs more than the work saved.
|
|
42
|
+
const MIN_PIXELS_TO_SPLIT = 400000;
|
|
43
|
+
|
|
44
|
+
let pool = null;
|
|
45
|
+
let nextDispatchId = 1;
|
|
46
|
+
|
|
47
|
+
function defaultSize() {
|
|
48
|
+
const cpus = os.cpus().length;
|
|
49
|
+
// Leave a core for the event loop and for libvips finishing a decode.
|
|
50
|
+
//
|
|
51
|
+
// The cap used to be eight, on the grounds that the defect scan's curve
|
|
52
|
+
// is flat past it (5.76x at 8, 6.55x at 16). That is true of the scan
|
|
53
|
+
// and false of the alignment polish, which is the larger cost: the
|
|
54
|
+
// polish is ~15 *sequential* rounds of a small batch, so each round
|
|
55
|
+
// pays a fixed dispatch cost and finishes no sooner than its slowest
|
|
56
|
+
// worker. Measured on a 16-core host against a 4096x5500 frame, pinned,
|
|
57
|
+
// with bit-identical transforms and verdicts at every size:
|
|
58
|
+
//
|
|
59
|
+
// workers 8 -> polish 342ms, align 798ms
|
|
60
|
+
// workers 12 -> polish 220ms, align 680ms
|
|
61
|
+
// workers 16 -> polish 215ms, align 678ms
|
|
62
|
+
// workers 24 -> polish 216ms, align 680ms
|
|
63
|
+
//
|
|
64
|
+
// It plateaus around twelve, so the cap is sixteen rather than "one per
|
|
65
|
+
// core": past the plateau the extra threads only cost memory and
|
|
66
|
+
// contend with whatever else the instance is running. Boxes with nine
|
|
67
|
+
// cores or fewer are unaffected.
|
|
68
|
+
return Math.max(1, Math.min(16, cpus - 1));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The pool size a request resolves to. 0 (the default) means "one per
|
|
72
|
+
* core, capped at sixteen"; anything else is taken literally. */
|
|
73
|
+
function wanted(size) {
|
|
74
|
+
return size && size > 0 ? size : defaultSize();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Spawn one worker and give it the *only* listeners it will ever have.
|
|
79
|
+
*
|
|
80
|
+
* The listeners are permanent, and each dispatch is tracked by id in
|
|
81
|
+
* `pending`, because the obvious alternative is broken: registering
|
|
82
|
+
* `once("message")` per dispatch means two concurrent dispatches to the
|
|
83
|
+
* same worker each add a listener, and the first reply fires *both* -
|
|
84
|
+
* EventEmitter delivers to every listener registered at emit time, and
|
|
85
|
+
* `once` only removes after delivery. The second caller then resolved on
|
|
86
|
+
* someone else's reply and read a half-written output buffer. Through the
|
|
87
|
+
* real pipeline that surfaced as two concurrent frames both reporting
|
|
88
|
+
* transform.score = 0, which is not an error value: it is a perfect match,
|
|
89
|
+
* it beats every candidate, and the part passes.
|
|
90
|
+
*
|
|
91
|
+
* Permanent listeners cost one thing that has to be paid back explicitly:
|
|
92
|
+
* an attached listener refs the worker's port, and the old per-dispatch
|
|
93
|
+
* attach/detach was doing that ref-counting by accident. Hence the
|
|
94
|
+
* ref()/unref() around `pending` going empty and non-empty - without it an
|
|
95
|
+
* idle pool holds the process open and `npm test` never exits.
|
|
96
|
+
*/
|
|
97
|
+
function spawn() {
|
|
98
|
+
const worker = new Worker(WORKER_PATH);
|
|
99
|
+
const pending = new Map();
|
|
100
|
+
worker.pending = pending;
|
|
101
|
+
|
|
102
|
+
const rejectAll = (err) => {
|
|
103
|
+
if (pending.size === 0) return;
|
|
104
|
+
const waiting = Array.from(pending.values());
|
|
105
|
+
pending.clear();
|
|
106
|
+
worker.unref();
|
|
107
|
+
for (const entry of waiting) entry.reject(err);
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
worker.on("message", (msg) => {
|
|
111
|
+
// null-safe, and an unknown id is ignored rather than thrown on: a
|
|
112
|
+
// reply can arrive for a dispatch already rejected by an earlier
|
|
113
|
+
// error or exit, and throwing here would be an uncaught exception
|
|
114
|
+
// inside the EventEmitter - it would take the process down, not the
|
|
115
|
+
// frame.
|
|
116
|
+
const entry = msg == null ? undefined : pending.get(msg.id);
|
|
117
|
+
if (!entry) return;
|
|
118
|
+
pending.delete(msg.id);
|
|
119
|
+
if (pending.size === 0) worker.unref();
|
|
120
|
+
if (msg.error) entry.reject(new Error(msg.error));
|
|
121
|
+
else entry.resolve();
|
|
122
|
+
});
|
|
123
|
+
worker.on("error", (err) => {
|
|
124
|
+
// only this worker leaves the pool. Tearing the whole pool down here
|
|
125
|
+
// would fail every other in-flight frame for one worker's fault; the
|
|
126
|
+
// next getPool() grows a replacement.
|
|
127
|
+
remove(worker);
|
|
128
|
+
rejectAll(err);
|
|
129
|
+
worker.terminate();
|
|
130
|
+
});
|
|
131
|
+
worker.on("exit", (code) => {
|
|
132
|
+
// a terminated or crashed worker can never answer its dispatches;
|
|
133
|
+
// without this they hung forever and the frame silently never
|
|
134
|
+
// settled (shutdown()/terminate() emit only 'exit')
|
|
135
|
+
remove(worker);
|
|
136
|
+
rejectAll(
|
|
137
|
+
new Error(`worker exited (code ${code}) before finishing its range`),
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
// idle: the listeners above would otherwise hold the loop open
|
|
141
|
+
worker.unref();
|
|
142
|
+
return worker;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function remove(worker) {
|
|
146
|
+
if (!pool) return;
|
|
147
|
+
const i = pool.workers.indexOf(worker);
|
|
148
|
+
if (i >= 0) pool.workers.splice(i, 1);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* One pool, grown to the largest size anyone has asked for, never torn
|
|
153
|
+
* down because a different size was asked for next.
|
|
154
|
+
*
|
|
155
|
+
* The pool used to be rebuilt whenever the requested size changed, which
|
|
156
|
+
* two things reach: `msg.workers` is a per-message override, and two
|
|
157
|
+
* golden-compare nodes can be configured differently. That cost ~60ms of
|
|
158
|
+
* respawn per frame - the very cost this module's header argues is
|
|
159
|
+
* unaffordable - and, worse, the teardown terminated workers that another
|
|
160
|
+
* in-flight frame was still waiting on, failing that frame for no reason
|
|
161
|
+
* of its own.
|
|
162
|
+
*
|
|
163
|
+
* Growing instead keeps the thread count bounded by the same `workers`
|
|
164
|
+
* clamp as before (64), and a caller asking for fewer simply dispatches to
|
|
165
|
+
* a prefix of the pool.
|
|
166
|
+
*/
|
|
167
|
+
function getPool(size) {
|
|
168
|
+
const want = wanted(size);
|
|
169
|
+
if (!HAS_SAB || want <= 1) return null;
|
|
170
|
+
if (!pool) pool = { workers: [] };
|
|
171
|
+
while (pool.workers.length < want) pool.workers.push(spawn());
|
|
172
|
+
return pool;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function shouldParallelise(pixels, size) {
|
|
176
|
+
if (!HAS_SAB) return false;
|
|
177
|
+
if (size === 1) return false;
|
|
178
|
+
return pixels >= MIN_PIXELS_TO_SPLIT;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* How many workers getPool(size) will actually run - for callers that
|
|
183
|
+
* must size per-worker scratch before dispatching (defectParallel's
|
|
184
|
+
* counter slots) and cannot wait until the pool exists. Keep in step
|
|
185
|
+
* with getPool's `want` computation.
|
|
186
|
+
*/
|
|
187
|
+
function poolSize(size) {
|
|
188
|
+
const want = wanted(size);
|
|
189
|
+
if (!HAS_SAB || want <= 1) return 0;
|
|
190
|
+
return want;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** One dispatch to one worker, settled by id. */
|
|
194
|
+
function dispatch(worker, message) {
|
|
195
|
+
return new Promise((resolve, reject) => {
|
|
196
|
+
const id = nextDispatchId++;
|
|
197
|
+
if (worker.pending.size === 0) worker.ref();
|
|
198
|
+
worker.pending.set(id, { resolve, reject });
|
|
199
|
+
try {
|
|
200
|
+
worker.postMessage({ ...message, id });
|
|
201
|
+
} catch (err) {
|
|
202
|
+
// a worker terminated between getPool() and here would otherwise
|
|
203
|
+
// leave this promise unsettled forever
|
|
204
|
+
worker.pending.delete(id);
|
|
205
|
+
if (worker.pending.size === 0) worker.unref();
|
|
206
|
+
reject(err);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Split [0,total) into one contiguous range per worker and run `kernel`
|
|
213
|
+
* over each. `ctx` must contain only SharedArrayBuffers and structured-
|
|
214
|
+
* cloneable scalars.
|
|
215
|
+
*/
|
|
216
|
+
function runRanges(kernel, ctx, total, size) {
|
|
217
|
+
const p = getPool(size);
|
|
218
|
+
if (!p) return null;
|
|
219
|
+
// A prefix of the pool, not all of it: the pool may hold more workers
|
|
220
|
+
// than this caller asked for, and `index` has to stay within 0..want-1
|
|
221
|
+
// to match the per-worker scratch the caller already sized from
|
|
222
|
+
// poolSize() - defectParallel's counter slots, in particular.
|
|
223
|
+
const want = wanted(size);
|
|
224
|
+
const workers = p.workers.slice(0, want);
|
|
225
|
+
const n = workers.length;
|
|
226
|
+
return Promise.all(
|
|
227
|
+
workers.map((worker, i) => {
|
|
228
|
+
const lo = Math.floor((i * total) / n);
|
|
229
|
+
const hi = Math.floor(((i + 1) * total) / n);
|
|
230
|
+
if (hi <= lo) return Promise.resolve();
|
|
231
|
+
return dispatch(worker, { kernel, ctx, lo, hi, index: i });
|
|
232
|
+
}),
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function shutdown() {
|
|
237
|
+
if (!pool) return;
|
|
238
|
+
const workers = pool.workers;
|
|
239
|
+
pool = null;
|
|
240
|
+
for (const w of workers) w.terminate();
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
module.exports = {
|
|
244
|
+
getPool,
|
|
245
|
+
runRanges,
|
|
246
|
+
shouldParallelise,
|
|
247
|
+
shutdown,
|
|
248
|
+
defaultSize,
|
|
249
|
+
poolSize,
|
|
250
|
+
};
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker side of the pool. Each kernel does the same arithmetic as its
|
|
3
|
+
* serial twin, over a contiguous range of rows or columns.
|
|
4
|
+
*
|
|
5
|
+
* The serial implementations stay the reference: they are what the tests
|
|
6
|
+
* assert against, and `test/parallel.test.js` asserts these produce
|
|
7
|
+
* byte-identical output. Keep them in step - a divergence here shows up
|
|
8
|
+
* as a defect that appears or vanishes depending on how many cores the
|
|
9
|
+
* machine has, which is close to the worst bug this project could have.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const { parentPort } = require("node:worker_threads");
|
|
15
|
+
const { slidingMax1D } = require("./dilate.js");
|
|
16
|
+
const { warpRows } = require("./warp.js");
|
|
17
|
+
const { fieldRows, applyRows } = require("./localAlign.js");
|
|
18
|
+
|
|
19
|
+
let scratch = null;
|
|
20
|
+
function deque(n) {
|
|
21
|
+
if (scratch === null || scratch.length < n) scratch = new Int32Array(n);
|
|
22
|
+
return scratch;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const kernels = {
|
|
26
|
+
// horizontal pass of the separable dilation: rows are independent
|
|
27
|
+
dilateRows({ src, tmp, width, height, radius }, lo, hi) {
|
|
28
|
+
const s = new Uint8Array(src);
|
|
29
|
+
const t = new Uint8Array(tmp);
|
|
30
|
+
const dq = deque(width);
|
|
31
|
+
for (let y = lo; y < hi; y++) {
|
|
32
|
+
const off = y * width;
|
|
33
|
+
slidingMax1D(s, off, 1, width, radius, t, off, 1, dq);
|
|
34
|
+
}
|
|
35
|
+
void height;
|
|
36
|
+
},
|
|
37
|
+
|
|
38
|
+
// Summed-area table, pass 1: each row's own prefix sum, rows
|
|
39
|
+
// independent. The serial twin fuses this with the column
|
|
40
|
+
// accumulation below, which is cache-friendly but carries a
|
|
41
|
+
// dependency from one row to the next and so cannot be split.
|
|
42
|
+
integralRows({ src, out, width, stride }, lo, hi) {
|
|
43
|
+
const S = new Uint8Array(src);
|
|
44
|
+
const O = new Uint32Array(out);
|
|
45
|
+
for (let y = lo; y < hi; y++) {
|
|
46
|
+
const srcRow = y * width;
|
|
47
|
+
const intRow = (y + 1) * stride;
|
|
48
|
+
let rowSum = 0;
|
|
49
|
+
for (let x = 0; x < width; x++) {
|
|
50
|
+
rowSum += S[srcRow + x];
|
|
51
|
+
O[intRow + x + 1] = rowSum;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
// Pass 2: accumulate each column downwards, columns independent.
|
|
57
|
+
// `lo`/`hi` are a column range, but the loops are row-major inside it
|
|
58
|
+
// so each worker walks a contiguous run of every row rather than
|
|
59
|
+
// striding down memory - row y only needs row y-1, which the previous
|
|
60
|
+
// iteration of this same loop already finished.
|
|
61
|
+
integralCols({ out, height, stride }, lo, hi) {
|
|
62
|
+
const O = new Uint32Array(out);
|
|
63
|
+
for (let y = 0; y < height; y++) {
|
|
64
|
+
const intRow = (y + 1) * stride;
|
|
65
|
+
const intPrevRow = y * stride;
|
|
66
|
+
for (let x = lo; x < hi; x++) {
|
|
67
|
+
O[intRow + x + 1] += O[intPrevRow + x + 1];
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
|
|
72
|
+
// The grey twin of the two passes above. Float64 addition is not
|
|
73
|
+
// associative in general, but every value here is an integer: greys
|
|
74
|
+
// are 0-255 and the largest sum this canvas can reach is far below
|
|
75
|
+
// 2^53, so each partial sum is exact and the split cannot change a
|
|
76
|
+
// single bit - the same reasoning that made this table Float64 rather
|
|
77
|
+
// than Uint32 in the first place.
|
|
78
|
+
integralRows64({ src, out, width, stride }, lo, hi) {
|
|
79
|
+
const S = new Uint8Array(src);
|
|
80
|
+
const O = new Float64Array(out);
|
|
81
|
+
for (let y = lo; y < hi; y++) {
|
|
82
|
+
const srcRow = y * width;
|
|
83
|
+
const intRow = (y + 1) * stride;
|
|
84
|
+
let rowSum = 0;
|
|
85
|
+
for (let x = 0; x < width; x++) {
|
|
86
|
+
rowSum += S[srcRow + x];
|
|
87
|
+
O[intRow + x + 1] = rowSum;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
},
|
|
91
|
+
|
|
92
|
+
integralCols64({ out, height, stride }, lo, hi) {
|
|
93
|
+
const O = new Float64Array(out);
|
|
94
|
+
for (let y = 0; y < height; y++) {
|
|
95
|
+
const intRow = (y + 1) * stride;
|
|
96
|
+
const intPrevRow = y * stride;
|
|
97
|
+
for (let x = lo; x < hi; x++) {
|
|
98
|
+
O[intRow + x + 1] += O[intPrevRow + x + 1];
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
|
|
103
|
+
// vertical pass: columns are independent
|
|
104
|
+
dilateCols({ tmp, out, width, height, radius }, lo, hi) {
|
|
105
|
+
const t = new Uint8Array(tmp);
|
|
106
|
+
const o = new Uint8Array(out);
|
|
107
|
+
const dq = deque(height);
|
|
108
|
+
for (let x = lo; x < hi; x++) {
|
|
109
|
+
slidingMax1D(t, x, width, height, radius, o, x, width, dq);
|
|
110
|
+
}
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
// defect = a AND NOT b, then cleared where either image was ambiguous
|
|
114
|
+
defect({ a, b, ambiguousA, ambiguousB, out, counts, width }, lo, hi, index) {
|
|
115
|
+
const A = new Uint8Array(a);
|
|
116
|
+
const B = new Uint8Array(b);
|
|
117
|
+
const O = new Uint8Array(out);
|
|
118
|
+
const C = new Uint32Array(counts);
|
|
119
|
+
const ambA = ambiguousA ? new Uint8Array(ambiguousA) : null;
|
|
120
|
+
const ambB = ambiguousB ? new Uint8Array(ambiguousB) : null;
|
|
121
|
+
let count = 0;
|
|
122
|
+
for (let y = lo; y < hi; y++) {
|
|
123
|
+
const row = y * width;
|
|
124
|
+
for (let x = 0; x < width; x++) {
|
|
125
|
+
const i = row + x;
|
|
126
|
+
let d = A[i] & ~B[i] & 1;
|
|
127
|
+
if (d && ((ambA !== null && ambA[i]) || (ambB !== null && ambB[i]))) d = 0;
|
|
128
|
+
O[i] = d;
|
|
129
|
+
count += d;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// one slot per worker, summed by the caller - avoids an atomic in
|
|
133
|
+
// the inner loop for a number only needed once at the end
|
|
134
|
+
C[index] = count;
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
// resample the frame into golden's grid - rows are independent
|
|
138
|
+
warp(
|
|
139
|
+
{ src, out, srcW, srcH, mx, my, theta, ox, oy, outW, sat, satStride },
|
|
140
|
+
lo,
|
|
141
|
+
hi,
|
|
142
|
+
) {
|
|
143
|
+
// the grey summed-area table is Float64 (see buildGrayTable - a
|
|
144
|
+
// Uint32 table wraps on large frames); keep this in step with the
|
|
145
|
+
// serial path or the two stop being byte-identical
|
|
146
|
+
const table = sat
|
|
147
|
+
? {
|
|
148
|
+
integral: new Float64Array(sat),
|
|
149
|
+
stride: satStride,
|
|
150
|
+
width: srcW,
|
|
151
|
+
height: srcH,
|
|
152
|
+
}
|
|
153
|
+
: null;
|
|
154
|
+
warpRows(
|
|
155
|
+
new Uint8Array(out),
|
|
156
|
+
new Uint8Array(src),
|
|
157
|
+
srcW,
|
|
158
|
+
srcH,
|
|
159
|
+
mx,
|
|
160
|
+
my,
|
|
161
|
+
theta,
|
|
162
|
+
ox,
|
|
163
|
+
oy,
|
|
164
|
+
outW,
|
|
165
|
+
table,
|
|
166
|
+
lo,
|
|
167
|
+
hi,
|
|
168
|
+
);
|
|
169
|
+
},
|
|
170
|
+
|
|
171
|
+
// per-tile displacement - tile rows are independent
|
|
172
|
+
localField(
|
|
173
|
+
{
|
|
174
|
+
golden,
|
|
175
|
+
target,
|
|
176
|
+
fx,
|
|
177
|
+
fy,
|
|
178
|
+
valid,
|
|
179
|
+
width,
|
|
180
|
+
height,
|
|
181
|
+
gridW,
|
|
182
|
+
tile,
|
|
183
|
+
maxOffset,
|
|
184
|
+
minStdDev,
|
|
185
|
+
},
|
|
186
|
+
lo,
|
|
187
|
+
hi,
|
|
188
|
+
) {
|
|
189
|
+
fieldRows(
|
|
190
|
+
new Uint8Array(golden),
|
|
191
|
+
new Uint8Array(target),
|
|
192
|
+
width,
|
|
193
|
+
height,
|
|
194
|
+
{ tile, maxOffset, minStdDev },
|
|
195
|
+
{
|
|
196
|
+
fx: new Float32Array(fx),
|
|
197
|
+
fy: new Float32Array(fy),
|
|
198
|
+
valid: new Uint8Array(valid),
|
|
199
|
+
gridW,
|
|
200
|
+
},
|
|
201
|
+
lo,
|
|
202
|
+
hi,
|
|
203
|
+
);
|
|
204
|
+
},
|
|
205
|
+
|
|
206
|
+
// resample under the displacement field - rows are independent
|
|
207
|
+
localApply(
|
|
208
|
+
{ target, out, fx, fy, width, height, gridW, gridH, tile },
|
|
209
|
+
lo,
|
|
210
|
+
hi,
|
|
211
|
+
) {
|
|
212
|
+
applyRows(
|
|
213
|
+
new Uint8Array(out),
|
|
214
|
+
new Uint8Array(target),
|
|
215
|
+
width,
|
|
216
|
+
height,
|
|
217
|
+
{ fx: new Float32Array(fx), fy: new Float32Array(fy), gridW, gridH },
|
|
218
|
+
tile,
|
|
219
|
+
lo,
|
|
220
|
+
hi,
|
|
221
|
+
);
|
|
222
|
+
},
|
|
223
|
+
|
|
224
|
+
// Score a contiguous run of candidate transforms for the alignment
|
|
225
|
+
// polish - candidates are independent, so this splits by candidate
|
|
226
|
+
// rather than by rows.
|
|
227
|
+
//
|
|
228
|
+
// It has to reproduce warpGray + the serial scorer *exactly*, not
|
|
229
|
+
// closely: these numbers are compared against each other to choose a
|
|
230
|
+
// transform, so a last-bit difference that depended on how the batch
|
|
231
|
+
// was split would make the chosen alignment a function of the machine's
|
|
232
|
+
// core count. Hence the same fill of 255, the same mx/my > 1.001 test
|
|
233
|
+
// for the area-average path, and warpRows itself rather than a copy.
|
|
234
|
+
objective(
|
|
235
|
+
{
|
|
236
|
+
gray,
|
|
237
|
+
fgObj,
|
|
238
|
+
sat,
|
|
239
|
+
satStride,
|
|
240
|
+
srcW,
|
|
241
|
+
srcH,
|
|
242
|
+
outW,
|
|
243
|
+
outH,
|
|
244
|
+
level,
|
|
245
|
+
params,
|
|
246
|
+
scores,
|
|
247
|
+
},
|
|
248
|
+
lo,
|
|
249
|
+
hi,
|
|
250
|
+
) {
|
|
251
|
+
const src = new Uint8Array(gray);
|
|
252
|
+
const fg = new Uint8Array(fgObj);
|
|
253
|
+
const p = new Float64Array(params);
|
|
254
|
+
const outScores = new Float64Array(scores);
|
|
255
|
+
const table = {
|
|
256
|
+
integral: new Float64Array(sat),
|
|
257
|
+
stride: satStride,
|
|
258
|
+
width: srcW,
|
|
259
|
+
height: srcH,
|
|
260
|
+
};
|
|
261
|
+
// one scratch canvas for this worker's whole run of candidates
|
|
262
|
+
const out = new Uint8Array(outW * outH);
|
|
263
|
+
for (let c = lo; c < hi; c++) {
|
|
264
|
+
const mx = p[c * 5];
|
|
265
|
+
const my = p[c * 5 + 1];
|
|
266
|
+
const theta = p[c * 5 + 2];
|
|
267
|
+
const ox = p[c * 5 + 3];
|
|
268
|
+
const oy = p[c * 5 + 4];
|
|
269
|
+
// warpGray allocates a fresh canvas and fills it before warping;
|
|
270
|
+
// refilling the scratch is the same thing without the allocation
|
|
271
|
+
out.fill(255);
|
|
272
|
+
warpRows(
|
|
273
|
+
out,
|
|
274
|
+
src,
|
|
275
|
+
srcW,
|
|
276
|
+
srcH,
|
|
277
|
+
mx,
|
|
278
|
+
my,
|
|
279
|
+
theta,
|
|
280
|
+
ox,
|
|
281
|
+
oy,
|
|
282
|
+
outW,
|
|
283
|
+
mx > 1.001 || my > 1.001 ? table : null,
|
|
284
|
+
0,
|
|
285
|
+
outH,
|
|
286
|
+
);
|
|
287
|
+
let mismatch = 0;
|
|
288
|
+
for (let i = 0; i < out.length; i++) {
|
|
289
|
+
if ((out[i] < level ? 1 : 0) !== fg[i]) mismatch++;
|
|
290
|
+
}
|
|
291
|
+
outScores[c] = mismatch / out.length;
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
|
|
295
|
+
// grey -> ink mask against one global level, plus the ambiguity band
|
|
296
|
+
binarize({ gray, fg, ambiguous, level, margin, width }, lo, hi) {
|
|
297
|
+
const G = new Uint8Array(gray);
|
|
298
|
+
const F = new Uint8Array(fg);
|
|
299
|
+
const A = ambiguous ? new Uint8Array(ambiguous) : null;
|
|
300
|
+
const floor = level - margin;
|
|
301
|
+
const ceil = level + margin;
|
|
302
|
+
for (let y = lo; y < hi; y++) {
|
|
303
|
+
const row = y * width;
|
|
304
|
+
for (let x = 0; x < width; x++) {
|
|
305
|
+
const i = row + x;
|
|
306
|
+
const v = G[i];
|
|
307
|
+
F[i] = v < level ? 1 : 0;
|
|
308
|
+
if (A !== null) A[i] = v >= floor && v <= ceil ? 1 : 0;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
|
|
314
|
+
// Every reply carries the id of the dispatch it answers. The pool used to
|
|
315
|
+
// settle a dispatch on the next message from its worker, whichever dispatch
|
|
316
|
+
// that message actually belonged to - see lib/pool.js.
|
|
317
|
+
parentPort.on("message", ({ kernel, ctx, lo, hi, index, id }) => {
|
|
318
|
+
try {
|
|
319
|
+
kernels[kernel](ctx, lo, hi, index);
|
|
320
|
+
parentPort.postMessage({ id });
|
|
321
|
+
} catch (err) {
|
|
322
|
+
parentPort.postMessage({ id, error: `${kernel}: ${err.message}` });
|
|
323
|
+
}
|
|
324
|
+
});
|
package/lib/scaleFile.js
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared calibration-baseline file helpers, used by both golden-compare
|
|
3
|
+
* (reads the calibrated mm/px scale) and checkerboard-calibrate (reads
|
|
4
|
+
* and writes it).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
"use strict";
|
|
8
|
+
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const fsp = fs.promises;
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
async function pathExists(p) {
|
|
14
|
+
try {
|
|
15
|
+
await fsp.access(p);
|
|
16
|
+
return true;
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isFinitePositive(v) {
|
|
23
|
+
return typeof v === "number" && Number.isFinite(v) && v > 0;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Physical sanity bound for mm/px. A vision lens maps a few mm onto many
|
|
27
|
+
// pixels, so a value past 1000 mm/px is a corrupt file, not a real rig -
|
|
28
|
+
// and so is 0, a negative, or an Infinity that JSON.parse happily accepts
|
|
29
|
+
// from "1e999". Any of those flow downstream into NaN and a sharp.resize
|
|
30
|
+
// that errors every frame (or a silent pass when the sizes happen to
|
|
31
|
+
// match), so refuse with a reason instead of applying them.
|
|
32
|
+
const MM_PER_PX_MAX = 1000;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Read the calibration baseline, returning null when there is nothing
|
|
36
|
+
* configured or on disk. A file that exists but is unreadable, corrupt,
|
|
37
|
+
* or out of physical range comes back as { error } rather than null so
|
|
38
|
+
* the caller can say so instead of silently running uncalibrated.
|
|
39
|
+
*
|
|
40
|
+
* nativeWidth/nativeHeight (the calibration photo's own size, written by
|
|
41
|
+
* checkerboard-calibrate) are validated when present; files written
|
|
42
|
+
* before the geometry was recorded simply omit them and keep working
|
|
43
|
+
* with the golden-native fallback in prepareGolden.
|
|
44
|
+
*/
|
|
45
|
+
async function readScaleFile(scaleFilePath) {
|
|
46
|
+
if (!scaleFilePath || !(await pathExists(scaleFilePath))) return null;
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(await fsp.readFile(scaleFilePath, "utf8"));
|
|
50
|
+
} catch (err) {
|
|
51
|
+
return { error: `calibration file is not readable JSON: ${err.message}` };
|
|
52
|
+
}
|
|
53
|
+
if (
|
|
54
|
+
!isFinitePositive(parsed.mmPerPixelNative) ||
|
|
55
|
+
parsed.mmPerPixelNative > MM_PER_PX_MAX
|
|
56
|
+
) {
|
|
57
|
+
return {
|
|
58
|
+
error:
|
|
59
|
+
`calibration mmPerPixelNative must be a finite positive number <= ` +
|
|
60
|
+
`${MM_PER_PX_MAX} (got ${parsed.mmPerPixelNative}) - re-run ` +
|
|
61
|
+
`checkerboard-calibrate`,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
const w = parsed.nativeWidth;
|
|
65
|
+
const h = parsed.nativeHeight;
|
|
66
|
+
if (w != null || h != null) {
|
|
67
|
+
if (!Number.isInteger(w) || !Number.isInteger(h) || w <= 0 || h <= 0) {
|
|
68
|
+
return {
|
|
69
|
+
error:
|
|
70
|
+
`calibration nativeWidth/nativeHeight must be positive integers ` +
|
|
71
|
+
`(got ${w}/${h}) - re-run checkerboard-calibrate`,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return parsed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function writeScaleFile(scaleFilePath, record) {
|
|
79
|
+
await fsp.mkdir(path.dirname(scaleFilePath), { recursive: true });
|
|
80
|
+
await fsp.writeFile(scaleFilePath, JSON.stringify(record, null, 2));
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { readScaleFile, writeScaleFile, pathExists };
|