@graciousstar/node-red-contrib-vision-tools 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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
+ });
@@ -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 };