@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,331 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkerboard-grid detection and pixel-to-mm scale measurement, for the
|
|
3
|
+
* checkerboard-calibrate node.
|
|
4
|
+
*
|
|
5
|
+
* Scope: centroid/pitch-based measurement only - no sub-pixel corner
|
|
6
|
+
* refinement, no lens-distortion or perspective correction. Consistent
|
|
7
|
+
* with this project's existing translation-only, fixed-rig scope: this
|
|
8
|
+
* answers "has the scale/geometry drifted since the last calibration,"
|
|
9
|
+
* not general camera geometric calibration.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
"use strict";
|
|
13
|
+
|
|
14
|
+
const sharp = require("sharp");
|
|
15
|
+
const { otsuThreshold } = require("./threshold.js");
|
|
16
|
+
const { connectedComponents } = require("./components.js");
|
|
17
|
+
const { dilate } = require("./dilate.js");
|
|
18
|
+
|
|
19
|
+
const DEG = Math.PI / 180;
|
|
20
|
+
|
|
21
|
+
function median(values) {
|
|
22
|
+
// An empty list has no median: return undefined rather than NaN, so a
|
|
23
|
+
// caller can tell "no data" from "a real number" - NaN propagates
|
|
24
|
+
// silently into downstream math (mm/px scale = pitch/targetPitch)
|
|
25
|
+
// and reads as a successful measurement.
|
|
26
|
+
if (values.length === 0) return undefined;
|
|
27
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
28
|
+
const mid = Math.floor(sorted.length / 2);
|
|
29
|
+
return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The two shapes a checkerboard's dark squares can take, given
|
|
34
|
+
* `expectedCols` dark squares in its longest row.
|
|
35
|
+
*
|
|
36
|
+
* A board with an *even* number of square columns puts the same number of
|
|
37
|
+
* dark squares in every row: 16 columns gives 8 per row, whichever colour
|
|
38
|
+
* leads. An *odd* number cannot - 17 columns gives 9 dark squares in the
|
|
39
|
+
* rows that start dark and 8 in the rows that start light - so the rows
|
|
40
|
+
* alternate, beginning with whichever colour the top-left square is.
|
|
41
|
+
*
|
|
42
|
+
* `expectedCols` counts the longest row in both cases, so an even board
|
|
43
|
+
* still means exactly what it always did.
|
|
44
|
+
*/
|
|
45
|
+
function matchesShape(rows, expectedCols, expectedRows) {
|
|
46
|
+
if (rows.length !== expectedRows) return false;
|
|
47
|
+
if (rows.every((r) => r.length === expectedCols)) return true;
|
|
48
|
+
// A one-square-wide board has no shorter row to alternate with.
|
|
49
|
+
if (expectedCols < 2) return false;
|
|
50
|
+
const lengths = [expectedCols, expectedCols - 1];
|
|
51
|
+
const startsLong = rows[0].length === expectedCols;
|
|
52
|
+
if (!startsLong && rows[0].length !== lengths[1]) return false;
|
|
53
|
+
const offset = startsLong ? 0 : 1;
|
|
54
|
+
return rows.every((r, i) => r.length === lengths[(i + offset) % 2]);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Fewest dark squares any accepted shape can hold. The blob count is only
|
|
59
|
+
* a fast-fail before the real shape check, so sizing it against the
|
|
60
|
+
* smallest shape keeps an odd-column board from being turned away for
|
|
61
|
+
* "missing" squares it was never going to have.
|
|
62
|
+
*/
|
|
63
|
+
function minimumSquares(expectedCols, expectedRows) {
|
|
64
|
+
const uniform = expectedCols * expectedRows;
|
|
65
|
+
if (expectedCols < 2) return uniform;
|
|
66
|
+
const long = Math.ceil(expectedRows / 2);
|
|
67
|
+
const short = expectedRows - long;
|
|
68
|
+
return Math.min(
|
|
69
|
+
uniform,
|
|
70
|
+
long * expectedCols + short * (expectedCols - 1),
|
|
71
|
+
long * (expectedCols - 1) + short * expectedCols,
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Find a checkerboard's dark-square centroids and arrange them into a
|
|
77
|
+
* expectedRows x expectedCols grid (row-major, each row sorted by x).
|
|
78
|
+
* Rows of an odd-column board alternate between expectedCols and one
|
|
79
|
+
* fewer - see matchesShape(). Blob candidates are filtered adaptively
|
|
80
|
+
* (relative to the median found blob, not an absolute pixel size) so this
|
|
81
|
+
* works across different checkerboard prints/resolutions without extra
|
|
82
|
+
* config.
|
|
83
|
+
*
|
|
84
|
+
* The mask is eroded by 1px first, on a background-padded copy:
|
|
85
|
+
* - same-colour squares of a checkerboard touch diagonally at their
|
|
86
|
+
* corners, and any resampling or blur (the board sitting a couple of
|
|
87
|
+
* degrees off square, Bayer demosaic, JPEG, a soft lens) smears those
|
|
88
|
+
* corner contacts into a 1-2px bridge that 4-connected labelling then
|
|
89
|
+
* merges - a 3-degree rotation merged one pair of squares on this
|
|
90
|
+
* project's synthetic board, 8 degrees merged three, and the blob
|
|
91
|
+
* count dropped below the expected grid size. One 3x3 erosion severs
|
|
92
|
+
* every bridge while shrinking each square symmetrically.
|
|
93
|
+
* - the padding matters because the erosion is border-clamped: a square
|
|
94
|
+
* touching the image edge erodes on its inner sides only, shifting its
|
|
95
|
+
* centroid by half a pixel and corrupting the pitch by ~0.3%. On the
|
|
96
|
+
* padded copy every square is interior, so centroids (and therefore
|
|
97
|
+
* the pitch measurement) stay exact.
|
|
98
|
+
*
|
|
99
|
+
* The centroids are then de-rotated (a sweep over plausible board
|
|
100
|
+
* rotations, smallest |angle| first) before row clustering, because the
|
|
101
|
+
* y-gap row grouping only works when rows are horizontal: at 8 degrees
|
|
102
|
+
* the y-drift across a 640px row is ~90px, larger than the gap between
|
|
103
|
+
* rows, and squares from neighbouring rows interleave into garbage rows.
|
|
104
|
+
* Distances are rotation-invariant, so the de-rotated centroids measure
|
|
105
|
+
* the true pitch exactly even when the sweep's angle is off by a fraction
|
|
106
|
+
* of a degree.
|
|
107
|
+
*/
|
|
108
|
+
function detectGrid(mask, width, height, expectedCols, expectedRows) {
|
|
109
|
+
const expectedCount = minimumSquares(expectedCols, expectedRows);
|
|
110
|
+
const p = 1;
|
|
111
|
+
const pw = width + 2 * p;
|
|
112
|
+
const ph = height + 2 * p;
|
|
113
|
+
const padded = new Uint8Array(pw * ph);
|
|
114
|
+
for (let y = 0; y < height; y++) {
|
|
115
|
+
padded.set(mask.subarray(y * width, (y + 1) * width), (y + p) * pw + p);
|
|
116
|
+
}
|
|
117
|
+
const inv = new Uint8Array(pw * ph);
|
|
118
|
+
for (let i = 0; i < inv.length; i++) inv[i] = padded[i] ? 0 : 1;
|
|
119
|
+
const dilated = dilate(inv, pw, ph, 1);
|
|
120
|
+
for (let i = 0; i < inv.length; i++) padded[i] = dilated[i] ? 0 : 1;
|
|
121
|
+
|
|
122
|
+
const rawBlobs = connectedComponents(padded, pw, ph, { minArea: 4 });
|
|
123
|
+
if (rawBlobs.length < expectedCount) {
|
|
124
|
+
return {
|
|
125
|
+
detected: false,
|
|
126
|
+
reason: `found only ${rawBlobs.length} candidate blob(s), need at least ${expectedCount}`,
|
|
127
|
+
centroids: [],
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const areas = rawBlobs.map((b) => b.area);
|
|
132
|
+
const medianArea = median(areas);
|
|
133
|
+
const candidates = rawBlobs.filter((b) => {
|
|
134
|
+
const w = b.x1 - b.x0;
|
|
135
|
+
const h = b.y1 - b.y0;
|
|
136
|
+
const aspect = w / h;
|
|
137
|
+
return (
|
|
138
|
+
b.area > medianArea * 0.3 &&
|
|
139
|
+
b.area < medianArea * 3 &&
|
|
140
|
+
aspect > 0.5 &&
|
|
141
|
+
aspect < 2.0
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
if (candidates.length < expectedCount) {
|
|
145
|
+
return {
|
|
146
|
+
detected: false,
|
|
147
|
+
reason: `found ${candidates.length} plausible square(s) after filtering, need ${expectedCount}`,
|
|
148
|
+
centroids: [],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const cx = width / 2 + p;
|
|
153
|
+
const cy = height / 2 + p;
|
|
154
|
+
const medianHeight = median(candidates.map((b) => b.y1 - b.y0));
|
|
155
|
+
// Sweep rotations outward from 0, cluster each de-rotated set into rows,
|
|
156
|
+
// and require the exact expected shape. The shape check alone is not
|
|
157
|
+
// enough: at residual rotations up to ~15 degrees the within-row y-drift
|
|
158
|
+
// stays below the gap threshold, so several wrong angles also arrange
|
|
159
|
+
// into a clean grid. The true angle is the one that makes the rows
|
|
160
|
+
// horizontal - score every passing candidate by within-row y-variance
|
|
161
|
+
// and keep the minimum (a least-squares fit to the grid rotation in
|
|
162
|
+
// disguise). A residual of under half a degree costs a fraction of a
|
|
163
|
+
// pixel of pitch, so the integer-degree sweep needs no refinement.
|
|
164
|
+
let best = null;
|
|
165
|
+
for (let deg = 0; deg <= 15; deg++) {
|
|
166
|
+
const signs = deg === 0 ? [0] : [1, -1];
|
|
167
|
+
for (const sign of signs) {
|
|
168
|
+
const a = sign * deg * DEG;
|
|
169
|
+
const cosT = Math.cos(a);
|
|
170
|
+
const sinT = Math.sin(a);
|
|
171
|
+
const pts = candidates.map((b) => ({
|
|
172
|
+
x: cx + (b.cx - cx) * cosT - (b.cy - cy) * sinT,
|
|
173
|
+
y: cy + (b.cx - cx) * sinT + (b.cy - cy) * cosT,
|
|
174
|
+
}));
|
|
175
|
+
const rows = clusterRows(pts, medianHeight);
|
|
176
|
+
if (!matchesShape(rows, expectedCols, expectedRows)) continue;
|
|
177
|
+
let variance = 0;
|
|
178
|
+
for (const row of rows) {
|
|
179
|
+
const meanY = row.reduce((s, q) => s + q.y, 0) / row.length;
|
|
180
|
+
for (const q of row) variance += (q.y - meanY) * (q.y - meanY);
|
|
181
|
+
}
|
|
182
|
+
if (!best || variance < best.variance) {
|
|
183
|
+
best = { rows, variance };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (best) return { detected: true, centroids: best.rows };
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
detected: false,
|
|
191
|
+
reason:
|
|
192
|
+
`grid shape mismatch: no rotation of ${candidates.length} blob(s) arranges into ` +
|
|
193
|
+
`${expectedRows}x${expectedCols}` +
|
|
194
|
+
(expectedCols >= 2
|
|
195
|
+
? ` (or ${expectedRows} rows alternating ${expectedCols}/${expectedCols - 1})`
|
|
196
|
+
: ""),
|
|
197
|
+
centroids: [],
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Sort centroids by y and split into rows wherever the gap to the running
|
|
202
|
+
// row average exceeds 0.6x the median square height; then sort each row by
|
|
203
|
+
// x. Callers de-rotate first, so rows are horizontal here.
|
|
204
|
+
function clusterRows(pts, medianHeight) {
|
|
205
|
+
const byY = [...pts].sort((a, b) => a.y - b.y);
|
|
206
|
+
const rows = [];
|
|
207
|
+
let currentRow = [byY[0]];
|
|
208
|
+
for (let i = 1; i < byY.length; i++) {
|
|
209
|
+
const rowAvgY =
|
|
210
|
+
currentRow.reduce((sum, b) => sum + b.y, 0) / currentRow.length;
|
|
211
|
+
if (byY[i].y - rowAvgY > medianHeight * 0.6) {
|
|
212
|
+
rows.push(currentRow);
|
|
213
|
+
currentRow = [byY[i]];
|
|
214
|
+
} else {
|
|
215
|
+
currentRow.push(byY[i]);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
rows.push(currentRow);
|
|
219
|
+
for (const row of rows) row.sort((a, b) => a.x - b.x);
|
|
220
|
+
return rows;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Median pixel pitch between adjacent same-color squares, row-wise (x)
|
|
225
|
+
* and column-wise (y). Median rather than mean so one stray misdetection
|
|
226
|
+
* doesn't skew the measurement.
|
|
227
|
+
*
|
|
228
|
+
* Only same-color (dark) squares are detected as blobs, so adjacent
|
|
229
|
+
* *rows* of a real checkerboard are horizontally staggered by one
|
|
230
|
+
* square - detectGrid()'s row r and row r+1 do NOT share x positions.
|
|
231
|
+
* Rows r and r+2 do (the pattern repeats every 2 rows), so the y-pitch
|
|
232
|
+
* is measured two rows apart to match physical squares up - giving a
|
|
233
|
+
* quantity directly comparable to the x-pitch (both "distance between
|
|
234
|
+
* same-color adjacent squares", i.e. two square-widths).
|
|
235
|
+
*/
|
|
236
|
+
function measurePitch(centroids) {
|
|
237
|
+
const xDiffs = [];
|
|
238
|
+
for (const row of centroids) {
|
|
239
|
+
for (let i = 1; i < row.length; i++) xDiffs.push(row[i].x - row[i - 1].x);
|
|
240
|
+
}
|
|
241
|
+
// Only even-indexed rows are stepped through, and on an odd-column board
|
|
242
|
+
// those all share row 0's length (the alternation has period 2), so this
|
|
243
|
+
// bound stays inside every row it indexes.
|
|
244
|
+
const cols = centroids[0].length;
|
|
245
|
+
const yDiffs = [];
|
|
246
|
+
for (let c = 0; c < cols; c++) {
|
|
247
|
+
for (let r = 2; r < centroids.length; r += 2) {
|
|
248
|
+
yDiffs.push(centroids[r][c].y - centroids[r - 2][c].y);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
return { pitchXPx: median(xDiffs), pitchYPx: median(yDiffs) };
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function computeScale(pitchXPx, pitchYPx, targetPitchMm) {
|
|
255
|
+
const avgPitchPx = (pitchXPx + pitchYPx) / 2;
|
|
256
|
+
return targetPitchMm / avgPitchPx;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* End-to-end: decode -> Otsu threshold -> detect grid -> measure pitch ->
|
|
261
|
+
* mm/px scale, at the image's native (undownscaled) resolution for
|
|
262
|
+
* maximum measurement precision.
|
|
263
|
+
*/
|
|
264
|
+
async function measureCheckerboard(buffer, cfg) {
|
|
265
|
+
const { data, info } = await sharp(buffer)
|
|
266
|
+
.removeAlpha()
|
|
267
|
+
.grayscale()
|
|
268
|
+
.raw()
|
|
269
|
+
.toBuffer({ resolveWithObject: true });
|
|
270
|
+
const { width, height } = info;
|
|
271
|
+
|
|
272
|
+
const threshold = otsuThreshold(data);
|
|
273
|
+
// Otsu's class B (accumulated into weightB above) is "value <= threshold",
|
|
274
|
+
// so foreground must be selected inclusively here - a strict "<" would
|
|
275
|
+
// misclassify every pixel when the histogram is a two-spike extreme
|
|
276
|
+
// (e.g. a clean synthetic black/white checkerboard with threshold 0).
|
|
277
|
+
const mask = new Uint8Array(width * height);
|
|
278
|
+
for (let i = 0; i < data.length; i++) mask[i] = data[i] <= threshold ? 1 : 0;
|
|
279
|
+
|
|
280
|
+
const grid = detectGrid(
|
|
281
|
+
mask,
|
|
282
|
+
width,
|
|
283
|
+
height,
|
|
284
|
+
cfg.checkerboardCols,
|
|
285
|
+
cfg.checkerboardRows,
|
|
286
|
+
);
|
|
287
|
+
if (!grid.detected) {
|
|
288
|
+
return { detected: false, reason: grid.reason, width, height, threshold };
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
const { pitchXPx, pitchYPx } = measurePitch(grid.centroids);
|
|
292
|
+
// A grid that is too thin to measure (fewer than 3 rows, or fewer
|
|
293
|
+
// than 2 columns, leaves one of the diff lists empty and its pitch
|
|
294
|
+
// undefined) must not report detected:true - a NaN mm/px scale would
|
|
295
|
+
// be saved as a baseline and silently disable the mm position gate.
|
|
296
|
+
if (
|
|
297
|
+
!(pitchXPx > 0) ||
|
|
298
|
+
!Number.isFinite(pitchXPx) ||
|
|
299
|
+
!(pitchYPx > 0) ||
|
|
300
|
+
!Number.isFinite(pitchYPx)
|
|
301
|
+
) {
|
|
302
|
+
return {
|
|
303
|
+
detected: false,
|
|
304
|
+
reason:
|
|
305
|
+
`could not measure a finite pitch from the detected grid ` +
|
|
306
|
+
`(pitchX=${pitchXPx}, pitchY=${pitchYPx}) - use a grid with at least 2 ` +
|
|
307
|
+
`columns and 3 rows of dark squares`,
|
|
308
|
+
width,
|
|
309
|
+
height,
|
|
310
|
+
threshold,
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
const mmPerPixel = computeScale(pitchXPx, pitchYPx, cfg.targetPitchMm);
|
|
314
|
+
return {
|
|
315
|
+
detected: true,
|
|
316
|
+
width,
|
|
317
|
+
height,
|
|
318
|
+
threshold,
|
|
319
|
+
pitchXPx,
|
|
320
|
+
pitchYPx,
|
|
321
|
+
mmPerPixel,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
module.exports = {
|
|
326
|
+
otsuThreshold,
|
|
327
|
+
detectGrid,
|
|
328
|
+
measurePitch,
|
|
329
|
+
computeScale,
|
|
330
|
+
measureCheckerboard,
|
|
331
|
+
};
|