@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,1461 @@
1
+ /**
2
+ * Deskew-and-crop a physical label out of a camera frame, using the
3
+ * @rosepetal/node-red-contrib-image-tools native OpenCV engine as the
4
+ * pixel worker and keeping only low-resolution analysis in JS.
5
+ *
6
+ * Why this shape (see ARCHITECTURE.md "label-crop"):
7
+ *
8
+ * - The heavy stages are native: decode, resize, Otsu, rotate, crop and
9
+ * final encoding all run inside the native C++ addon. JS only
10
+ * touches the <= maxEdge detection mask (connected components + a
11
+ * convex-hull minimum-area rectangle), which is a few hundred kilobytes.
12
+ * - Encoded input is decoded exactly once (colorConvert(buffer, RGB)),
13
+ * and the output preserves the decoded channel layout through the
14
+ * rotate/crop chain.
15
+ * - Only a tight ROI around the label is ever rotated. Rotating the
16
+ * whole frame would cost the full canvas even for a small label, and
17
+ * the engine's rotate pads the canvas it is given - padding a small
18
+ * ROI is free, padding a 23MP frame is not.
19
+ * - Rotation uses the detected boundary angle with OpenCV's image-coordinate
20
+ * convention; the label rectangle becomes axis-aligned, so the final
21
+ * crop is the centred w x h rect - exactly tight to the label, no
22
+ * perspective correction, no content-aware decisions.
23
+ * - Confidence gates keep bad evidence from being trusted: a blob that
24
+ * is too small, touches the frame border, is not rectangular, is not
25
+ * dominant, or violates an optional expected aspect ratio is a
26
+ * **miss** (the caller keeps the original frame), never a wrong crop.
27
+ * - The engine is a setup dependency, not a fallback: if the bridge
28
+ * cannot be loaded this module throws, so the node can report a setup
29
+ * error instead of silently passing every frame through.
30
+ *
31
+ * The engine interface is the promisified cpp-bridge of
32
+ * @rosepetal/node-red-contrib-image-tools:
33
+ *
34
+ * colorConvert(image, targetColorSpace, [fmt], [q], [pngOpt]) -> {image, timing}
35
+ * resize(image, wMode, wVal, hMode, hVal, [fmt], [q], [pngOpt]) -> {image, timing}
36
+ * filter(image, type, kernel, intensity, [fmt], [q], [pngOpt]) -> {image, timing}
37
+ * crop(image, x, y, w, h, normalized, [fmt], [q], [pngOpt]) -> {image, timing}
38
+ * rotate(image, angleDeg, [padColor], [fmt], [q], [pngOpt]) -> {image, timing}
39
+ *
40
+ * with `image` either an encoded Buffer (decoded by the engine) or a raw
41
+ * { data, width, height, channels, colorSpace, dtype } object.
42
+ */
43
+
44
+ const { performance } = require("node:perf_hooks");
45
+ const { findLine, rectFromLines } = require("./lineFinder.js");
46
+
47
+ // The same optional engine path lib/nativeSeed.js uses; the engine ships
48
+ // prebuilt binaries for Linux x64/arm64, Alpine x64, and macOS arm64.
49
+ const ENGINE_PATH =
50
+ "@rosepetal/node-red-contrib-image-tools/node-red-contrib-image-tools/lib/cpp-bridge.js";
51
+
52
+ const ENGINE_UNAVAILABLE_PREFIX = "label-crop: OpenCV engine unavailable: ";
53
+ const DEG = Math.PI / 180;
54
+
55
+ const OUTPUT_FORMATS = ["raw", "jpg", "png", "webp"];
56
+ const POLARITIES = ["auto", "light", "dark"];
57
+
58
+ const BOUNDARY_MODES = ["blob", "calipers"];
59
+
60
+ const DEFAULTS = {
61
+ // "blob" thresholds the whole frame and takes the dominant region;
62
+ // "calipers" fits the boundary from four drawn line-finder regions,
63
+ // for a label whose own edge is fainter than the print inside it
64
+ boundaryMode: "blob",
65
+ // calipers mode only: { left, right, top, bottom }, each an object of
66
+ // { x, y, width, height, angleDeg? } plus any lib/lineFinder.js option
67
+ edgeRegions: null,
68
+ // long edge of the detection copy, px
69
+ maxEdge: 640,
70
+ // how the label relates to the background: dark label on light
71
+ // background = "dark", light label on dark background = "light"
72
+ polarity: "auto",
73
+ // component must cover at least this fraction of the frame
74
+ minAreaFraction: 0.05,
75
+ // blob area must fill at least this fraction of its exterior rectangle
76
+ minRectangularity: 0.4,
77
+ // fraction of the blob's bbox edges allowed to touch the frame border;
78
+ // 0.5 permits a label clipped by two opposite frame edges while still
79
+ // rejecting a full-frame background component
80
+ maxBorderContact: 0.5,
81
+ // reject a foreground region that implausibly consumes nearly the frame
82
+ maxAreaFraction: 0.9,
83
+ // the best blob must be at least this many times the second-best
84
+ minDominance: 1.5,
85
+ // overall gate; below this the detection is a miss
86
+ minConfidence: 0.4,
87
+ // optional expected w/h of the label in the deskewed frame
88
+ aspectRatio: null,
89
+ aspectTolerance: 0.15,
90
+ // optional expected label area as a fraction of the frame (measured by
91
+ // drawing over a representative sample); blank = no size gate
92
+ expectedSizeFraction: null,
93
+ sizeTolerance: 0.2,
94
+ // extra ring around the label bbox before rotation, as a fraction of
95
+ // the label's long dimension (keeps the rotate from sampling past the
96
+ // ROI boundary); the ring is cropped away by the final tight crop
97
+ cropMargin: 0.02,
98
+ // below this angle the rotation is skipped and the label is cropped
99
+ // directly from the frame
100
+ minRotateAngleDeg: 0.5,
101
+ outputFormat: "raw",
102
+ outputQuality: 90,
103
+ pngOptimize: false,
104
+ padColor: "#000000",
105
+ };
106
+
107
+ // ---- engine loading ----------------------------------------------------
108
+
109
+ // undefined = not yet attempted, null = load failed, object = loaded
110
+ let bridge;
111
+ let bridgeError = null;
112
+
113
+ /**
114
+ * Lazily require the native cpp-bridge. Throws (setup error) when the
115
+ * engine is unavailable - the node treats that as a configuration problem,
116
+ * not as "no label found".
117
+ */
118
+ function getBridge() {
119
+ if (bridge === undefined) {
120
+ bridgeError = null;
121
+ try {
122
+ // eslint-disable-next-line global-require
123
+ bridge = require(ENGINE_PATH);
124
+ if (!bridge || typeof bridge.resize !== "function") {
125
+ bridge = null;
126
+ bridgeError = new Error(
127
+ "the engine loaded but exposes no resize() - is @rosepetal/" +
128
+ "node-red-contrib-image-tools the expected package?",
129
+ );
130
+ }
131
+ } catch (err) {
132
+ bridge = null;
133
+ bridgeError = err;
134
+ }
135
+ }
136
+ if (!bridge) {
137
+ throw new Error(
138
+ ENGINE_UNAVAILABLE_PREFIX +
139
+ (bridgeError ? bridgeError.message : "engine not installed"),
140
+ );
141
+ }
142
+ return bridge;
143
+ }
144
+
145
+ /** True when the native engine can be loaded (or was injected). */
146
+ function available() {
147
+ try {
148
+ return getBridge() !== null;
149
+ } catch {
150
+ return false;
151
+ }
152
+ }
153
+
154
+ /** Test seam: replace the lazily loaded engine (or force the unavailable
155
+ * state with null). */
156
+ function _setBridge(fake) {
157
+ bridge = fake;
158
+ bridgeError = null;
159
+ }
160
+
161
+ /** Test seam: forget the cached loader state. */
162
+ function _resetBridge() {
163
+ bridge = undefined;
164
+ bridgeError = null;
165
+ }
166
+
167
+ // ---- config -------------------------------------------------------------
168
+
169
+ function normalizeCfg(cfg = {}) {
170
+ const out = { ...DEFAULTS };
171
+ for (const k of Object.keys(DEFAULTS)) {
172
+ const v = cfg[k];
173
+ if (v !== undefined && v !== null && v !== "") out[k] = v;
174
+ }
175
+ const finiteOr = (value, fallback) => {
176
+ const n = Number(value);
177
+ return Number.isFinite(n) ? n : fallback;
178
+ };
179
+ if (!BOUNDARY_MODES.includes(out.boundaryMode)) {
180
+ out.boundaryMode = DEFAULTS.boundaryMode;
181
+ }
182
+ if (out.edgeRegions != null && typeof out.edgeRegions !== "object") {
183
+ out.edgeRegions = null;
184
+ }
185
+ if (!POLARITIES.includes(out.polarity)) out.polarity = DEFAULTS.polarity;
186
+ if (!OUTPUT_FORMATS.includes(out.outputFormat))
187
+ out.outputFormat = DEFAULTS.outputFormat;
188
+ out.maxEdge = Math.max(
189
+ 32,
190
+ Math.min(4096, Math.round(finiteOr(out.maxEdge, DEFAULTS.maxEdge))),
191
+ );
192
+ out.minAreaFraction = Math.max(
193
+ 0.001,
194
+ Math.min(0.9, finiteOr(out.minAreaFraction, DEFAULTS.minAreaFraction)),
195
+ );
196
+ out.maxAreaFraction = Math.max(
197
+ out.minAreaFraction,
198
+ Math.min(0.999, finiteOr(out.maxAreaFraction, DEFAULTS.maxAreaFraction)),
199
+ );
200
+ out.minRectangularity = Math.max(
201
+ 0.05,
202
+ Math.min(1, finiteOr(out.minRectangularity, DEFAULTS.minRectangularity)),
203
+ );
204
+ out.maxBorderContact = Math.max(
205
+ 0,
206
+ Math.min(1, finiteOr(out.maxBorderContact, DEFAULTS.maxBorderContact)),
207
+ );
208
+ out.minDominance = Math.max(
209
+ 1.01,
210
+ finiteOr(out.minDominance, DEFAULTS.minDominance),
211
+ );
212
+ out.minConfidence = Math.max(
213
+ 0.01,
214
+ Math.min(1, finiteOr(out.minConfidence, DEFAULTS.minConfidence)),
215
+ );
216
+ out.cropMargin = Math.max(
217
+ 0,
218
+ Math.min(0.25, finiteOr(out.cropMargin, DEFAULTS.cropMargin)),
219
+ );
220
+ out.minRotateAngleDeg = Math.max(
221
+ 0,
222
+ Math.min(10, finiteOr(out.minRotateAngleDeg, DEFAULTS.minRotateAngleDeg)),
223
+ );
224
+ out.aspectTolerance = Math.max(
225
+ 0.01,
226
+ Math.min(1, finiteOr(out.aspectTolerance, DEFAULTS.aspectTolerance)),
227
+ );
228
+ out.sizeTolerance = Math.max(
229
+ 0.01,
230
+ Math.min(1, finiteOr(out.sizeTolerance, DEFAULTS.sizeTolerance)),
231
+ );
232
+ out.outputQuality = Math.max(
233
+ 1,
234
+ Math.min(
235
+ 100,
236
+ Math.round(finiteOr(out.outputQuality, DEFAULTS.outputQuality)),
237
+ ),
238
+ );
239
+ out.pngOptimize = !!out.pngOptimize;
240
+ if (out.aspectRatio != null && out.aspectRatio !== "") {
241
+ const a = Number(out.aspectRatio);
242
+ out.aspectRatio = Number.isFinite(a) && a > 0 ? a : null;
243
+ }
244
+ if (out.expectedSizeFraction != null && out.expectedSizeFraction !== "") {
245
+ const s = Number(out.expectedSizeFraction);
246
+ out.expectedSizeFraction = Number.isFinite(s) && s > 0 && s < 1 ? s : null;
247
+ }
248
+ return out;
249
+ }
250
+
251
+ // ---- input handling -----------------------------------------------------
252
+
253
+ function isRawImage(v) {
254
+ return (
255
+ v !== null &&
256
+ typeof v === "object" &&
257
+ typeof v.width === "number" &&
258
+ typeof v.height === "number" &&
259
+ (ArrayBuffer.isView(v.data) || Buffer.isBuffer(v.data))
260
+ );
261
+ }
262
+
263
+ /** Copy a raw descriptor into the engine's canonical shape without copying
264
+ * its pixels: the data view is shared, not cloned. */
265
+ function normalizeRaw(v) {
266
+ const data = Buffer.isBuffer(v.data)
267
+ ? v.data
268
+ : Buffer.from(v.data.buffer, v.data.byteOffset, v.data.byteLength);
269
+ const width = Number(v.width);
270
+ const height = Number(v.height);
271
+ const channels = Number(v.channels || (v.colorSpace === "GRAY" ? 1 : 3));
272
+ const dtype = v.dtype || "uint8";
273
+ if (
274
+ !Number.isInteger(width) ||
275
+ width <= 0 ||
276
+ !Number.isInteger(height) ||
277
+ height <= 0
278
+ ) {
279
+ throw new Error("label-crop: raw width and height must be positive integers");
280
+ }
281
+ if (![1, 3, 4].includes(channels)) {
282
+ throw new Error("label-crop: raw channels must be 1, 3, or 4");
283
+ }
284
+ if (dtype !== "uint8") {
285
+ throw new Error("label-crop: raw dtype must be uint8");
286
+ }
287
+ const expectedBytes = width * height * channels;
288
+ if (!Number.isSafeInteger(expectedBytes) || data.byteLength < expectedBytes) {
289
+ throw new Error(
290
+ `label-crop: raw data is shorter than ${width}x${height}x${channels}`,
291
+ );
292
+ }
293
+ let defaultColorSpace = "RGB";
294
+ if (channels === 1) defaultColorSpace = "GRAY";
295
+ else if (channels === 4) defaultColorSpace = "RGBA";
296
+ return {
297
+ data,
298
+ width,
299
+ height,
300
+ channels,
301
+ colorSpace: v.colorSpace || defaultColorSpace,
302
+ dtype,
303
+ };
304
+ }
305
+
306
+ // ---- low-resolution analysis (the only JS pixel work) -------------------
307
+
308
+ function connectedComponentsStats(mask, width, height) {
309
+ const n = width * height;
310
+ const visited = new Uint8Array(n);
311
+ const stack = new Int32Array(n);
312
+ const comps = [];
313
+ for (let start = 0; start < n; start++) {
314
+ if (!mask[start] || visited[start]) continue;
315
+ let sp = 0;
316
+ visited[start] = 1;
317
+ stack[sp++] = start;
318
+ let area = 0;
319
+ let minX = width;
320
+ let minY = height;
321
+ let maxX = -1;
322
+ let maxY = -1;
323
+ let sumX = 0;
324
+ let sumY = 0;
325
+ const seed = start;
326
+ while (sp > 0) {
327
+ const idx = stack[--sp];
328
+ const x = idx % width;
329
+ const y = (idx / width) | 0;
330
+ area++;
331
+ sumX += x;
332
+ sumY += y;
333
+ if (x < minX) minX = x;
334
+ if (x > maxX) maxX = x;
335
+ if (y < minY) minY = y;
336
+ if (y > maxY) maxY = y;
337
+ if (x > 0 && mask[idx - 1] && !visited[idx - 1]) {
338
+ visited[idx - 1] = 1;
339
+ stack[sp++] = idx - 1;
340
+ }
341
+ if (x < width - 1 && mask[idx + 1] && !visited[idx + 1]) {
342
+ visited[idx + 1] = 1;
343
+ stack[sp++] = idx + 1;
344
+ }
345
+ if (y > 0 && mask[idx - width] && !visited[idx - width]) {
346
+ visited[idx - width] = 1;
347
+ stack[sp++] = idx - width;
348
+ }
349
+ if (y < height - 1 && mask[idx + width] && !visited[idx + width]) {
350
+ visited[idx + width] = 1;
351
+ stack[sp++] = idx + width;
352
+ }
353
+ }
354
+ comps.push({
355
+ area,
356
+ cx: sumX / area,
357
+ cy: sumY / area,
358
+ x0: minX,
359
+ y0: minY,
360
+ x1: maxX + 1,
361
+ y1: maxY + 1,
362
+ seed,
363
+ });
364
+ }
365
+ return comps;
366
+ }
367
+
368
+ function collectPixels(mask, width, height, seed) {
369
+ const n = width * height;
370
+ const visited = new Uint8Array(n);
371
+ const stack = new Int32Array(n);
372
+ const px = new Int32Array(n);
373
+ let sp = 0;
374
+ let count = 0;
375
+ visited[seed] = 1;
376
+ stack[sp++] = seed;
377
+ while (sp > 0) {
378
+ const idx = stack[--sp];
379
+ px[count++] = idx;
380
+ const x = idx % width;
381
+ if (x > 0 && mask[idx - 1] && !visited[idx - 1]) {
382
+ visited[idx - 1] = 1;
383
+ stack[sp++] = idx - 1;
384
+ }
385
+ if (x < width - 1 && mask[idx + 1] && !visited[idx + 1]) {
386
+ visited[idx + 1] = 1;
387
+ stack[sp++] = idx + 1;
388
+ }
389
+ if (idx >= width && mask[idx - width] && !visited[idx - width]) {
390
+ visited[idx - width] = 1;
391
+ stack[sp++] = idx - width;
392
+ }
393
+ if (idx < n - width && mask[idx + width] && !visited[idx + width]) {
394
+ visited[idx + width] = 1;
395
+ stack[sp++] = idx + width;
396
+ }
397
+ }
398
+ return px.subarray(0, count);
399
+ }
400
+
401
+ /** Exterior points are enough for the physical boundary and deliberately
402
+ * ignore printed holes, whose asymmetric mass badly biases PCA moments. */
403
+ function exteriorPoints(px, width, height) {
404
+ const left = new Int32Array(height);
405
+ const right = new Int32Array(height);
406
+ left.fill(width);
407
+ right.fill(-1);
408
+ for (let k = 0; k < px.length; k++) {
409
+ const idx = px[k];
410
+ const x = idx % width;
411
+ const y = (idx / width) | 0;
412
+ if (x < left[y]) left[y] = x;
413
+ if (x > right[y]) right[y] = x;
414
+ }
415
+ const points = [];
416
+ for (let y = 0; y < height; y++) {
417
+ if (right[y] < 0) continue;
418
+ points.push({ x: left[y], y });
419
+ if (right[y] !== left[y]) points.push({ x: right[y], y });
420
+ }
421
+ return points;
422
+ }
423
+
424
+ function convexHull(points) {
425
+ if (points.length <= 2) return points;
426
+ points.sort((a, b) => a.x - b.x || a.y - b.y);
427
+ const cross = (o, a, b) =>
428
+ (a.x - o.x) * (b.y - o.y) - (a.y - o.y) * (b.x - o.x);
429
+ const lower = [];
430
+ for (const point of points) {
431
+ while (lower.length >= 2 && cross(lower.at(-2), lower.at(-1), point) <= 0) {
432
+ lower.pop();
433
+ }
434
+ lower.push(point);
435
+ }
436
+ const upper = [];
437
+ for (let i = points.length - 1; i >= 0; i--) {
438
+ const point = points[i];
439
+ while (upper.length >= 2 && cross(upper.at(-2), upper.at(-1), point) <= 0) {
440
+ upper.pop();
441
+ }
442
+ upper.push(point);
443
+ }
444
+ lower.pop();
445
+ upper.pop();
446
+ return lower.concat(upper);
447
+ }
448
+
449
+ /** Minimum-area rectangle around the component's convex exterior. This is
450
+ * the contour/minAreaRect equivalent for the small JS mask. */
451
+ function exteriorRect(px, width, height) {
452
+ const hull = convexHull(exteriorPoints(px, width, height));
453
+ if (hull.length < 2) return null;
454
+ let bestTheta = 0;
455
+ let bestArea = Infinity;
456
+ for (let i = 0; i < hull.length; i++) {
457
+ const a = hull[i];
458
+ const b = hull[(i + 1) % hull.length];
459
+ const theta = Math.atan2(b.y - a.y, b.x - a.x);
460
+ const ca = Math.cos(theta);
461
+ const sa = Math.sin(theta);
462
+ let minU = Infinity;
463
+ let maxU = -Infinity;
464
+ let minV = Infinity;
465
+ let maxV = -Infinity;
466
+ for (const point of hull) {
467
+ const u = point.x * ca + point.y * sa;
468
+ const v = -point.x * sa + point.y * ca;
469
+ if (u < minU) minU = u;
470
+ if (u > maxU) maxU = u;
471
+ if (v < minV) minV = v;
472
+ if (v > maxV) maxV = v;
473
+ }
474
+ const area = (maxU - minU) * (maxV - minV);
475
+ if (area < bestArea) {
476
+ bestArea = area;
477
+ bestTheta = theta;
478
+ }
479
+ }
480
+
481
+ while (bestTheta > Math.PI / 4) bestTheta -= Math.PI / 2;
482
+ while (bestTheta < -Math.PI / 4) bestTheta += Math.PI / 2;
483
+ const ca = Math.cos(bestTheta);
484
+ const sa = Math.sin(bestTheta);
485
+ let minU = Infinity;
486
+ let maxU = -Infinity;
487
+ let minV = Infinity;
488
+ let maxV = -Infinity;
489
+ for (const point of hull) {
490
+ const u = point.x * ca + point.y * sa;
491
+ const v = -point.x * sa + point.y * ca;
492
+ if (u < minU) minU = u;
493
+ if (u > maxU) maxU = u;
494
+ if (v < minV) minV = v;
495
+ if (v > maxV) maxV = v;
496
+ }
497
+ const centerU = (minU + maxU) / 2;
498
+ const centerV = (minV + maxV) / 2;
499
+ return {
500
+ cx: centerU * ca - centerV * sa,
501
+ cy: centerU * sa + centerV * ca,
502
+ theta: bestTheta,
503
+ w: maxU - minU,
504
+ h: maxV - minV,
505
+ area: px.length,
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Analyse a foreground mask (0/255, 1 byte per pixel) and decide whether
511
+ * it contains one dominant, rectangle-like blob. Pure JS, exported for
512
+ * tests: everything below the maxEdge detection copy runs here.
513
+ *
514
+ * Returns { detected, reason, confidence, polarity (set by caller),
515
+ * center, angleDeg, width, height, corners, areaFraction,
516
+ * rectangularity, dominance, borderContact }.
517
+ */
518
+ function analyzeMask(mask, width, height, cfg) {
519
+ const opts = normalizeCfg(cfg);
520
+ const frameArea = width * height;
521
+ const minArea = Math.max(1, Math.round(opts.minAreaFraction * frameArea));
522
+ const maxArea = Math.max(
523
+ minArea,
524
+ Math.round(opts.maxAreaFraction * frameArea),
525
+ );
526
+ const comps = connectedComponentsStats(mask, width, height);
527
+
528
+ if (comps.length === 0) {
529
+ return { detected: false, reason: "no-component", confidence: 0 };
530
+ }
531
+ const largeEnough = comps.filter((c) => c.area >= minArea);
532
+ if (largeEnough.length === 0) {
533
+ return { detected: false, reason: "too-small", confidence: 0 };
534
+ }
535
+ const candidates = largeEnough.filter((c) => c.area <= maxArea);
536
+ if (candidates.length === 0) {
537
+ return { detected: false, reason: "too-large", confidence: 0 };
538
+ }
539
+ candidates.sort((a, b) => b.area - a.area);
540
+ const best = candidates[0];
541
+ const second = candidates[1];
542
+ const dominance = second ? best.area / second.area : Infinity;
543
+ if (dominance < opts.minDominance) {
544
+ return { detected: false, reason: "ambiguous", confidence: 0 };
545
+ }
546
+
547
+ const touches =
548
+ (best.x0 === 0 ? 1 : 0) +
549
+ (best.y0 === 0 ? 1 : 0) +
550
+ (best.x1 === width ? 1 : 0) +
551
+ (best.y1 === height ? 1 : 0);
552
+ const borderContact = touches / 4;
553
+ if (borderContact > opts.maxBorderContact) {
554
+ return { detected: false, reason: "border-contact", confidence: 0 };
555
+ }
556
+
557
+ const px = collectPixels(mask, width, height, best.seed);
558
+ const rect = exteriorRect(px, width, height);
559
+ if (!rect || rect.w <= 0 || rect.h <= 0) {
560
+ return { detected: false, reason: "degenerate-boundary", confidence: 0 };
561
+ }
562
+ const areaFraction = best.area / frameArea;
563
+ const rectangularity = rect.area / (rect.w * rect.h);
564
+ if (rectangularity < opts.minRectangularity) {
565
+ return { detected: false, reason: "low-rectangularity", confidence: 0 };
566
+ }
567
+
568
+ let aspectMismatch = 0;
569
+ if (opts.aspectRatio != null) {
570
+ const aspect = rect.w / rect.h;
571
+ aspectMismatch = Math.abs(Math.log(aspect / opts.aspectRatio));
572
+ if (aspectMismatch > opts.aspectTolerance) {
573
+ return { detected: false, reason: "aspect-mismatch", confidence: 0 };
574
+ }
575
+ }
576
+
577
+ const areaFactor = Math.min(1, areaFraction / (4 * opts.minAreaFraction));
578
+ const rectFactor = Math.min(1, rectangularity / opts.minRectangularity);
579
+ const aspectFactor =
580
+ opts.aspectRatio == null
581
+ ? 1
582
+ : Math.max(0, Math.min(1, 1 - aspectMismatch / opts.aspectTolerance));
583
+ const confidence = areaFactor * rectFactor * aspectFactor;
584
+
585
+ if (confidence < opts.minConfidence) {
586
+ return { detected: false, reason: "low-confidence", confidence };
587
+ }
588
+
589
+ const corners = rectangleCorners(rect.cx, rect.cy, rect.w, rect.h, rect.theta);
590
+
591
+ return {
592
+ detected: true,
593
+ reason: "ok",
594
+ confidence,
595
+ center: { x: rect.cx, y: rect.cy },
596
+ angleDeg: rect.theta / DEG,
597
+ width: rect.w,
598
+ height: rect.h,
599
+ corners,
600
+ areaFraction,
601
+ rectangularity,
602
+ dominance,
603
+ borderContact,
604
+ };
605
+ }
606
+
607
+ /** The four corners of a rectangle, in a stable order. */
608
+ function rectangleCorners(cx, cy, w, h, theta) {
609
+ const ca = Math.cos(theta);
610
+ const sa = Math.sin(theta);
611
+ const hw = w / 2;
612
+ const hh = h / 2;
613
+ return [
614
+ { x: cx + hw * ca - hh * sa, y: cy + hw * sa + hh * ca },
615
+ { x: cx + hw * ca + hh * sa, y: cy + hw * sa - hh * ca },
616
+ { x: cx - hw * ca + hh * sa, y: cy - hw * sa - hh * ca },
617
+ { x: cx - hw * ca - hh * sa, y: cy - hw * sa + hh * ca },
618
+ ];
619
+ }
620
+
621
+ /**
622
+ * Snap each side of the region rectangle to the visible label boundary.
623
+ * The blob rectangle always *contains* the label, so the true boundary is
624
+ * found scanning inward from each side. Two signals are accumulated into
625
+ * 1-D histograms along the rect's axes (u = along theta, v = perpendicular):
626
+ *
627
+ * - **brightness**: the fraction of the rect's extent that is label-tone
628
+ * (>= the bright mode for a light label, <= the dark mode for a dark
629
+ * one). The label interior is solid label-tone, while a bright halo or
630
+ * a similar-tone table outside it is not - so the boundary is where a
631
+ * run of three columns/rows first reaches ~85%. This is what separates
632
+ * "proper white" from "grayish" when the table has bright patches.
633
+ * - **edges** (fallback): Sobel magnitude, a full-length boundary line
634
+ * becomes one tall bin. Used only when brightness finds nothing, e.g. a
635
+ * seam/shadow boundary on a similarly-toned surface.
636
+ *
637
+ * A side with no evidence (clipped at the frame, or a smooth table) keeps
638
+ * its region position.
639
+ *
640
+ * @param {Uint8Array} grayData small gray copy (label-tone fractions)
641
+ * @param {Uint8Array|null} edgeData Sobel magnitude on the same copy
642
+ * @param {number} width, height detection copy dimensions
643
+ * @param {object} rect { cx, cy, w, h, theta (rad) } from the region pass
644
+ * @param {string} polarity "light" | "dark"
645
+ * @returns {{ cx, cy, w, h, theta, sides: Array<{side, from, to, snapped}> }}
646
+ * never null - callers apply it directly (unsnapped sides keep `from`).
647
+ */
648
+ function refineRectBoundary(grayData, edgeData, width, height, rect, polarity) {
649
+ const { cx, cy, w, h, theta } = rect;
650
+ const ca = Math.cos(theta);
651
+ const sa = Math.sin(theta);
652
+ const extent = Math.max(width, height);
653
+ const accSize = 2 * extent + 1;
654
+ const off = extent;
655
+ const n = width * height;
656
+ const uc = cx * ca + cy * sa;
657
+ const vc = -cx * sa + cy * ca;
658
+ const u0 = Math.round(uc - w / 2);
659
+ const u1 = Math.round(uc + w / 2);
660
+ const v0 = Math.round(vc - h / 2);
661
+ const v1 = Math.round(vc + h / 2);
662
+ const edgeFloor = 24;
663
+
664
+ // Label tone level: the 98th-percentile gray value (bright mode for a
665
+ // light label, dark mode for a dark label), a small margin inside it.
666
+ const hist = new Int32Array(256);
667
+ for (let i = 0; i < n; i++) hist[grayData[i]]++;
668
+ const target = n * 0.02;
669
+ let level = -1;
670
+ if (polarity === "dark") {
671
+ let acc = 0;
672
+ for (let g = 0; g < 256; g++) {
673
+ acc += hist[g];
674
+ if (acc >= target) {
675
+ level = g + 8;
676
+ break;
677
+ }
678
+ }
679
+ } else {
680
+ let acc = 0;
681
+ for (let g = 255; g >= 0; g--) {
682
+ acc += hist[g];
683
+ if (acc >= target) {
684
+ level = g - 8;
685
+ break;
686
+ }
687
+ }
688
+ }
689
+ const isLabel = (g) => (polarity === "dark" ? g <= level : g >= level);
690
+
691
+ // Per-axis accumulators, restricted to the rect's extent so distant
692
+ // table regions cannot dilute the fractions.
693
+ const countU = new Int32Array(accSize);
694
+ const countV = new Int32Array(accSize);
695
+ const labelU = new Int32Array(accSize);
696
+ const labelV = new Int32Array(accSize);
697
+ const edgeU = new Float64Array(accSize);
698
+ const edgeV = new Float64Array(accSize);
699
+ for (let i = 0; i < n; i++) {
700
+ const x = i % width;
701
+ const y = (i / width) | 0;
702
+ const u = x * ca + y * sa;
703
+ const v = -x * sa + y * ca;
704
+ const g = grayData[i];
705
+ const m = edgeData ? edgeData[i] : 0;
706
+ if (v >= v0 && v <= v1) {
707
+ const bin = off + Math.round(u);
708
+ if (bin >= 0 && bin < accSize) {
709
+ countU[bin]++;
710
+ if (isLabel(g)) labelU[bin]++;
711
+ if (m >= edgeFloor) edgeU[bin] += m;
712
+ }
713
+ }
714
+ if (u >= u0 && u <= u1) {
715
+ const bin = off + Math.round(v);
716
+ if (bin >= 0 && bin < accSize) {
717
+ countV[bin]++;
718
+ if (isLabel(g)) labelV[bin]++;
719
+ if (m >= edgeFloor) edgeV[bin] += m;
720
+ }
721
+ }
722
+ }
723
+ const fracU = new Float64Array(accSize);
724
+ const fracV = new Float64Array(accSize);
725
+ for (let i = 0; i < accSize; i++) {
726
+ if (countU[i] > 0) fracU[i] = labelU[i] / countU[i];
727
+ if (countV[i] > 0) fracV[i] = labelV[i] / countV[i];
728
+ }
729
+
730
+ // Inward search window: deep enough to reach a boundary even when the
731
+ // region side is clamped at the frame edge (clipped label).
732
+ const win = Math.max(
733
+ 12,
734
+ Math.round(0.5 * Math.min(w, h)),
735
+ Math.round(0.35 * Math.min(width, height)),
736
+ );
737
+ // The brightness threshold adapts to how much of the rect the label
738
+ // actually fills: the region rect can be taller/wider than the label
739
+ // (halo, clipped table), which dilutes the label-tone fraction. The
740
+ // boundary is where the fraction reaches ~70% of its own maximum in the
741
+ // frame; a label filling < 30% of the rect is too weak to trust.
742
+ let maxFracU = 0;
743
+ let maxFracV = 0;
744
+ for (let i = 0; i < accSize; i++) {
745
+ if (countU[i] >= 8 && fracU[i] > maxFracU) maxFracU = fracU[i];
746
+ if (countV[i] >= 8 && fracV[i] > maxFracV) maxFracV = fracV[i];
747
+ }
748
+ const threshU = maxFracU >= 0.3 ? 0.7 * maxFracU : Infinity;
749
+ const threshV = maxFracV >= 0.3 ? 0.7 * maxFracV : Infinity;
750
+ // A boundary spans the perpendicular side's length, so scale the edge
751
+ // floor by it: left/right sides run h, top/bottom sides run w.
752
+ const floorU = Math.max(300, Math.round(h * 15));
753
+ const floorV = Math.max(300, Math.round(w * 15));
754
+
755
+ const brightnessAt = (frac, count, p, dir, thresh) =>
756
+ count[p + off] >= 8 &&
757
+ frac[p + off] >= thresh &&
758
+ count[p + dir + off] >= 8 &&
759
+ frac[p + dir + off] >= thresh &&
760
+ count[p + 2 * dir + off] >= 8 &&
761
+ frac[p + 2 * dir + off] >= thresh;
762
+ const scanBrightness = (start, dir, frac, count, thresh) => {
763
+ for (let i = 0; i <= win; i++) {
764
+ const p = start + dir * i;
765
+ if (p + 2 * dir + off < 0 || p + 2 * dir + off >= accSize) break;
766
+ if (brightnessAt(frac, count, p, dir, thresh)) return p;
767
+ }
768
+ return null;
769
+ };
770
+ const scanEdge = (start, dir, edge, floor) => {
771
+ for (let i = 0; i <= win; i++) {
772
+ const p = start + dir * i;
773
+ if (p + 2 * dir + off < 0 || p + 2 * dir + off >= accSize) break;
774
+ const a = edge[p + off];
775
+ const b = edge[p + dir + off];
776
+ const c = edge[p + 2 * dir + off];
777
+ if (a >= floor && b >= floor && c >= floor) return p;
778
+ }
779
+ return null;
780
+ };
781
+
782
+ // A snap must move the side by a meaningful distance (a 1-2px drift is
783
+ // boundary rounding, and letting it count would block the seam fallback)
784
+ // - and a scan that merely confirms the region position is no snap at
785
+ // all. Brightness first; the edge seam is only trusted when the strip
786
+ // between it and the region side is weaker than the label tone itself -
787
+ // otherwise the "edge" is printed content inside the label (a barcode
788
+ // band reads exactly like a seam on the Sobel accumulator).
789
+ const stripAvg = (frac, from, to) => {
790
+ const a = Math.min(from, to);
791
+ const b = Math.max(from, to);
792
+ let sum = 0;
793
+ let cnt = 0;
794
+ for (let i = a; i <= b; i++) {
795
+ const v = frac[i + off];
796
+ if (Number.isFinite(v)) {
797
+ sum += v;
798
+ cnt++;
799
+ }
800
+ }
801
+ return cnt > 0 ? sum / cnt : 0;
802
+ };
803
+ const improve = (region, brightness, edge, frac, maxFrac) => {
804
+ if (brightness !== null && Math.abs(brightness - region) >= 3) {
805
+ return brightness;
806
+ }
807
+ if (edge !== null && Math.abs(edge - region) >= 3) {
808
+ if (stripAvg(frac, region, edge) < 0.9 * maxFrac) {
809
+ return edge;
810
+ }
811
+ }
812
+ return region;
813
+ };
814
+ const left = improve(
815
+ u0,
816
+ scanBrightness(u0, 1, fracU, countU, threshU),
817
+ scanEdge(u0, 1, edgeU, floorU),
818
+ fracU,
819
+ maxFracU,
820
+ );
821
+ const right = improve(
822
+ u1,
823
+ scanBrightness(u1, -1, fracU, countU, threshU),
824
+ scanEdge(u1, -1, edgeU, floorU),
825
+ fracU,
826
+ maxFracU,
827
+ );
828
+ const top = improve(
829
+ v0,
830
+ scanBrightness(v0, 1, fracV, countV, threshV),
831
+ scanEdge(v0, 1, edgeV, floorV),
832
+ fracV,
833
+ maxFracV,
834
+ );
835
+ const bottom = improve(
836
+ v1,
837
+ scanBrightness(v1, -1, fracV, countV, threshV),
838
+ scanEdge(v1, -1, edgeV, floorV),
839
+ fracV,
840
+ maxFracV,
841
+ );
842
+
843
+ const newW = right - left;
844
+ const newH = bottom - top;
845
+ // Degenerate snap (a huge window crossed the opposite side): revert.
846
+ if (newW < 8) {
847
+ return {
848
+ cx,
849
+ cy,
850
+ w,
851
+ h,
852
+ theta,
853
+ sides: [
854
+ { side: "left", from: left, to: left, snapped: false },
855
+ { side: "right", from: right, to: right, snapped: false },
856
+ { side: "top", from: top, to: top, snapped: false },
857
+ { side: "bottom", from: bottom, to: bottom, snapped: false },
858
+ ],
859
+ };
860
+ }
861
+ if (newH < 8) {
862
+ return {
863
+ cx,
864
+ cy,
865
+ w,
866
+ h,
867
+ theta,
868
+ sides: [
869
+ { side: "left", from: left, to: left, snapped: false },
870
+ { side: "right", from: right, to: right, snapped: false },
871
+ { side: "top", from: top, to: top, snapped: false },
872
+ { side: "bottom", from: bottom, to: bottom, snapped: false },
873
+ ],
874
+ };
875
+ }
876
+ const newUc = (left + right) / 2;
877
+ const newVc = (top + bottom) / 2;
878
+ return {
879
+ cx: newUc * ca - newVc * sa,
880
+ cy: newUc * sa + newVc * ca,
881
+ w: newW,
882
+ h: newH,
883
+ theta,
884
+ sides: [
885
+ {
886
+ side: "left",
887
+ from: Math.round(uc - w / 2),
888
+ to: left,
889
+ snapped: left !== Math.round(uc - w / 2),
890
+ },
891
+ {
892
+ side: "right",
893
+ from: Math.round(uc + w / 2),
894
+ to: right,
895
+ snapped: right !== Math.round(uc + w / 2),
896
+ },
897
+ {
898
+ side: "top",
899
+ from: Math.round(vc - h / 2),
900
+ to: top,
901
+ snapped: top !== Math.round(vc - h / 2),
902
+ },
903
+ {
904
+ side: "bottom",
905
+ from: Math.round(vc + h / 2),
906
+ to: bottom,
907
+ snapped: bottom !== Math.round(vc + h / 2),
908
+ },
909
+ ],
910
+ };
911
+ }
912
+
913
+ // ---- the op -------------------------------------------------------------
914
+
915
+ const round1 = (v) => Math.round(v * 10) / 10;
916
+ const round3 = (v) => Math.round(v * 1000) / 1000;
917
+ const round4 = (v) => Math.round(v * 10000) / 10000;
918
+
919
+ function buildMissMetadata(analysis, timings, smallW, smallH, sx, sy) {
920
+ let dominance = null;
921
+ if (analysis && analysis.dominance !== Infinity) {
922
+ dominance = round3(analysis.dominance);
923
+ }
924
+ return {
925
+ detected: false,
926
+ reason: analysis ? analysis.reason : "no-component",
927
+ polarity: analysis ? analysis.polarity : null,
928
+ angleDeg: null,
929
+ center: null,
930
+ corners: null,
931
+ width: null,
932
+ height: null,
933
+ confidence: analysis ? round3(analysis.confidence) : 0,
934
+ areaFraction: analysis ? round4(analysis.areaFraction || 0) : 0,
935
+ rectangularity: analysis ? round3(analysis.rectangularity || 0) : 0,
936
+ dominance,
937
+ borderContact: analysis ? round3(analysis.borderContact || 0) : 0,
938
+ smallSize: { width: smallW, height: smallH },
939
+ scale: { x: round4(sx), y: round4(sy) },
940
+ crop: null,
941
+ // calipers mode reports each edge's own outcome, so a region that
942
+ // needs re-aiming can be identified without re-running the frame
943
+ ...(analysis && analysis.edges ? { edges: analysis.edges } : {}),
944
+ timings,
945
+ };
946
+ }
947
+
948
+ /**
949
+ * Deskew-and-crop a label.
950
+ *
951
+ * @param {Buffer|object} input encoded image Buffer or raw image object
952
+ * @param {object} [cfg] options (see DEFAULTS)
953
+ * @param {object} [engine] engine to use; defaults to the lazily loaded
954
+ * native bridge. Pass null to force the "unavailable" setup error.
955
+ * @returns {Promise<{image, detected, metadata}>} on a miss, `image` is
956
+ * the original input unchanged.
957
+ */
958
+ async function labelCrop(input, cfg = {}, engine) {
959
+ const tStart = performance.now();
960
+ const eng = engine === undefined ? getBridge() : engine;
961
+ if (!eng) {
962
+ throw new Error(ENGINE_UNAVAILABLE_PREFIX + "engine not installed");
963
+ }
964
+ const opts = normalizeCfg(cfg);
965
+ const original = input;
966
+
967
+ // ---- decode / normalise once -------------------------------------
968
+ let full;
969
+ const timings = {
970
+ decodeMs: 0,
971
+ detectCopyMs: 0,
972
+ maskMs: 0,
973
+ analysisMs: 0,
974
+ edgeMs: 0,
975
+ refineMs: 0,
976
+ rotateMs: 0,
977
+ cropMs: 0,
978
+ totalMs: 0,
979
+ engine: [],
980
+ };
981
+ if (Buffer.isBuffer(input)) {
982
+ const d0 = performance.now();
983
+ const res = await eng.colorConvert(input, "RGB", "raw");
984
+ timings.decodeMs = performance.now() - d0;
985
+ timings.engine.push({ op: "decode", ...res.timing });
986
+ full = res.image;
987
+ } else if (isRawImage(input)) {
988
+ full = normalizeRaw(input);
989
+ } else {
990
+ throw new Error(
991
+ "label-crop: input must be an encoded image Buffer or a raw " +
992
+ "{ data, width, height, channels } image object",
993
+ );
994
+ }
995
+
996
+ // ---- calipers boundary mode ---------------------------------------
997
+ // Four operator-drawn line finders instead of a whole-frame blob
998
+ // search, for a boundary whose own contrast is weaker than the
999
+ // artwork's. Produces the same `best` shape the blob path does, so
1000
+ // the rotate/crop tail below is shared unchanged.
1001
+ if (opts.boundaryMode === "calipers") {
1002
+ const found = await boundaryByCalipers(eng, full, opts, timings);
1003
+ if (!found.best.detected) {
1004
+ timings.totalMs = performance.now() - tStart;
1005
+ return {
1006
+ image: original,
1007
+ detected: false,
1008
+ metadata: buildMissMetadata(
1009
+ found.best,
1010
+ timings,
1011
+ found.smallW,
1012
+ found.smallH,
1013
+ 1,
1014
+ 1,
1015
+ ),
1016
+ };
1017
+ }
1018
+ return cropToRect({
1019
+ eng,
1020
+ full,
1021
+ opts,
1022
+ timings,
1023
+ original,
1024
+ tStart,
1025
+ best: found.best,
1026
+ smallW: found.smallW,
1027
+ smallH: found.smallH,
1028
+ });
1029
+ }
1030
+
1031
+ // ---- detection copy: <= maxEdge long edge, grey -------------------
1032
+ const scale = Math.min(1, opts.maxEdge / Math.max(full.width, full.height));
1033
+ const smallW = Math.max(1, Math.round(full.width * scale));
1034
+ const smallH = Math.max(1, Math.round(full.height * scale));
1035
+ let det = full;
1036
+ if (scale < 1) {
1037
+ const d0 = performance.now();
1038
+ const res = await eng.resize(full, "num", smallW, "num", smallH, "raw");
1039
+ timings.detectCopyMs += performance.now() - d0;
1040
+ timings.engine.push({ op: "resize", ...res.timing });
1041
+ det = res.image;
1042
+ }
1043
+ if ((det.channels || 1) !== 1) {
1044
+ const d0 = performance.now();
1045
+ const res = await eng.colorConvert(det, "GRAY", "raw");
1046
+ timings.detectCopyMs += performance.now() - d0;
1047
+ timings.engine.push({ op: "colorConvert", ...res.timing });
1048
+ det = res.image;
1049
+ }
1050
+
1051
+ // ---- Otsu once; invert the small mask in JS for dark polarity -------
1052
+ const maskStarted = performance.now();
1053
+ const thresholded = await eng.filter(det, "otsu", 3, 0, "raw");
1054
+ timings.maskMs = performance.now() - maskStarted;
1055
+ timings.engine.push({ op: "filter", ...thresholded.timing });
1056
+ const mask = thresholded.image;
1057
+ const lightMask = new Uint8Array(
1058
+ mask.data.buffer,
1059
+ mask.data.byteOffset,
1060
+ mask.width * mask.height,
1061
+ );
1062
+ let polarities = ["light"];
1063
+ if (opts.polarity === "auto") polarities = ["light", "dark"];
1064
+ else if (opts.polarity === "dark") polarities = ["dark"];
1065
+ let darkMask;
1066
+ let best = null;
1067
+ for (const polarity of polarities) {
1068
+ const a0 = performance.now();
1069
+ let pixels = lightMask;
1070
+ if (polarity === "dark") {
1071
+ darkMask ||= Uint8Array.from(lightMask, (value) => (value ? 0 : 255));
1072
+ pixels = darkMask;
1073
+ }
1074
+ const analysis = analyzeMask(pixels, mask.width, mask.height, opts);
1075
+ timings.analysisMs += performance.now() - a0;
1076
+ analysis.polarity = polarity;
1077
+ if (!best || analysis.confidence > best.confidence) best = analysis;
1078
+ }
1079
+
1080
+ if (!best || !best.detected) {
1081
+ timings.totalMs = performance.now() - tStart;
1082
+ return {
1083
+ image: original,
1084
+ detected: false,
1085
+ metadata: buildMissMetadata(best, timings, smallW, smallH, scale, scale),
1086
+ };
1087
+ }
1088
+
1089
+ // ---- boundary refinement: snap each side to the label's visible edge
1090
+ // (brightness step, Sobel as fallback) so a clipped or table-blended
1091
+ // label crops to its real boundary ---
1092
+ const edgeStarted = performance.now();
1093
+ const edgeRes = await eng.filter(det, "edge", 3, 1.0, "raw");
1094
+ timings.edgeMs = performance.now() - edgeStarted;
1095
+ timings.engine.push({ op: "edge", ...edgeRes.timing });
1096
+ const refineStarted = performance.now();
1097
+ const refined = refineRectBoundary(
1098
+ new Uint8Array(det.data.buffer, det.data.byteOffset, det.data.byteLength),
1099
+ new Uint8Array(
1100
+ edgeRes.image.data.buffer,
1101
+ edgeRes.image.data.byteOffset,
1102
+ edgeRes.image.data.byteLength,
1103
+ ),
1104
+ smallW,
1105
+ smallH,
1106
+ {
1107
+ cx: best.center.x,
1108
+ cy: best.center.y,
1109
+ w: best.width,
1110
+ h: best.height,
1111
+ theta: best.angleDeg * DEG,
1112
+ },
1113
+ best.polarity,
1114
+ );
1115
+ timings.refineMs = performance.now() - refineStarted;
1116
+ best.center.x = refined.cx;
1117
+ best.center.y = refined.cy;
1118
+ best.width = refined.w;
1119
+ best.height = refined.h;
1120
+ best.angleDeg = refined.theta / DEG;
1121
+ best.corners = rectangleCorners(
1122
+ refined.cx,
1123
+ refined.cy,
1124
+ refined.w,
1125
+ refined.h,
1126
+ refined.theta,
1127
+ );
1128
+ best.snappedSides = refined.sides.filter((s) => s.snapped).map((s) => s.side);
1129
+
1130
+ // ---- expected-size gate: the refined label must cover roughly the
1131
+ // fraction of the frame the user drew on a representative sample. This
1132
+ // turns a badly-detected rect (halo included, wrong product) into a
1133
+ // clean miss instead of a wrong crop. Compared after refinement, so the
1134
+ // clipped/table-blended extents the refinement removes are not counted.
1135
+ if (opts.expectedSizeFraction != null) {
1136
+ const refinedAreaFraction = (refined.w * refined.h) / (smallW * smallH);
1137
+ const sizeMismatch = Math.abs(
1138
+ Math.log(refinedAreaFraction / opts.expectedSizeFraction),
1139
+ );
1140
+ if (sizeMismatch > opts.sizeTolerance) {
1141
+ timings.totalMs = performance.now() - tStart;
1142
+ return {
1143
+ image: original,
1144
+ detected: false,
1145
+ metadata: buildMissMetadata(
1146
+ { ...best, reason: "size-mismatch" },
1147
+ timings,
1148
+ smallW,
1149
+ smallH,
1150
+ scale,
1151
+ scale,
1152
+ ),
1153
+ };
1154
+ }
1155
+ }
1156
+
1157
+ return cropToRect({
1158
+ eng,
1159
+ full,
1160
+ opts,
1161
+ timings,
1162
+ original,
1163
+ tStart,
1164
+ best,
1165
+ smallW,
1166
+ smallH,
1167
+ });
1168
+ }
1169
+
1170
+ /**
1171
+ * Boundary from four operator-drawn line finders.
1172
+ *
1173
+ * Runs at **full resolution**, not on the maxEdge detection copy. The
1174
+ * blob search can afford a 640px copy because it is looking for a shape;
1175
+ * a caliper is looking for a position, and on a 3700px frame that copy
1176
+ * costs a factor of six in every measurement it makes. The regions are
1177
+ * small, so full resolution is affordable here in a way a whole-frame
1178
+ * search would not be - a caliper only touches the box it was given.
1179
+ *
1180
+ * Returns `{ best, smallW, smallH }` with `best` in the same shape
1181
+ * `analyzeMask` produces and `smallW/smallH` equal to the frame, so the
1182
+ * shared tail's scale factors come out as 1 and no rounding is
1183
+ * introduced on the way back up.
1184
+ */
1185
+ async function boundaryByCalipers(eng, full, opts, timings) {
1186
+ const smallW = full.width;
1187
+ const smallH = full.height;
1188
+ const fail = (reason, extra = {}) => ({
1189
+ best: { detected: false, reason, confidence: 0, ...extra },
1190
+ smallW,
1191
+ smallH,
1192
+ });
1193
+
1194
+ const sides = ["left", "right", "top", "bottom"];
1195
+ const configured = opts.edgeRegions || {};
1196
+ const missingCfg = sides.filter((s) => !configured[s]);
1197
+ if (missingCfg.length) {
1198
+ return fail(`calipers-unconfigured:${missingCfg.join("+")}`);
1199
+ }
1200
+
1201
+ // One grayscale conversion of the whole frame, reused by all four
1202
+ // regions. Cropping four ROIs natively instead would be four engine
1203
+ // round-trips for the same pixels.
1204
+ const g0 = performance.now();
1205
+ let gray = full;
1206
+ if ((full.channels || 1) !== 1) {
1207
+ const res = await eng.colorConvert(full, "GRAY", "raw");
1208
+ timings.engine.push({ op: "colorConvert", ...res.timing });
1209
+ gray = res.image;
1210
+ }
1211
+ timings.grayMs = performance.now() - g0;
1212
+ const pixels = new Uint8Array(
1213
+ gray.data.buffer,
1214
+ gray.data.byteOffset,
1215
+ gray.width * gray.height,
1216
+ );
1217
+
1218
+ // The scan direction each side implies, so an operator who drew a box
1219
+ // over the left edge does not also have to say "scan rightwards".
1220
+ const impliedScan = { left: "right", right: "left", top: "down", bottom: "up" };
1221
+
1222
+ const c0 = performance.now();
1223
+ const results = {};
1224
+ for (const side of sides) {
1225
+ const spec = configured[side];
1226
+ const { x, y, width, height, angleDeg, ...rest } = spec;
1227
+ results[side] = findLine(
1228
+ pixels,
1229
+ gray.width,
1230
+ gray.height,
1231
+ { x, y, width, height, angleDeg },
1232
+ { scanDirection: impliedScan[side], ...rest },
1233
+ );
1234
+ }
1235
+ timings.caliperMs = performance.now() - c0;
1236
+
1237
+ const rect = rectFromLines(results);
1238
+ const edges = {};
1239
+ for (const side of sides) {
1240
+ const r = results[side];
1241
+ edges[side] = {
1242
+ found: r.found,
1243
+ reason: r.reason,
1244
+ score: round3(r.score),
1245
+ angleDeg: r.angleDeg == null ? null : round3(r.angleDeg),
1246
+ residualPx: r.residualPx == null ? null : round3(r.residualPx),
1247
+ calipers: r.calipers,
1248
+ line: r.found ? { x: round1(r.line.x), y: round1(r.line.y) } : null,
1249
+ };
1250
+ }
1251
+ if (!rect.ok) {
1252
+ // A frame whose boundary cannot be located is not inspectable, so
1253
+ // this is a miss rather than a crop against a guessed rectangle -
1254
+ // the same rule the blob path applies to weak evidence.
1255
+ return fail(`calipers:${rect.reason}`, { edges });
1256
+ }
1257
+
1258
+ return {
1259
+ smallW,
1260
+ smallH,
1261
+ best: {
1262
+ detected: true,
1263
+ reason: "ok",
1264
+ polarity: "calipers",
1265
+ confidence: rect.score,
1266
+ center: { x: rect.cx, y: rect.cy },
1267
+ width: rect.width,
1268
+ height: rect.height,
1269
+ angleDeg: rect.angleDeg,
1270
+ corners: rect.corners,
1271
+ // the blob path's shape descriptors have no meaning here: the
1272
+ // rectangle came from four fitted lines, not from a region of
1273
+ // connected pixels. Reported as null rather than as a plausible
1274
+ // looking number nothing measured.
1275
+ areaFraction: (rect.width * rect.height) / (smallW * smallH),
1276
+ rectangularity: null,
1277
+ dominance: null,
1278
+ borderContact: null,
1279
+ snappedSides: [],
1280
+ edges,
1281
+ residualPx: rect.residualPx,
1282
+ },
1283
+ };
1284
+ }
1285
+
1286
+ /**
1287
+ * Rotate and crop the frame to the detected rectangle.
1288
+ *
1289
+ * Split out because both boundary modes end here: `best` is in
1290
+ * `smallW x smallH` detection coordinates (the blob path's maxEdge copy,
1291
+ * or the full frame itself when the calipers found the rectangle
1292
+ * directly), and everything from here is the same either way.
1293
+ */
1294
+ async function cropToRect({
1295
+ eng,
1296
+ full,
1297
+ opts,
1298
+ timings,
1299
+ original,
1300
+ tStart,
1301
+ best,
1302
+ smallW,
1303
+ smallH,
1304
+ }) {
1305
+ // ---- scale the analysis into the full-resolution frame -------------
1306
+ const sx = full.width / smallW;
1307
+ const sy = full.height / smallH;
1308
+ const cx = best.center.x * sx;
1309
+ const cy = best.center.y * sy;
1310
+ const w = best.width * sx;
1311
+ const h = best.height * sy;
1312
+ const angleDeg = best.angleDeg;
1313
+ const ca = Math.cos(angleDeg * DEG);
1314
+ const sa = Math.sin(angleDeg * DEG);
1315
+
1316
+ // ---- tight ROI: the label bbox + a small margin, clamped -----------
1317
+ const margin = Math.max(2, Math.round(opts.cropMargin * Math.max(w, h)));
1318
+ const bboxW = Math.abs(w * ca) + Math.abs(h * sa);
1319
+ const bboxH = Math.abs(w * sa) + Math.abs(h * ca);
1320
+ const imgW = full.width;
1321
+ const imgH = full.height;
1322
+ const clampX = (v) => Math.max(0, Math.min(v, imgW));
1323
+ const clampY = (v) => Math.max(0, Math.min(v, imgH));
1324
+ let cx0 = clampX(Math.round(cx - bboxW / 2 - margin));
1325
+ let cy0 = clampY(Math.round(cy - bboxH / 2 - margin));
1326
+ const cx1 = clampX(Math.round(cx + bboxW / 2 + margin));
1327
+ const cy1 = clampY(Math.round(cy + bboxH / 2 + margin));
1328
+ cx0 = Math.min(cx0, cx1 - 1);
1329
+ cy0 = Math.min(cy0, cy1 - 1);
1330
+ const cw = cx1 - cx0;
1331
+ const ch = cy1 - cy0;
1332
+ if (cw < 2 || ch < 2) {
1333
+ timings.totalMs = performance.now() - tStart;
1334
+ return {
1335
+ image: original,
1336
+ detected: false,
1337
+ metadata: buildMissMetadata(
1338
+ { ...best, reason: "off-frame" },
1339
+ timings,
1340
+ smallW,
1341
+ smallH,
1342
+ sx,
1343
+ sy,
1344
+ ),
1345
+ };
1346
+ }
1347
+
1348
+ // The label's centre relative to the ROI centre. This is OpenCV's
1349
+ // getRotationMatrix2D(+angle) linear part in image coordinates.
1350
+ const dx = cx - (cx0 + cw / 2);
1351
+ const dy = cy - (cy0 + ch / 2);
1352
+ const dxr = dx * ca + dy * sa;
1353
+ const dyr = -dx * sa + dy * ca;
1354
+ // Match the engine's own rotate exactly: cv::Size receives truncated ints.
1355
+ const dstW = Math.trunc(ch * Math.abs(sa) + cw * Math.abs(ca));
1356
+ const dstH = Math.trunc(ch * Math.abs(ca) + cw * Math.abs(sa));
1357
+ const dstCx = dstW / 2 + dxr;
1358
+ const dstCy = dstH / 2 + dyr;
1359
+
1360
+ // ---- rotate only the ROI (skip when the angle is negligible) -------
1361
+ let rotated = full;
1362
+ let rotateMs = 0;
1363
+ if (Math.abs(angleDeg) >= opts.minRotateAngleDeg) {
1364
+ const d0 = performance.now();
1365
+ const roi = await eng.crop(full, cx0, cy0, cw, ch, false, "raw");
1366
+ timings.rotateMs += performance.now() - d0;
1367
+ timings.engine.push({ op: "crop", ...roi.timing });
1368
+ const d1 = performance.now();
1369
+ // OpenCV's image-coordinate rotation matrix levels the boundary axis
1370
+ // (cos(theta), sin(theta)) when passed +theta, not -theta.
1371
+ const rot = await eng.rotate(roi.image, angleDeg, opts.padColor, "raw");
1372
+ timings.rotateMs += performance.now() - d1;
1373
+ timings.engine.push({ op: "rotate", ...rot.timing });
1374
+ rotated = rot.image;
1375
+ rotateMs = timings.rotateMs;
1376
+ }
1377
+
1378
+ // ---- the final tight crop: the centred w x h label rect ------------
1379
+ const fw = Math.max(1, Math.round(w));
1380
+ const fh = Math.max(1, Math.round(h));
1381
+ let fx;
1382
+ let fy;
1383
+ if (Math.abs(angleDeg) < opts.minRotateAngleDeg) {
1384
+ // no rotation: the crop rect sits at the label centre in the frame
1385
+ fx = Math.max(0, Math.min(Math.round(cx - fw / 2), imgW - fw));
1386
+ fy = Math.max(0, Math.min(Math.round(cy - fh / 2), imgH - fh));
1387
+ } else {
1388
+ fx = Math.max(0, Math.min(Math.round(dstCx - fw / 2), dstW - fw));
1389
+ fy = Math.max(0, Math.min(Math.round(dstCy - fh / 2), dstH - fh));
1390
+ }
1391
+
1392
+ const d2 = performance.now();
1393
+ const final = await eng.crop(
1394
+ rotated,
1395
+ fx,
1396
+ fy,
1397
+ fw,
1398
+ fh,
1399
+ false,
1400
+ opts.outputFormat,
1401
+ opts.outputQuality,
1402
+ opts.pngOptimize,
1403
+ );
1404
+ timings.cropMs = performance.now() - d2;
1405
+ timings.engine.push({ op: "final-crop", ...final.timing });
1406
+ timings.totalMs = performance.now() - tStart;
1407
+
1408
+ const corners = best.corners.map((c) => ({
1409
+ x: round1(c.x * sx),
1410
+ y: round1(c.y * sy),
1411
+ }));
1412
+ const metadata = {
1413
+ detected: true,
1414
+ reason: "ok",
1415
+ polarity: best.polarity,
1416
+ angleDeg: round3(angleDeg),
1417
+ center: { x: round1(cx), y: round1(cy) },
1418
+ corners,
1419
+ width: round1(w),
1420
+ height: round1(h),
1421
+ confidence: round3(best.confidence),
1422
+ areaFraction: round4(best.areaFraction),
1423
+ rectangularity: round3(best.rectangularity),
1424
+ dominance: best.dominance === Infinity ? null : round3(best.dominance),
1425
+ borderContact: round3(best.borderContact),
1426
+ refinedSides: best.snappedSides || [],
1427
+ ...(best.edges ? { edges: best.edges } : {}),
1428
+ ...(best.residualPx != null ? { residualPx: round3(best.residualPx) } : {}),
1429
+ smallSize: { width: smallW, height: smallH },
1430
+ scale: { x: round4(sx), y: round4(sy) },
1431
+ crop: {
1432
+ x: cx0,
1433
+ y: cy0,
1434
+ width: cw,
1435
+ height: ch,
1436
+ rotatedWidth: dstW,
1437
+ rotatedHeight: dstH,
1438
+ finalX: fx,
1439
+ finalY: fy,
1440
+ finalWidth: fw,
1441
+ finalHeight: fh,
1442
+ rotated: rotateMs > 0,
1443
+ },
1444
+ timings,
1445
+ };
1446
+
1447
+ return { image: final.image, detected: true, metadata };
1448
+ }
1449
+
1450
+ module.exports = {
1451
+ labelCrop,
1452
+ analyzeMask,
1453
+ refineRectBoundary,
1454
+ getBridge,
1455
+ available,
1456
+ _setBridge,
1457
+ _resetBridge,
1458
+ DEFAULTS,
1459
+ BOUNDARY_MODES,
1460
+ ENGINE_UNAVAILABLE_PREFIX,
1461
+ };