react-native-rectangle-doc-scanner 0.58.0 → 0.60.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/dist/DocScanner.js +90 -83
- package/package.json +1 -1
- package/src/DocScanner.tsx +100 -100
package/dist/DocScanner.js
CHANGED
|
@@ -271,7 +271,63 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
|
|
|
271
271
|
step = 'cvtColor';
|
|
272
272
|
reportStage(step);
|
|
273
273
|
react_native_fast_opencv_1.OpenCV.invoke('cvtColor', mat, mat, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
|
|
274
|
-
|
|
274
|
+
// Enhanced morphological operations for noise reduction
|
|
275
|
+
const morphologyKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 7, 7);
|
|
276
|
+
step = 'getStructuringElement';
|
|
277
|
+
reportStage(step);
|
|
278
|
+
const element = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', react_native_fast_opencv_1.MorphShapes.MORPH_RECT, morphologyKernel);
|
|
279
|
+
step = 'morphologyEx';
|
|
280
|
+
reportStage(step);
|
|
281
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', mat, mat, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
282
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', mat, mat, react_native_fast_opencv_1.MorphTypes.MORPH_OPEN, element);
|
|
283
|
+
const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
|
|
284
|
+
const THRESH_BINARY = 0;
|
|
285
|
+
const THRESH_OTSU = 8;
|
|
286
|
+
// Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
|
|
287
|
+
step = 'bilateralFilter';
|
|
288
|
+
reportStage(step);
|
|
289
|
+
let processed = mat;
|
|
290
|
+
try {
|
|
291
|
+
const tempMat = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat);
|
|
292
|
+
react_native_fast_opencv_1.OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
|
|
293
|
+
processed = tempMat;
|
|
294
|
+
}
|
|
295
|
+
catch (error) {
|
|
296
|
+
if (__DEV__) {
|
|
297
|
+
console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
|
|
298
|
+
}
|
|
299
|
+
const blurKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
|
|
300
|
+
react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
|
|
301
|
+
processed = mat;
|
|
302
|
+
}
|
|
303
|
+
// Additional blur and close pass to smooth jagged edges
|
|
304
|
+
step = 'gaussianBlur';
|
|
305
|
+
reportStage(step);
|
|
306
|
+
const gaussianKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
|
|
307
|
+
react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', processed, processed, gaussianKernel, 0);
|
|
308
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', processed, processed, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
309
|
+
const baseMat = react_native_fast_opencv_1.OpenCV.invoke('clone', processed);
|
|
310
|
+
const frameArea = width * height;
|
|
311
|
+
const originalArea = frame.width * frame.height;
|
|
312
|
+
const minEdgeThreshold = Math.max(14, Math.min(frame.width, frame.height) * MIN_EDGE_RATIO);
|
|
313
|
+
const epsilonValues = [
|
|
314
|
+
0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.007, 0.008, 0.009,
|
|
315
|
+
0.01, 0.012, 0.015, 0.018, 0.02, 0.025, 0.03, 0.035, 0.04, 0.05,
|
|
316
|
+
0.06, 0.07, 0.08, 0.09, 0.1, 0.12,
|
|
317
|
+
];
|
|
318
|
+
let bestQuad = null;
|
|
319
|
+
let bestArea = 0;
|
|
320
|
+
let convexHullWarned = false;
|
|
321
|
+
const considerCandidate = (candidate) => {
|
|
322
|
+
'worklet';
|
|
323
|
+
if (!candidate) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (!bestQuad || candidate.area > bestArea) {
|
|
327
|
+
bestQuad = candidate.quad;
|
|
328
|
+
bestArea = candidate.area;
|
|
329
|
+
}
|
|
330
|
+
};
|
|
275
331
|
const evaluateContours = (inputMat, attemptLabel) => {
|
|
276
332
|
'worklet';
|
|
277
333
|
step = `findContours_${attemptLabel}`;
|
|
@@ -281,21 +337,18 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
|
|
|
281
337
|
const contourVector = react_native_fast_opencv_1.OpenCV.toJSValue(contours);
|
|
282
338
|
const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
|
|
283
339
|
let bestLocal = null;
|
|
284
|
-
const resizedArea = width * height;
|
|
285
|
-
const originalArea = frame.width * frame.height;
|
|
286
|
-
const minEdgeThreshold = Math.max(16, Math.min(frame.width, frame.height) * 0.012);
|
|
287
340
|
for (let i = 0; i < contourArray.length; i += 1) {
|
|
288
341
|
step = `${attemptLabel}_contour_${i}_copy`;
|
|
289
342
|
reportStage(step);
|
|
290
343
|
const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contours, i);
|
|
291
344
|
step = `${attemptLabel}_contour_${i}_area`;
|
|
292
345
|
reportStage(step);
|
|
293
|
-
const { value:
|
|
294
|
-
if (typeof
|
|
346
|
+
const { value: area } = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour, false);
|
|
347
|
+
if (typeof area !== 'number' || !isFinite(area) || area < 60) {
|
|
295
348
|
continue;
|
|
296
349
|
}
|
|
297
|
-
const
|
|
298
|
-
if (
|
|
350
|
+
const resizedRatio = area / frameArea;
|
|
351
|
+
if (resizedRatio < 0.00012 || resizedRatio > 0.98) {
|
|
299
352
|
continue;
|
|
300
353
|
}
|
|
301
354
|
let contourToUse = contour;
|
|
@@ -305,18 +358,16 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
|
|
|
305
358
|
contourToUse = hull;
|
|
306
359
|
}
|
|
307
360
|
catch (err) {
|
|
308
|
-
if (__DEV__) {
|
|
361
|
+
if (__DEV__ && !convexHullWarned) {
|
|
309
362
|
console.warn('[DocScanner] convexHull failed, using original contour');
|
|
363
|
+
convexHullWarned = true;
|
|
310
364
|
}
|
|
311
365
|
}
|
|
312
366
|
const { value: perimeter } = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contourToUse, true);
|
|
313
|
-
if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter <
|
|
367
|
+
if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter < 80) {
|
|
314
368
|
continue;
|
|
315
369
|
}
|
|
316
370
|
const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
317
|
-
const epsilonValues = [
|
|
318
|
-
0.012, 0.01, 0.008, 0.006, 0.005, 0.004, 0.0035, 0.003, 0.0025, 0.002, 0.0016, 0.0012,
|
|
319
|
-
];
|
|
320
371
|
let approxArray = [];
|
|
321
372
|
for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
|
|
322
373
|
const epsilon = epsilonValues[attempt] * perimeter;
|
|
@@ -348,28 +399,24 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
|
|
|
348
399
|
if (!(0, quad_1.isValidQuad)(sanitized)) {
|
|
349
400
|
continue;
|
|
350
401
|
}
|
|
351
|
-
const
|
|
352
|
-
const minEdge = Math.min(...
|
|
353
|
-
const maxEdge = Math.max(...
|
|
402
|
+
const edges = (0, quad_1.quadEdgeLengths)(sanitized);
|
|
403
|
+
const minEdge = Math.min(...edges);
|
|
404
|
+
const maxEdge = Math.max(...edges);
|
|
354
405
|
if (!Number.isFinite(minEdge) || minEdge < minEdgeThreshold) {
|
|
355
406
|
continue;
|
|
356
407
|
}
|
|
357
408
|
const aspectRatio = maxEdge / Math.max(minEdge, 1);
|
|
358
|
-
if (!Number.isFinite(aspectRatio) || aspectRatio >
|
|
409
|
+
if (!Number.isFinite(aspectRatio) || aspectRatio > 8.5) {
|
|
359
410
|
continue;
|
|
360
411
|
}
|
|
361
412
|
const quadAreaValue = (0, quad_1.quadArea)(sanitized);
|
|
362
|
-
const
|
|
363
|
-
if (
|
|
413
|
+
const originalRatio = originalArea > 0 ? quadAreaValue / originalArea : 0;
|
|
414
|
+
if (originalRatio < 0.00012 || originalRatio > 0.92) {
|
|
364
415
|
continue;
|
|
365
416
|
}
|
|
366
|
-
if (__DEV__) {
|
|
367
|
-
console.log('[DocScanner] candidate', attemptLabel, 'areaRatio', areaRatioOriginal);
|
|
368
|
-
}
|
|
369
417
|
const candidate = {
|
|
370
418
|
quad: sanitized,
|
|
371
419
|
area: quadAreaValue,
|
|
372
|
-
label: attemptLabel,
|
|
373
420
|
};
|
|
374
421
|
if (!bestLocal || candidate.area > bestLocal.area) {
|
|
375
422
|
bestLocal = candidate;
|
|
@@ -377,85 +424,45 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
|
|
|
377
424
|
}
|
|
378
425
|
return bestLocal;
|
|
379
426
|
};
|
|
380
|
-
const considerCandidate = (candidate) => {
|
|
381
|
-
'worklet';
|
|
382
|
-
if (!candidate) {
|
|
383
|
-
return;
|
|
384
|
-
}
|
|
385
|
-
if (__DEV__) {
|
|
386
|
-
console.log('[DocScanner] best so far from', candidate.label, 'area', candidate.area);
|
|
387
|
-
}
|
|
388
|
-
if (!bestCandidate || candidate.area > bestCandidate.area) {
|
|
389
|
-
bestCandidate = candidate;
|
|
390
|
-
}
|
|
391
|
-
};
|
|
392
|
-
const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
|
|
393
|
-
const THRESH_BINARY = 0;
|
|
394
|
-
const THRESH_OTSU = 8;
|
|
395
|
-
step = 'prepareMorphology';
|
|
396
|
-
reportStage(step);
|
|
397
|
-
const morphologyKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
|
|
398
|
-
const element = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', react_native_fast_opencv_1.MorphShapes.MORPH_RECT, morphologyKernel);
|
|
399
|
-
const blurKernelSize = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
|
|
400
|
-
// Edge-preserving smoothing for noisy frames
|
|
401
|
-
step = 'bilateralFilter';
|
|
402
|
-
reportStage(step);
|
|
403
|
-
let filteredMat = mat;
|
|
404
|
-
try {
|
|
405
|
-
const tempMat = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat);
|
|
406
|
-
react_native_fast_opencv_1.OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
|
|
407
|
-
filteredMat = tempMat;
|
|
408
|
-
}
|
|
409
|
-
catch (error) {
|
|
410
|
-
if (__DEV__) {
|
|
411
|
-
console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
step = 'gaussianBlur';
|
|
415
|
-
reportStage(step);
|
|
416
|
-
react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', filteredMat, filteredMat, blurKernelSize, 0);
|
|
417
|
-
step = 'morphologyClose';
|
|
418
|
-
reportStage(step);
|
|
419
|
-
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', filteredMat, filteredMat, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
420
|
-
const baseGray = react_native_fast_opencv_1.OpenCV.invoke('clone', filteredMat);
|
|
421
427
|
const runCanny = (label, low, high) => {
|
|
422
428
|
'worklet';
|
|
423
|
-
const working = react_native_fast_opencv_1.OpenCV.invoke('clone',
|
|
429
|
+
const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseMat);
|
|
424
430
|
step = `${label}_canny`;
|
|
425
431
|
reportStage(step);
|
|
426
432
|
react_native_fast_opencv_1.OpenCV.invoke('Canny', working, working, low, high);
|
|
427
433
|
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', working, working, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
428
434
|
considerCandidate(evaluateContours(working, label));
|
|
429
435
|
};
|
|
430
|
-
|
|
431
|
-
runCanny('canny_soft', Math.max(8, CANNY_LOW * 0.6), CANNY_HIGH * 0.7 + CANNY_LOW * 0.2);
|
|
432
|
-
const runAdaptive = (label, blockSize, c, thresholdMode) => {
|
|
436
|
+
const runAdaptive = (label, blockSize, c) => {
|
|
433
437
|
'worklet';
|
|
434
|
-
const working = react_native_fast_opencv_1.OpenCV.invoke('clone',
|
|
438
|
+
const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseMat);
|
|
435
439
|
step = `${label}_adaptive`;
|
|
436
440
|
reportStage(step);
|
|
437
|
-
|
|
438
|
-
react_native_fast_opencv_1.OpenCV.invoke('threshold', working, working, 0, 255, THRESH_BINARY | THRESH_OTSU);
|
|
439
|
-
}
|
|
440
|
-
else {
|
|
441
|
-
react_native_fast_opencv_1.OpenCV.invoke('adaptiveThreshold', working, working, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, blockSize, c);
|
|
442
|
-
}
|
|
441
|
+
react_native_fast_opencv_1.OpenCV.invoke('adaptiveThreshold', working, working, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, blockSize, c);
|
|
443
442
|
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', working, working, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
444
443
|
considerCandidate(evaluateContours(working, label));
|
|
445
444
|
};
|
|
446
|
-
|
|
447
|
-
|
|
445
|
+
const runOtsu = () => {
|
|
446
|
+
'worklet';
|
|
447
|
+
const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseMat);
|
|
448
|
+
step = 'otsu_threshold';
|
|
449
|
+
reportStage(step);
|
|
450
|
+
react_native_fast_opencv_1.OpenCV.invoke('threshold', working, working, 0, 255, THRESH_BINARY | THRESH_OTSU);
|
|
451
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', working, working, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
452
|
+
considerCandidate(evaluateContours(working, 'otsu'));
|
|
453
|
+
};
|
|
454
|
+
runCanny('canny_primary', CANNY_LOW, CANNY_HIGH);
|
|
455
|
+
runCanny('canny_soft', Math.max(6, CANNY_LOW * 0.6), Math.max(CANNY_LOW * 1.2, CANNY_HIGH * 0.75));
|
|
456
|
+
runCanny('canny_hard', Math.max(12, CANNY_LOW * 1.1), CANNY_HIGH * 1.25);
|
|
457
|
+
runAdaptive('adaptive_19', 19, 7);
|
|
458
|
+
runAdaptive('adaptive_23', 23, 5);
|
|
459
|
+
runOtsu();
|
|
448
460
|
step = 'clearBuffers';
|
|
449
461
|
reportStage(step);
|
|
450
462
|
react_native_fast_opencv_1.OpenCV.clearBuffers();
|
|
451
463
|
step = 'updateQuad';
|
|
452
464
|
reportStage(step);
|
|
453
|
-
|
|
454
|
-
updateQuad(bestCandidate.quad);
|
|
455
|
-
}
|
|
456
|
-
else {
|
|
457
|
-
updateQuad(null);
|
|
458
|
-
}
|
|
465
|
+
updateQuad(bestQuad);
|
|
459
466
|
}
|
|
460
467
|
catch (error) {
|
|
461
468
|
reportError(step, error);
|
package/package.json
CHANGED
package/src/DocScanner.tsx
CHANGED
|
@@ -76,12 +76,6 @@ type CameraRef = {
|
|
|
76
76
|
|
|
77
77
|
type CameraOverrides = Omit<React.ComponentProps<typeof Camera>, 'style' | 'ref' | 'frameProcessor'>;
|
|
78
78
|
|
|
79
|
-
type DetectionCandidate = {
|
|
80
|
-
quad: Point[];
|
|
81
|
-
area: number;
|
|
82
|
-
label: string;
|
|
83
|
-
};
|
|
84
|
-
|
|
85
79
|
/**
|
|
86
80
|
* Configuration for detection quality and behavior
|
|
87
81
|
*/
|
|
@@ -343,9 +337,70 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
343
337
|
reportStage(step);
|
|
344
338
|
OpenCV.invoke('cvtColor', mat, mat, ColorConversionCodes.COLOR_BGR2GRAY);
|
|
345
339
|
|
|
346
|
-
|
|
340
|
+
// Enhanced morphological operations for noise reduction
|
|
341
|
+
const morphologyKernel = OpenCV.createObject(ObjectType.Size, 7, 7);
|
|
342
|
+
step = 'getStructuringElement';
|
|
343
|
+
reportStage(step);
|
|
344
|
+
const element = OpenCV.invoke('getStructuringElement', MorphShapes.MORPH_RECT, morphologyKernel);
|
|
345
|
+
step = 'morphologyEx';
|
|
346
|
+
reportStage(step);
|
|
347
|
+
OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_CLOSE, element);
|
|
348
|
+
OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_OPEN, element);
|
|
349
|
+
|
|
350
|
+
const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
|
|
351
|
+
const THRESH_BINARY = 0;
|
|
352
|
+
const THRESH_OTSU = 8;
|
|
353
|
+
|
|
354
|
+
// Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
|
|
355
|
+
step = 'bilateralFilter';
|
|
356
|
+
reportStage(step);
|
|
357
|
+
let processed = mat;
|
|
358
|
+
try {
|
|
359
|
+
const tempMat = OpenCV.createObject(ObjectType.Mat);
|
|
360
|
+
OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
|
|
361
|
+
processed = tempMat;
|
|
362
|
+
} catch (error) {
|
|
363
|
+
if (__DEV__) {
|
|
364
|
+
console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
|
|
365
|
+
}
|
|
366
|
+
const blurKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
|
|
367
|
+
OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
|
|
368
|
+
processed = mat;
|
|
369
|
+
}
|
|
347
370
|
|
|
348
|
-
|
|
371
|
+
// Additional blur and close pass to smooth jagged edges
|
|
372
|
+
step = 'gaussianBlur';
|
|
373
|
+
reportStage(step);
|
|
374
|
+
const gaussianKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
|
|
375
|
+
OpenCV.invoke('GaussianBlur', processed, processed, gaussianKernel, 0);
|
|
376
|
+
OpenCV.invoke('morphologyEx', processed, processed, MorphTypes.MORPH_CLOSE, element);
|
|
377
|
+
|
|
378
|
+
const baseMat = OpenCV.invoke('clone', processed);
|
|
379
|
+
const frameArea = width * height;
|
|
380
|
+
const originalArea = frame.width * frame.height;
|
|
381
|
+
const minEdgeThreshold = Math.max(14, Math.min(frame.width, frame.height) * MIN_EDGE_RATIO);
|
|
382
|
+
const epsilonValues = [
|
|
383
|
+
0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.007, 0.008, 0.009,
|
|
384
|
+
0.01, 0.012, 0.015, 0.018, 0.02, 0.025, 0.03, 0.035, 0.04, 0.05,
|
|
385
|
+
0.06, 0.07, 0.08, 0.09, 0.1, 0.12,
|
|
386
|
+
];
|
|
387
|
+
|
|
388
|
+
let bestQuad: Point[] | null = null;
|
|
389
|
+
let bestArea = 0;
|
|
390
|
+
let convexHullWarned = false;
|
|
391
|
+
|
|
392
|
+
const considerCandidate = (candidate: { quad: Point[]; area: number } | null) => {
|
|
393
|
+
'worklet';
|
|
394
|
+
if (!candidate) {
|
|
395
|
+
return;
|
|
396
|
+
}
|
|
397
|
+
if (!bestQuad || candidate.area > bestArea) {
|
|
398
|
+
bestQuad = candidate.quad;
|
|
399
|
+
bestArea = candidate.area;
|
|
400
|
+
}
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const evaluateContours = (inputMat: unknown, attemptLabel: string): { quad: Point[]; area: number } | null => {
|
|
349
404
|
'worklet';
|
|
350
405
|
|
|
351
406
|
step = `findContours_${attemptLabel}`;
|
|
@@ -356,10 +411,7 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
356
411
|
const contourVector = OpenCV.toJSValue(contours);
|
|
357
412
|
const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
|
|
358
413
|
|
|
359
|
-
let bestLocal:
|
|
360
|
-
const resizedArea = width * height;
|
|
361
|
-
const originalArea = frame.width * frame.height;
|
|
362
|
-
const minEdgeThreshold = Math.max(16, Math.min(frame.width, frame.height) * 0.012);
|
|
414
|
+
let bestLocal: { quad: Point[]; area: number } | null = null;
|
|
363
415
|
|
|
364
416
|
for (let i = 0; i < contourArray.length; i += 1) {
|
|
365
417
|
step = `${attemptLabel}_contour_${i}_copy`;
|
|
@@ -368,13 +420,13 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
368
420
|
|
|
369
421
|
step = `${attemptLabel}_contour_${i}_area`;
|
|
370
422
|
reportStage(step);
|
|
371
|
-
const { value:
|
|
372
|
-
if (typeof
|
|
423
|
+
const { value: area } = OpenCV.invoke('contourArea', contour, false);
|
|
424
|
+
if (typeof area !== 'number' || !isFinite(area) || area < 60) {
|
|
373
425
|
continue;
|
|
374
426
|
}
|
|
375
427
|
|
|
376
|
-
const
|
|
377
|
-
if (
|
|
428
|
+
const resizedRatio = area / frameArea;
|
|
429
|
+
if (resizedRatio < 0.00012 || resizedRatio > 0.98) {
|
|
378
430
|
continue;
|
|
379
431
|
}
|
|
380
432
|
|
|
@@ -384,21 +436,18 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
384
436
|
OpenCV.invoke('convexHull', contour, hull, false, true);
|
|
385
437
|
contourToUse = hull;
|
|
386
438
|
} catch (err) {
|
|
387
|
-
if (__DEV__) {
|
|
439
|
+
if (__DEV__ && !convexHullWarned) {
|
|
388
440
|
console.warn('[DocScanner] convexHull failed, using original contour');
|
|
441
|
+
convexHullWarned = true;
|
|
389
442
|
}
|
|
390
443
|
}
|
|
391
444
|
|
|
392
445
|
const { value: perimeter } = OpenCV.invoke('arcLength', contourToUse, true);
|
|
393
|
-
if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter <
|
|
446
|
+
if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter < 80) {
|
|
394
447
|
continue;
|
|
395
448
|
}
|
|
396
449
|
|
|
397
450
|
const approx = OpenCV.createObject(ObjectType.PointVector);
|
|
398
|
-
const epsilonValues = [
|
|
399
|
-
0.012, 0.01, 0.008, 0.006, 0.005, 0.004, 0.0035, 0.003, 0.0025, 0.002, 0.0016, 0.0012,
|
|
400
|
-
];
|
|
401
|
-
|
|
402
451
|
let approxArray: Array<{ x: number; y: number }> = [];
|
|
403
452
|
|
|
404
453
|
for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
|
|
@@ -409,7 +458,6 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
409
458
|
|
|
410
459
|
const approxValue = OpenCV.toJSValue(approx);
|
|
411
460
|
const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
|
|
412
|
-
|
|
413
461
|
if (candidate.length === 4) {
|
|
414
462
|
approxArray = candidate as Array<{ x: number; y: number }>;
|
|
415
463
|
break;
|
|
@@ -441,31 +489,26 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
441
489
|
continue;
|
|
442
490
|
}
|
|
443
491
|
|
|
444
|
-
const
|
|
445
|
-
const minEdge = Math.min(...
|
|
446
|
-
const maxEdge = Math.max(...
|
|
492
|
+
const edges = quadEdgeLengths(sanitized);
|
|
493
|
+
const minEdge = Math.min(...edges);
|
|
494
|
+
const maxEdge = Math.max(...edges);
|
|
447
495
|
if (!Number.isFinite(minEdge) || minEdge < minEdgeThreshold) {
|
|
448
496
|
continue;
|
|
449
497
|
}
|
|
450
498
|
const aspectRatio = maxEdge / Math.max(minEdge, 1);
|
|
451
|
-
if (!Number.isFinite(aspectRatio) || aspectRatio >
|
|
499
|
+
if (!Number.isFinite(aspectRatio) || aspectRatio > 8.5) {
|
|
452
500
|
continue;
|
|
453
501
|
}
|
|
454
502
|
|
|
455
503
|
const quadAreaValue = quadArea(sanitized);
|
|
456
|
-
const
|
|
457
|
-
if (
|
|
504
|
+
const originalRatio = originalArea > 0 ? quadAreaValue / originalArea : 0;
|
|
505
|
+
if (originalRatio < 0.00012 || originalRatio > 0.92) {
|
|
458
506
|
continue;
|
|
459
507
|
}
|
|
460
508
|
|
|
461
|
-
|
|
462
|
-
console.log('[DocScanner] candidate', attemptLabel, 'areaRatio', areaRatioOriginal);
|
|
463
|
-
}
|
|
464
|
-
|
|
465
|
-
const candidate: DetectionCandidate = {
|
|
509
|
+
const candidate = {
|
|
466
510
|
quad: sanitized,
|
|
467
511
|
area: quadAreaValue,
|
|
468
|
-
label: attemptLabel,
|
|
469
512
|
};
|
|
470
513
|
|
|
471
514
|
if (!bestLocal || candidate.area > bestLocal.area) {
|
|
@@ -476,56 +519,9 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
476
519
|
return bestLocal;
|
|
477
520
|
};
|
|
478
521
|
|
|
479
|
-
const considerCandidate = (candidate: DetectionCandidate | null) => {
|
|
480
|
-
'worklet';
|
|
481
|
-
if (!candidate) {
|
|
482
|
-
return;
|
|
483
|
-
}
|
|
484
|
-
if (__DEV__) {
|
|
485
|
-
console.log('[DocScanner] best so far from', candidate.label, 'area', candidate.area);
|
|
486
|
-
}
|
|
487
|
-
if (!bestCandidate || candidate.area > bestCandidate.area) {
|
|
488
|
-
bestCandidate = candidate;
|
|
489
|
-
}
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
|
|
493
|
-
const THRESH_BINARY = 0;
|
|
494
|
-
const THRESH_OTSU = 8;
|
|
495
|
-
|
|
496
|
-
step = 'prepareMorphology';
|
|
497
|
-
reportStage(step);
|
|
498
|
-
const morphologyKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
|
|
499
|
-
const element = OpenCV.invoke('getStructuringElement', MorphShapes.MORPH_RECT, morphologyKernel);
|
|
500
|
-
const blurKernelSize = OpenCV.createObject(ObjectType.Size, 5, 5);
|
|
501
|
-
|
|
502
|
-
// Edge-preserving smoothing for noisy frames
|
|
503
|
-
step = 'bilateralFilter';
|
|
504
|
-
reportStage(step);
|
|
505
|
-
let filteredMat = mat;
|
|
506
|
-
try {
|
|
507
|
-
const tempMat = OpenCV.createObject(ObjectType.Mat);
|
|
508
|
-
OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
|
|
509
|
-
filteredMat = tempMat;
|
|
510
|
-
} catch (error) {
|
|
511
|
-
if (__DEV__) {
|
|
512
|
-
console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
|
|
513
|
-
}
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
step = 'gaussianBlur';
|
|
517
|
-
reportStage(step);
|
|
518
|
-
OpenCV.invoke('GaussianBlur', filteredMat, filteredMat, blurKernelSize, 0);
|
|
519
|
-
|
|
520
|
-
step = 'morphologyClose';
|
|
521
|
-
reportStage(step);
|
|
522
|
-
OpenCV.invoke('morphologyEx', filteredMat, filteredMat, MorphTypes.MORPH_CLOSE, element);
|
|
523
|
-
|
|
524
|
-
const baseGray = OpenCV.invoke('clone', filteredMat);
|
|
525
|
-
|
|
526
522
|
const runCanny = (label: string, low: number, high: number) => {
|
|
527
523
|
'worklet';
|
|
528
|
-
const working = OpenCV.invoke('clone',
|
|
524
|
+
const working = OpenCV.invoke('clone', baseMat);
|
|
529
525
|
step = `${label}_canny`;
|
|
530
526
|
reportStage(step);
|
|
531
527
|
OpenCV.invoke('Canny', working, working, low, high);
|
|
@@ -533,36 +529,40 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
533
529
|
considerCandidate(evaluateContours(working, label));
|
|
534
530
|
};
|
|
535
531
|
|
|
536
|
-
|
|
537
|
-
runCanny('canny_soft', Math.max(8, CANNY_LOW * 0.6), CANNY_HIGH * 0.7 + CANNY_LOW * 0.2);
|
|
538
|
-
|
|
539
|
-
const runAdaptive = (label: string, blockSize: number, c: number, thresholdMode: number) => {
|
|
532
|
+
const runAdaptive = (label: string, blockSize: number, c: number) => {
|
|
540
533
|
'worklet';
|
|
541
|
-
const working = OpenCV.invoke('clone',
|
|
534
|
+
const working = OpenCV.invoke('clone', baseMat);
|
|
542
535
|
step = `${label}_adaptive`;
|
|
543
536
|
reportStage(step);
|
|
544
|
-
|
|
545
|
-
OpenCV.invoke('threshold', working, working, 0, 255, THRESH_BINARY | THRESH_OTSU);
|
|
546
|
-
} else {
|
|
547
|
-
OpenCV.invoke('adaptiveThreshold', working, working, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, blockSize, c);
|
|
548
|
-
}
|
|
537
|
+
OpenCV.invoke('adaptiveThreshold', working, working, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, blockSize, c);
|
|
549
538
|
OpenCV.invoke('morphologyEx', working, working, MorphTypes.MORPH_CLOSE, element);
|
|
550
539
|
considerCandidate(evaluateContours(working, label));
|
|
551
540
|
};
|
|
552
541
|
|
|
553
|
-
|
|
554
|
-
|
|
542
|
+
const runOtsu = () => {
|
|
543
|
+
'worklet';
|
|
544
|
+
const working = OpenCV.invoke('clone', baseMat);
|
|
545
|
+
step = 'otsu_threshold';
|
|
546
|
+
reportStage(step);
|
|
547
|
+
OpenCV.invoke('threshold', working, working, 0, 255, THRESH_BINARY | THRESH_OTSU);
|
|
548
|
+
OpenCV.invoke('morphologyEx', working, working, MorphTypes.MORPH_CLOSE, element);
|
|
549
|
+
considerCandidate(evaluateContours(working, 'otsu'));
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
runCanny('canny_primary', CANNY_LOW, CANNY_HIGH);
|
|
553
|
+
runCanny('canny_soft', Math.max(6, CANNY_LOW * 0.6), Math.max(CANNY_LOW * 1.2, CANNY_HIGH * 0.75));
|
|
554
|
+
runCanny('canny_hard', Math.max(12, CANNY_LOW * 1.1), CANNY_HIGH * 1.25);
|
|
555
|
+
|
|
556
|
+
runAdaptive('adaptive_19', 19, 7);
|
|
557
|
+
runAdaptive('adaptive_23', 23, 5);
|
|
558
|
+
runOtsu();
|
|
555
559
|
|
|
556
560
|
step = 'clearBuffers';
|
|
557
561
|
reportStage(step);
|
|
558
562
|
OpenCV.clearBuffers();
|
|
559
563
|
step = 'updateQuad';
|
|
560
564
|
reportStage(step);
|
|
561
|
-
|
|
562
|
-
updateQuad((bestCandidate as DetectionCandidate).quad);
|
|
563
|
-
} else {
|
|
564
|
-
updateQuad(null);
|
|
565
|
-
}
|
|
565
|
+
updateQuad(bestQuad);
|
|
566
566
|
} catch (error) {
|
|
567
567
|
reportError(step, error);
|
|
568
568
|
}
|