@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,765 @@
1
+ /**
2
+ * Caliper line finder: find a straight edge inside a user-drawn search
3
+ * region.
4
+ *
5
+ * Why this exists (see ARCHITECTURE.md "line-finder"):
6
+ *
7
+ * `lib/labelCrop.js` finds the label by thresholding the whole frame and
8
+ * taking the dominant blob. That works when the part sits on a
9
+ * contrasting background. It does not work when the *strongest* edge
10
+ * near the boundary belongs to the artwork rather than to the part: on
11
+ * the Inspection rig the label's own boundary is a 4-10 grey-level step
12
+ * while the printed rules a few millimetres inside it are 25-90, so the
13
+ * global search locks onto the print and the resulting rectangle swings
14
+ * by half its area from frame to frame.
15
+ *
16
+ * A caliper finder removes the ambiguity by construction. The operator
17
+ * draws a box over the edge they mean; nothing outside that box can win.
18
+ * The contrast that matters is then the only contrast in the search.
19
+ *
20
+ * The method, per region:
21
+ *
22
+ * 1. Sample the region in its own (scan, line) axes with bilinear
23
+ * interpolation, so a rotated region costs no extra code path.
24
+ * 2. Split the line axis into `calipers` bands and average each band
25
+ * across its full width. Averaging is the whole trick: a 4-level
26
+ * step under 3 levels of sensor noise is invisible in one row and
27
+ * obvious across two hundred.
28
+ * 3. Smooth each band's profile, differentiate it, and take the
29
+ * extremum that matches the configured polarity - with parabolic
30
+ * sub-pixel interpolation, since a half-pixel bias over a 3000px
31
+ * frame is a millimetre of crop error.
32
+ * 4. Fit a line to the caliper points by total least squares, dropping
33
+ * outliers, so one caliper that found a speck of dirt cannot tilt
34
+ * the result.
35
+ *
36
+ * Pure JS on a grayscale raster: no OpenCV engine, no image decode. That
37
+ * keeps it unit-testable on any platform (the bridge ships no
38
+ * win32 binary) and cheap enough to run four times per frame, since only
39
+ * the region's own pixels are ever touched.
40
+ */
41
+
42
+ "use strict";
43
+
44
+ const DEG = Math.PI / 180;
45
+
46
+ const SCAN_DIRECTIONS = ["right", "left", "down", "up"];
47
+ const POLARITIES = ["either", "darkToLight", "lightToDark"];
48
+ const EDGE_SELECTS = ["best", "first", "last"];
49
+
50
+ const DEFAULTS = {
51
+ // which way the calipers travel across the region; the edge being
52
+ // found is perpendicular to this
53
+ scanDirection: "right",
54
+ // darkToLight/lightToDark are named for what the caliper sees as it
55
+ // travels *along* scanDirection
56
+ polarity: "either",
57
+ // how many independent scans across the region's length
58
+ calipers: 16,
59
+ // grey levels per pixel of travel the step must reach; below this a
60
+ // caliper reports nothing rather than guessing
61
+ contrastThreshold: 2,
62
+ // box-smoothing half-width applied to each profile before
63
+ // differentiating, in scan pixels
64
+ filterHalfWidth: 2,
65
+ // which candidate to keep when a caliper sees several qualifying edges
66
+ edgeSelect: "best",
67
+ // skip this many qualifying edges before selecting; lets an operator
68
+ // step past a known first edge (a frame vignette, say)
69
+ ignoreCount: 0,
70
+ // a caliper point further than this from the fitted line is dropped
71
+ outlierTolerancePx: 2.5,
72
+ // fraction of calipers that must survive the fit for a "found"
73
+ minCaliperFraction: 0.5,
74
+ // reject a fit that leans this far from the region's own orientation;
75
+ // null disables the check
76
+ angleToleranceDeg: 10,
77
+ };
78
+
79
+ function finiteOr(value, fallback) {
80
+ const n = typeof value === "number" ? value : Number.parseFloat(value);
81
+ return Number.isFinite(n) ? n : fallback;
82
+ }
83
+
84
+ function clamp(value, lo, hi) {
85
+ return Math.min(hi, Math.max(lo, value));
86
+ }
87
+
88
+ /** Fill in and range-check a caller's options. */
89
+ function normalizeCfg(cfg = {}) {
90
+ const out = { ...DEFAULTS };
91
+ for (const k of Object.keys(DEFAULTS)) {
92
+ if (cfg[k] !== undefined && cfg[k] !== null && cfg[k] !== "") out[k] = cfg[k];
93
+ }
94
+ // angleToleranceDeg is the one option whose "off" value is null, so a
95
+ // caller passing null has to reach it - the loop above cannot, since
96
+ // for every other option null means "not supplied, use the default".
97
+ if (cfg.angleToleranceDeg === null || cfg.angleToleranceDeg === "") {
98
+ out.angleToleranceDeg = null;
99
+ }
100
+ if (!SCAN_DIRECTIONS.includes(out.scanDirection)) {
101
+ out.scanDirection = DEFAULTS.scanDirection;
102
+ }
103
+ if (!POLARITIES.includes(out.polarity)) out.polarity = DEFAULTS.polarity;
104
+ if (!EDGE_SELECTS.includes(out.edgeSelect)) out.edgeSelect = DEFAULTS.edgeSelect;
105
+ out.calipers = clamp(
106
+ Math.round(finiteOr(out.calipers, DEFAULTS.calipers)),
107
+ 1,
108
+ 512,
109
+ );
110
+ out.contrastThreshold = clamp(
111
+ finiteOr(out.contrastThreshold, DEFAULTS.contrastThreshold),
112
+ 0,
113
+ 255,
114
+ );
115
+ out.filterHalfWidth = clamp(
116
+ Math.round(finiteOr(out.filterHalfWidth, DEFAULTS.filterHalfWidth)),
117
+ 0,
118
+ 64,
119
+ );
120
+ out.ignoreCount = clamp(
121
+ Math.round(finiteOr(out.ignoreCount, DEFAULTS.ignoreCount)),
122
+ 0,
123
+ 64,
124
+ );
125
+ out.outlierTolerancePx = clamp(
126
+ finiteOr(out.outlierTolerancePx, DEFAULTS.outlierTolerancePx),
127
+ 0.1,
128
+ 1000,
129
+ );
130
+ out.minCaliperFraction = clamp(
131
+ finiteOr(out.minCaliperFraction, DEFAULTS.minCaliperFraction),
132
+ 0.05,
133
+ 1,
134
+ );
135
+ out.angleToleranceDeg =
136
+ out.angleToleranceDeg == null
137
+ ? null
138
+ : clamp(finiteOr(out.angleToleranceDeg, DEFAULTS.angleToleranceDeg), 0, 90);
139
+ return out;
140
+ }
141
+
142
+ /**
143
+ * Normalise a search region.
144
+ *
145
+ * `{ x, y, width, height }` is the un-rotated box with (x, y) its
146
+ * top-left corner; `angleDeg` then rotates it clockwise about its own
147
+ * centre (clockwise because image y runs downwards, so this matches what
148
+ * an operator sees when they rotate the box in the editor).
149
+ */
150
+ function normalizeRegion(region) {
151
+ if (!region || typeof region !== "object") {
152
+ throw new Error("line-finder: region must be { x, y, width, height }");
153
+ }
154
+ const x = finiteOr(region.x, Number.NaN);
155
+ const y = finiteOr(region.y, Number.NaN);
156
+ const width = finiteOr(region.width, Number.NaN);
157
+ const height = finiteOr(region.height, Number.NaN);
158
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
159
+ throw new Error("line-finder: region needs finite x and y");
160
+ }
161
+ if (!(width > 0) || !(height > 0)) {
162
+ throw new Error("line-finder: region needs positive width and height");
163
+ }
164
+ return {
165
+ x,
166
+ y,
167
+ width,
168
+ height,
169
+ angleDeg: finiteOr(region.angleDeg, 0),
170
+ cx: x + width / 2,
171
+ cy: y + height / 2,
172
+ };
173
+ }
174
+
175
+ /**
176
+ * Region-local frame for a scan direction.
177
+ *
178
+ * `scan` is the unit vector the calipers travel along, `line` the unit
179
+ * vector the edge is expected to run along, and `origin` the image point
180
+ * where (scan=0, line=0) sits. `depth`/`length` are the region's extents
181
+ * along those two axes.
182
+ */
183
+ function regionFrame(region, scanDirection) {
184
+ const a = region.angleDeg * DEG;
185
+ const ca = Math.cos(a);
186
+ const sa = Math.sin(a);
187
+ // the region's own axes in image coordinates
188
+ const ex = { x: ca, y: sa }; // local +x
189
+ const ey = { x: -sa, y: ca }; // local +y
190
+ const hw = region.width / 2;
191
+ const hh = region.height / 2;
192
+
193
+ // (sx, sy) is the local-space corner the scan starts from, and the
194
+ // local-space vectors it advances along.
195
+ let scan;
196
+ let line;
197
+ let depth;
198
+ let length;
199
+ let start;
200
+ switch (scanDirection) {
201
+ case "right":
202
+ scan = ex;
203
+ line = ey;
204
+ depth = region.width;
205
+ length = region.height;
206
+ start = { u: -hw, v: -hh };
207
+ break;
208
+ case "left":
209
+ scan = { x: -ex.x, y: -ex.y };
210
+ line = ey;
211
+ depth = region.width;
212
+ length = region.height;
213
+ start = { u: hw, v: -hh };
214
+ break;
215
+ case "down":
216
+ scan = ey;
217
+ line = ex;
218
+ depth = region.height;
219
+ length = region.width;
220
+ start = { u: -hw, v: -hh };
221
+ break;
222
+ default: // "up"
223
+ scan = { x: -ey.x, y: -ey.y };
224
+ line = ex;
225
+ depth = region.height;
226
+ length = region.width;
227
+ start = { u: -hw, v: hh };
228
+ break;
229
+ }
230
+ // start corner in image coordinates
231
+ const origin = {
232
+ x: region.cx + start.u * ex.x + start.v * ey.x,
233
+ y: region.cy + start.u * ex.y + start.v * ey.y,
234
+ };
235
+ return { scan, line, origin, depth, length };
236
+ }
237
+
238
+ /**
239
+ * The region's four corners in image coordinates, in scan order:
240
+ * [scan-start/line-start, scan-end/line-start, scan-end/line-end,
241
+ * scan-start/line-end]. Exported so a preview can draw the box the
242
+ * operator actually configured, rather than an axis-aligned
243
+ * approximation of it that would hide a rotation mistake.
244
+ */
245
+ function regionCorners(region, scanDirection = DEFAULTS.scanDirection) {
246
+ const reg = normalizeRegion(region);
247
+ const f = regionFrame(reg, scanDirection);
248
+ const at = (u, v) => ({
249
+ x: f.origin.x + f.scan.x * u + f.line.x * v,
250
+ y: f.origin.y + f.scan.y * u + f.line.y * v,
251
+ });
252
+ return [at(0, 0), at(f.depth, 0), at(f.depth, f.length), at(0, f.length)];
253
+ }
254
+
255
+ /** Bilinear sample; returns NaN outside the image so callers can skip. */
256
+ function sampleBilinear(gray, width, height, x, y) {
257
+ if (!(x >= 0) || !(y >= 0) || x > width - 1 || y > height - 1) return Number.NaN;
258
+ const x0 = Math.floor(x);
259
+ const y0 = Math.floor(y);
260
+ const x1 = Math.min(x0 + 1, width - 1);
261
+ const y1 = Math.min(y0 + 1, height - 1);
262
+ const fx = x - x0;
263
+ const fy = y - y0;
264
+ const row0 = y0 * width;
265
+ const row1 = y1 * width;
266
+ const a = gray[row0 + x0] * (1 - fx) + gray[row0 + x1] * fx;
267
+ const b = gray[row1 + x0] * (1 - fx) + gray[row1 + x1] * fx;
268
+ return a * (1 - fy) + b * fy;
269
+ }
270
+
271
+ /** Box-smooth a profile in place-safe fashion (returns a new array). */
272
+ function smooth(profile, halfWidth) {
273
+ if (halfWidth <= 0) return profile;
274
+ const n = profile.length;
275
+ const out = new Float64Array(n);
276
+ // prefix sums keep this O(n) regardless of the window size
277
+ const sum = new Float64Array(n + 1);
278
+ for (let i = 0; i < n; i++) sum[i + 1] = sum[i] + profile[i];
279
+ for (let i = 0; i < n; i++) {
280
+ const lo = Math.max(0, i - halfWidth);
281
+ const hi = Math.min(n, i + halfWidth + 1);
282
+ out[i] = (sum[hi] - sum[lo]) / (hi - lo);
283
+ }
284
+ return out;
285
+ }
286
+
287
+ /**
288
+ * Candidate edges in one profile, as { at, strength } with `at` in
289
+ * profile samples and `strength` signed (positive = brighter along the
290
+ * scan). Candidates are local extrema of the first derivative, which is
291
+ * where the step is steepest.
292
+ */
293
+ function findEdges(profile, cfg) {
294
+ const n = profile.length;
295
+ if (n < 3) return [];
296
+ const d = new Float64Array(n);
297
+ // central difference: the derivative sits on the sample, not between
298
+ // two of them, which keeps the parabolic refinement below unbiased
299
+ for (let i = 1; i < n - 1; i++) d[i] = (profile[i + 1] - profile[i - 1]) / 2;
300
+
301
+ const wantSign =
302
+ cfg.polarity === "darkToLight" ? 1 : cfg.polarity === "lightToDark" ? -1 : 0;
303
+ const out = [];
304
+ for (let i = 2; i < n - 2; i++) {
305
+ const v = d[i];
306
+ if (wantSign !== 0 && Math.sign(v) !== wantSign) continue;
307
+ const mag = Math.abs(v);
308
+ if (mag < cfg.contrastThreshold) continue;
309
+ // strict on one side, non-strict on the other, so a plateau of
310
+ // equal derivatives yields exactly one candidate
311
+ if (mag < Math.abs(d[i - 1]) || mag <= Math.abs(d[i + 1])) continue;
312
+ // parabolic refinement on |d| through the peak and its neighbours
313
+ const y0 = Math.abs(d[i - 1]);
314
+ const y1 = mag;
315
+ const y2 = Math.abs(d[i + 1]);
316
+ const denom = y0 - 2 * y1 + y2;
317
+ const offset = denom === 0 ? 0 : clamp((0.5 * (y0 - y2)) / denom, -1, 1);
318
+ out.push({ at: i + offset, strength: v, magnitude: mag });
319
+ }
320
+ return out;
321
+ }
322
+
323
+ /** Apply ignoreCount + edgeSelect to one caliper's candidate list. */
324
+ function selectEdge(candidates, cfg) {
325
+ if (candidates.length === 0) return null;
326
+ // `first`/`last` are in scan order; `best` is by contrast. ignoreCount
327
+ // always steps along scan order, which is what "skip the vignette"
328
+ // means to an operator.
329
+ const ordered = candidates.slice().sort((a, b) => a.at - b.at);
330
+ const remaining = ordered.slice(cfg.ignoreCount);
331
+ if (remaining.length === 0) return null;
332
+ if (cfg.edgeSelect === "first") return remaining[0];
333
+ if (cfg.edgeSelect === "last") return remaining[remaining.length - 1];
334
+ let best = remaining[0];
335
+ for (const c of remaining) if (c.magnitude > best.magnitude) best = c;
336
+ return best;
337
+ }
338
+
339
+ /**
340
+ * Total-least-squares line through points, as a centroid plus a unit
341
+ * direction. Ordinary least squares cannot represent a vertical line, and
342
+ * two of the four edges of an upright label are vertical.
343
+ */
344
+ function fitLine(points) {
345
+ const n = points.length;
346
+ let sx = 0;
347
+ let sy = 0;
348
+ for (const p of points) {
349
+ sx += p.x;
350
+ sy += p.y;
351
+ }
352
+ const cx = sx / n;
353
+ const cy = sy / n;
354
+ let sxx = 0;
355
+ let syy = 0;
356
+ let sxy = 0;
357
+ for (const p of points) {
358
+ const dx = p.x - cx;
359
+ const dy = p.y - cy;
360
+ sxx += dx * dx;
361
+ syy += dy * dy;
362
+ sxy += dx * dy;
363
+ }
364
+ // principal axis of the scatter matrix
365
+ const theta = 0.5 * Math.atan2(2 * sxy, sxx - syy);
366
+ return { cx, cy, dx: Math.cos(theta), dy: Math.sin(theta) };
367
+ }
368
+
369
+ /** Signed distance from a point to a fitted line (left of it is positive). */
370
+ function distanceTo(line, p) {
371
+ return -line.dy * (p.x - line.cx) + line.dx * (p.y - line.cy);
372
+ }
373
+
374
+ /**
375
+ * Per-band mean profiles along the scan axis.
376
+ *
377
+ * Each band averages its whole slice of the region before anything looks for
378
+ * an edge in it, and that averaging is the sensitivity: a four-grey-level
379
+ * step is invisible in one row and unambiguous across a hundred and seventy
380
+ * five. Returns one entry per band - a Float64Array of `scanSteps` means, or
381
+ * null for a band that is not wholly inside the image, since a band with a
382
+ * moving sample count would have a step where the coverage changes rather
383
+ * than where the edge is.
384
+ */
385
+ function profilesGeneral(gray, width, height, frame, geom) {
386
+ const { bands, scanSteps, bandLength, rowsPerBand } = geom;
387
+ const out = new Array(bands);
388
+ for (let b = 0; b < bands; b++) {
389
+ const profile = new Float64Array(scanSteps);
390
+ const counts = new Float64Array(scanSteps);
391
+ for (let r = 0; r < rowsPerBand; r++) {
392
+ // centre of row r within band b, along the line axis
393
+ const v = bandLength * b + ((r + 0.5) * bandLength) / rowsPerBand;
394
+ const baseX = frame.origin.x + frame.line.x * v;
395
+ const baseY = frame.origin.y + frame.line.y * v;
396
+ for (let s = 0; s < scanSteps; s++) {
397
+ const u = (s + 0.5) * (frame.depth / scanSteps);
398
+ const px = baseX + frame.scan.x * u;
399
+ const py = baseY + frame.scan.y * u;
400
+ const value = sampleBilinear(gray, width, height, px, py);
401
+ if (Number.isNaN(value)) continue;
402
+ profile[s] += value;
403
+ counts[s] += 1;
404
+ }
405
+ }
406
+ let complete = true;
407
+ for (let s = 0; s < scanSteps; s++) {
408
+ if (counts[s] < rowsPerBand) {
409
+ complete = false;
410
+ break;
411
+ }
412
+ profile[s] /= counts[s];
413
+ }
414
+ out[b] = complete ? profile : null;
415
+ }
416
+ return out;
417
+ }
418
+
419
+ /**
420
+ * The same numbers for a region whose axes are the image's, which is every
421
+ * unrotated region - all four edges of an upright label included.
422
+ *
423
+ * The interpolation cannot be skipped even then: the scan samples at
424
+ * (s + 0.5) while sampleBilinear puts pixel centres on whole numbers, so
425
+ * every sample sits between two columns and dropping the blend would move
426
+ * every measurement half a pixel. What it can skip is the *bookkeeping*.
427
+ * Position along the scan axis depends only on s, so each step's two
428
+ * neighbours and their weight are resolved once for the whole region rather
429
+ * than once per sample, and the cross-axis pair is resolved once per row.
430
+ * That leaves four array reads in the inner loop instead of a call that
431
+ * recomputes two floors and a bounds test every time - measured at 2.2x on
432
+ * this rig's four regions, and bit-identical to profilesGeneral, which
433
+ * test/lineFinderSampling.test.js checks value by value.
434
+ */
435
+ function profilesAxisAligned(gray, width, height, frame, geom) {
436
+ const { bands, scanSteps, bandLength, rowsPerBand } = geom;
437
+ const stepU = frame.depth / scanSteps;
438
+ // which image axis the calipers travel along; the other one is the line
439
+ // axis the bands are stacked along
440
+ const alongX = frame.scan.y === 0;
441
+ const scanLimit = (alongX ? width : height) - 1;
442
+ const crossLimit = (alongX ? height : width) - 1;
443
+ const scanBase = alongX ? frame.origin.x : frame.origin.y;
444
+ const scanDir = alongX ? frame.scan.x : frame.scan.y;
445
+ const crossBase = alongX ? frame.origin.y : frame.origin.x;
446
+ const crossDir = alongX ? frame.line.y : frame.line.x;
447
+
448
+ const lo = new Int32Array(scanSteps);
449
+ const hi = new Int32Array(scanSteps);
450
+ const frac = new Float64Array(scanSteps);
451
+ for (let s = 0; s < scanSteps; s++) {
452
+ const p = scanBase + scanDir * ((s + 0.5) * stepU);
453
+ if (!(p >= 0) || p > scanLimit) {
454
+ // off the image along the scan: every row would be NaN there, so no
455
+ // band can be complete
456
+ return new Array(bands).fill(null);
457
+ }
458
+ const f = Math.floor(p);
459
+ lo[s] = f;
460
+ hi[s] = Math.min(f + 1, scanLimit);
461
+ frac[s] = p - f;
462
+ }
463
+
464
+ const out = new Array(bands);
465
+ for (let b = 0; b < bands; b++) {
466
+ const profile = new Float64Array(scanSteps);
467
+ let rows = 0;
468
+ for (let r = 0; r < rowsPerBand; r++) {
469
+ const v = bandLength * b + ((r + 0.5) * bandLength) / rowsPerBand;
470
+ const q = crossBase + crossDir * v;
471
+ if (!(q >= 0) || q > crossLimit) continue;
472
+ const q0 = Math.floor(q);
473
+ const q1 = Math.min(q0 + 1, crossLimit);
474
+ const fq = q - q0;
475
+ const gq = 1 - fq;
476
+ rows++;
477
+ if (alongX) {
478
+ const rowA = q0 * width;
479
+ const rowB = q1 * width;
480
+ for (let s = 0; s < scanSteps; s++) {
481
+ const f = frac[s];
482
+ const g = 1 - f;
483
+ const a = gray[rowA + lo[s]] * g + gray[rowA + hi[s]] * f;
484
+ const c = gray[rowB + lo[s]] * g + gray[rowB + hi[s]] * f;
485
+ profile[s] += a * gq + c * fq;
486
+ }
487
+ } else {
488
+ // the calipers run down a column, so lo/hi are rows and the
489
+ // interpolation pair q0/q1 are columns
490
+ for (let s = 0; s < scanSteps; s++) {
491
+ const f = frac[s];
492
+ const rowA = lo[s] * width;
493
+ const rowB = hi[s] * width;
494
+ const a = gray[rowA + q0] * gq + gray[rowA + q1] * fq;
495
+ const c = gray[rowB + q0] * gq + gray[rowB + q1] * fq;
496
+ profile[s] += a * (1 - f) + c * f;
497
+ }
498
+ }
499
+ }
500
+ if (rows < rowsPerBand) {
501
+ out[b] = null;
502
+ continue;
503
+ }
504
+ for (let s = 0; s < scanSteps; s++) profile[s] /= rows;
505
+ out[b] = profile;
506
+ }
507
+ return out;
508
+ }
509
+
510
+ /**
511
+ * True when the region's own axes are the image's, so the cheap profile
512
+ * builder applies. Tested on the frame vectors rather than on the angle
513
+ * because that is exactly what the fast path relies on, and because only an
514
+ * angle of 0 gives exact axis alignment - Math.sin(Math.PI) is 1.2e-16, not
515
+ * zero, and a snapped angle would no longer be bit-identical.
516
+ */
517
+ function isAxisAligned(frame) {
518
+ return (
519
+ (frame.scan.x === 0 || frame.scan.y === 0) &&
520
+ (frame.line.x === 0 || frame.line.y === 0)
521
+ );
522
+ }
523
+
524
+ /**
525
+ * Find one straight edge inside `region`.
526
+ *
527
+ * @param {Uint8Array|Uint8ClampedArray} gray single-channel image
528
+ * @param {number} width image width
529
+ * @param {number} height image height
530
+ * @param {object} region { x, y, width, height, angleDeg? }
531
+ * @param {object} [cfg] see DEFAULTS
532
+ * @returns {object} { found, reason, line, angleDeg, score, calipers,
533
+ * residualPx, points }
534
+ */
535
+ function findLine(gray, width, height, region, cfg = {}) {
536
+ const opts = normalizeCfg(cfg);
537
+ const reg = normalizeRegion(region);
538
+ const frame = regionFrame(reg, opts.scanDirection);
539
+
540
+ const scanSteps = Math.max(5, Math.round(frame.depth));
541
+ const bands = opts.calipers;
542
+ // Enough rows per band that averaging actually buys noise rejection,
543
+ // but never fewer than one.
544
+ const bandLength = frame.length / bands;
545
+ const rowsPerBand = Math.max(1, Math.round(bandLength));
546
+
547
+ const geom = { bands, scanSteps, bandLength, rowsPerBand };
548
+ const profiles = isAxisAligned(frame)
549
+ ? profilesAxisAligned(gray, width, height, frame, geom)
550
+ : profilesGeneral(gray, width, height, frame, geom);
551
+
552
+ const points = [];
553
+ let found = 0;
554
+ for (let b = 0; b < bands; b++) {
555
+ // null means the band was not wholly inside the image
556
+ const profile = profiles[b];
557
+ if (!profile) continue;
558
+
559
+ const edge = selectEdge(
560
+ findEdges(smooth(profile, opts.filterHalfWidth), opts),
561
+ opts,
562
+ );
563
+ if (!edge) continue;
564
+ found++;
565
+ // back to image coordinates: the edge sits `u` along the scan axis
566
+ // from this band's start point
567
+ const u = (edge.at + 0.5) * (frame.depth / scanSteps);
568
+ const v = bandLength * (b + 0.5);
569
+ points.push({
570
+ x: frame.origin.x + frame.line.x * v + frame.scan.x * u,
571
+ y: frame.origin.y + frame.line.y * v + frame.scan.y * u,
572
+ contrast: Math.abs(edge.strength),
573
+ strength: edge.strength,
574
+ band: b,
575
+ used: true,
576
+ });
577
+ }
578
+
579
+ const minUsed = Math.max(2, Math.ceil(bands * opts.minCaliperFraction));
580
+ const miss = (reason, usedCount = 0) => ({
581
+ found: false,
582
+ reason,
583
+ line: null,
584
+ angleDeg: null,
585
+ score: 0,
586
+ calipers: { total: bands, found, used: usedCount },
587
+ residualPx: null,
588
+ points,
589
+ });
590
+ if (points.length < 2) return miss("no-edge");
591
+ if (points.length < minUsed) return miss("too-few-calipers");
592
+
593
+ // Peel the single worst point per pass rather than every point over
594
+ // tolerance at once. One caliper that landed on a speck drags the
595
+ // first fit far enough that a batch trim rejects the *inliers* too -
596
+ // they are all on the same side of a line the outlier tilted - and the
597
+ // finder then throws away a perfectly good edge. Removing one at a
598
+ // time cannot do that, and is deterministic, which RANSAC is not.
599
+ // The inlier fraction here is high by construction (the operator drew
600
+ // the box around one edge), so no sampling method is needed.
601
+ let used = points.slice();
602
+ let line = fitLine(used);
603
+ while (used.length > minUsed) {
604
+ let worst = -1;
605
+ let worstAt = -1;
606
+ for (let i = 0; i < used.length; i++) {
607
+ const d = Math.abs(distanceTo(line, used[i]));
608
+ if (d > worst) {
609
+ worst = d;
610
+ worstAt = i;
611
+ }
612
+ }
613
+ if (worst <= opts.outlierTolerancePx) break;
614
+ used.splice(worstAt, 1);
615
+ line = fitLine(used);
616
+ }
617
+ const usedSet = new Set(used.map((p) => p.band));
618
+ for (const p of points) p.used = usedSet.has(p.band);
619
+
620
+ let sq = 0;
621
+ for (const p of used) {
622
+ const d = distanceTo(line, p);
623
+ sq += d * d;
624
+ }
625
+ const residualPx = Math.sqrt(sq / used.length);
626
+
627
+ // Report the angle of the fitted line, folded to (-90, 90].
628
+ let angleDeg = Math.atan2(line.dy, line.dx) / DEG;
629
+ while (angleDeg > 90) angleDeg -= 180;
630
+ while (angleDeg <= -90) angleDeg += 180;
631
+
632
+ if (opts.angleToleranceDeg != null) {
633
+ // the orientation the region says to expect, folded the same way
634
+ let expected = Math.atan2(frame.line.y, frame.line.x) / DEG;
635
+ while (expected > 90) expected -= 180;
636
+ while (expected <= -90) expected += 180;
637
+ let delta = Math.abs(angleDeg - expected);
638
+ if (delta > 90) delta = 180 - delta;
639
+ if (delta > opts.angleToleranceDeg) {
640
+ const m = miss("angle-out-of-tolerance");
641
+ m.angleDeg = angleDeg;
642
+ m.calipers.used = used.length;
643
+ return m;
644
+ }
645
+ }
646
+
647
+ // Score blends coverage with fit quality: a line found by half the
648
+ // calipers, or one they only loosely agree on, is worth reporting but
649
+ // not worth trusting as much as a tight full-length fit.
650
+ const coverage = used.length / bands;
651
+ const tightness = 1 / (1 + residualPx / opts.outlierTolerancePx);
652
+ const score = clamp(coverage * tightness, 0, 1);
653
+
654
+ // Endpoints clipped to the region's length, for drawing and for
655
+ // intersecting with a neighbouring edge.
656
+ const half = frame.length / 2;
657
+ const midV = { x: frame.origin.x + frame.line.x * half, y: frame.origin.y + frame.line.y * half };
658
+ const t = (midV.x - line.cx) * line.dx + (midV.y - line.cy) * line.dy;
659
+ const mid = { x: line.cx + line.dx * t, y: line.cy + line.dy * t };
660
+ return {
661
+ found: true,
662
+ reason: "ok",
663
+ line: {
664
+ // a point on the line and a unit direction along it
665
+ x: line.cx,
666
+ y: line.cy,
667
+ dx: line.dx,
668
+ dy: line.dy,
669
+ p0: { x: mid.x - line.dx * half, y: mid.y - line.dy * half },
670
+ p1: { x: mid.x + line.dx * half, y: mid.y + line.dy * half },
671
+ },
672
+ angleDeg,
673
+ score,
674
+ calipers: { total: bands, found, used: used.length },
675
+ residualPx,
676
+ points,
677
+ };
678
+ }
679
+
680
+ /**
681
+ * Intersection of two fitted lines, or null when they are near-parallel.
682
+ * `line` is the shape `findLine` returns on `result.line`.
683
+ */
684
+ function intersectLines(a, b) {
685
+ const denom = a.dx * -b.dy - a.dy * -b.dx;
686
+ // ~0.5 degrees; below this the intersection point is numerically
687
+ // meaningless and a corner built from it would be worse than no corner
688
+ if (Math.abs(denom) < 1e-3) return null;
689
+ const rx = b.x - a.x;
690
+ const ry = b.y - a.y;
691
+ const t = (rx * -b.dy - ry * -b.dx) / denom;
692
+ return { x: a.x + a.dx * t, y: a.y + a.dy * t };
693
+ }
694
+
695
+ /**
696
+ * Turn four found edges into the oriented rectangle they bound.
697
+ *
698
+ * Takes `{ left, right, top, bottom }` of `findLine` results and returns
699
+ * the rectangle in the form `lib/labelCrop.js` already rotates and crops
700
+ * with: centre, size, angle and the four corners.
701
+ */
702
+ function rectFromLines({ left, right, top, bottom }) {
703
+ const edges = { left, right, top, bottom };
704
+ const missing = Object.keys(edges).filter((k) => !edges[k] || !edges[k].found);
705
+ if (missing.length) {
706
+ return { ok: false, reason: `missing-edge:${missing.join("+")}`, missing };
707
+ }
708
+ const tl = intersectLines(left.line, top.line);
709
+ const tr = intersectLines(right.line, top.line);
710
+ const br = intersectLines(right.line, bottom.line);
711
+ const bl = intersectLines(left.line, bottom.line);
712
+ if (!tl || !tr || !br || !bl) {
713
+ return { ok: false, reason: "parallel-edges", missing: [] };
714
+ }
715
+ const corners = [tl, tr, br, bl];
716
+ const cx = (tl.x + tr.x + br.x + bl.x) / 4;
717
+ const cy = (tl.y + tr.y + br.y + bl.y) / 4;
718
+ // Width from the two horizontal spans, height from the two vertical
719
+ // ones: averaging both sides cancels the residual tilt each edge
720
+ // carries, which a single span would bake into the crop.
721
+ const dist = (p, q) => Math.hypot(p.x - q.x, p.y - q.y);
722
+ const w = (dist(tl, tr) + dist(bl, br)) / 2;
723
+ const h = (dist(tl, bl) + dist(tr, br)) / 2;
724
+ // The rectangle's angle is the top/bottom edges' shared orientation;
725
+ // take the mean so neither edge alone decides the deskew.
726
+ const angleDeg = (top.angleDeg + bottom.angleDeg) / 2;
727
+ const score = Math.min(left.score, right.score, top.score, bottom.score);
728
+ return {
729
+ ok: true,
730
+ reason: "ok",
731
+ cx,
732
+ cy,
733
+ width: w,
734
+ height: h,
735
+ angleDeg,
736
+ corners,
737
+ score,
738
+ residualPx: Math.max(
739
+ left.residualPx,
740
+ right.residualPx,
741
+ top.residualPx,
742
+ bottom.residualPx,
743
+ ),
744
+ };
745
+ }
746
+
747
+ module.exports = {
748
+ findLine,
749
+ intersectLines,
750
+ rectFromLines,
751
+ regionCorners,
752
+ // exported for tests
753
+ normalizeCfg,
754
+ normalizeRegion,
755
+ regionFrame,
756
+ profilesGeneral,
757
+ profilesAxisAligned,
758
+ isAxisAligned,
759
+ findEdges,
760
+ fitLine,
761
+ DEFAULTS,
762
+ SCAN_DIRECTIONS,
763
+ POLARITIES,
764
+ EDGE_SELECTS,
765
+ };