@graciousstar/node-red-contrib-vision-tools 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/ARCHITECTURE.md +875 -0
- package/CHANGELOG.md +524 -0
- package/LICENSE +202 -0
- package/README.md +992 -0
- package/barcode-locate.html +256 -0
- package/barcode-locate.js +156 -0
- package/checkerboard-calibrate.html +167 -0
- package/checkerboard-calibrate.js +282 -0
- package/examples/label-crop-with-line-finder.json +239 -0
- package/golden-compare.html +713 -0
- package/golden-compare.js +1126 -0
- package/icons/checkerboard-calibrate.svg +8 -0
- package/icons/golden-compare.svg +6 -0
- package/label-crop.html +1178 -0
- package/label-crop.js +250 -0
- package/lib/align.js +867 -0
- package/lib/checkerboard.js +331 -0
- package/lib/compare.js +1338 -0
- package/lib/components.js +84 -0
- package/lib/dilate.js +65 -0
- package/lib/inspector.js +188 -0
- package/lib/inspectorCore.js +128 -0
- package/lib/inspectorWorker.js +40 -0
- package/lib/integral.js +76 -0
- package/lib/labelCrop.js +1461 -0
- package/lib/lineFinder.js +765 -0
- package/lib/localAlign.js +360 -0
- package/lib/locate.js +302 -0
- package/lib/nativeSeed.js +292 -0
- package/lib/parallel.js +428 -0
- package/lib/pool.js +250 -0
- package/lib/poolWorker.js +324 -0
- package/lib/scaleFile.js +83 -0
- package/lib/shared.js +87 -0
- package/lib/threshold.js +231 -0
- package/lib/transformFile.js +169 -0
- package/lib/warp.js +216 -0
- package/line-finder.html +1361 -0
- package/line-finder.js +280 -0
- package/package.json +73 -0
|
@@ -0,0 +1,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
|
+
};
|