@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,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
+ };