react-native-rectangle-doc-scanner 0.57.0 → 0.58.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 +161 -134
- package/package.json +1 -1
- package/src/DocScanner.tsx +184 -141
package/dist/DocScanner.js
CHANGED
|
@@ -271,164 +271,191 @@ 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
|
-
|
|
275
|
-
const
|
|
276
|
-
|
|
274
|
+
let bestCandidate = null;
|
|
275
|
+
const evaluateContours = (inputMat, attemptLabel) => {
|
|
276
|
+
'worklet';
|
|
277
|
+
step = `findContours_${attemptLabel}`;
|
|
278
|
+
reportStage(step);
|
|
279
|
+
const contours = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVectorOfVectors);
|
|
280
|
+
react_native_fast_opencv_1.OpenCV.invoke('findContours', inputMat, contours, react_native_fast_opencv_1.RetrievalModes.RETR_EXTERNAL, react_native_fast_opencv_1.ContourApproximationModes.CHAIN_APPROX_SIMPLE);
|
|
281
|
+
const contourVector = react_native_fast_opencv_1.OpenCV.toJSValue(contours);
|
|
282
|
+
const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
|
|
283
|
+
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
|
+
for (let i = 0; i < contourArray.length; i += 1) {
|
|
288
|
+
step = `${attemptLabel}_contour_${i}_copy`;
|
|
289
|
+
reportStage(step);
|
|
290
|
+
const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contours, i);
|
|
291
|
+
step = `${attemptLabel}_contour_${i}_area`;
|
|
292
|
+
reportStage(step);
|
|
293
|
+
const { value: rawArea } = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour, false);
|
|
294
|
+
if (typeof rawArea !== 'number' || !isFinite(rawArea) || rawArea < 40) {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
const resizedAreaRatio = rawArea / resizedArea;
|
|
298
|
+
if (resizedAreaRatio < 0.0001 || resizedAreaRatio > 0.97) {
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
let contourToUse = contour;
|
|
302
|
+
try {
|
|
303
|
+
const hull = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
304
|
+
react_native_fast_opencv_1.OpenCV.invoke('convexHull', contour, hull, false, true);
|
|
305
|
+
contourToUse = hull;
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
if (__DEV__) {
|
|
309
|
+
console.warn('[DocScanner] convexHull failed, using original contour');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
const { value: perimeter } = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contourToUse, true);
|
|
313
|
+
if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter < 40) {
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
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
|
+
let approxArray = [];
|
|
321
|
+
for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
|
|
322
|
+
const epsilon = epsilonValues[attempt] * perimeter;
|
|
323
|
+
step = `${attemptLabel}_contour_${i}_approx_${attempt}`;
|
|
324
|
+
reportStage(step);
|
|
325
|
+
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
|
|
326
|
+
const approxValue = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
|
|
327
|
+
const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
|
|
328
|
+
if (candidate.length === 4) {
|
|
329
|
+
approxArray = candidate;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
if (approxArray.length !== 4) {
|
|
334
|
+
continue;
|
|
335
|
+
}
|
|
336
|
+
const isValidPoint = (pt) => typeof pt.x === 'number' && typeof pt.y === 'number' && isFinite(pt.x) && isFinite(pt.y);
|
|
337
|
+
if (!approxArray.every(isValidPoint)) {
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
const normalizedPoints = approxArray.map((pt) => ({
|
|
341
|
+
x: pt.x / ratio,
|
|
342
|
+
y: pt.y / ratio,
|
|
343
|
+
}));
|
|
344
|
+
if (!isConvexQuadrilateral(normalizedPoints)) {
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
const sanitized = (0, quad_1.sanitizeQuad)((0, quad_1.orderQuadPoints)(normalizedPoints));
|
|
348
|
+
if (!(0, quad_1.isValidQuad)(sanitized)) {
|
|
349
|
+
continue;
|
|
350
|
+
}
|
|
351
|
+
const quadEdges = (0, quad_1.quadEdgeLengths)(sanitized);
|
|
352
|
+
const minEdge = Math.min(...quadEdges);
|
|
353
|
+
const maxEdge = Math.max(...quadEdges);
|
|
354
|
+
if (!Number.isFinite(minEdge) || minEdge < minEdgeThreshold) {
|
|
355
|
+
continue;
|
|
356
|
+
}
|
|
357
|
+
const aspectRatio = maxEdge / Math.max(minEdge, 1);
|
|
358
|
+
if (!Number.isFinite(aspectRatio) || aspectRatio > 9) {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
const quadAreaValue = (0, quad_1.quadArea)(sanitized);
|
|
362
|
+
const areaRatioOriginal = originalArea > 0 ? quadAreaValue / originalArea : 0;
|
|
363
|
+
if (areaRatioOriginal < 0.00008 || areaRatioOriginal > 0.92) {
|
|
364
|
+
continue;
|
|
365
|
+
}
|
|
366
|
+
if (__DEV__) {
|
|
367
|
+
console.log('[DocScanner] candidate', attemptLabel, 'areaRatio', areaRatioOriginal);
|
|
368
|
+
}
|
|
369
|
+
const candidate = {
|
|
370
|
+
quad: sanitized,
|
|
371
|
+
area: quadAreaValue,
|
|
372
|
+
label: attemptLabel,
|
|
373
|
+
};
|
|
374
|
+
if (!bestLocal || candidate.area > bestLocal.area) {
|
|
375
|
+
bestLocal = candidate;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
return bestLocal;
|
|
379
|
+
};
|
|
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';
|
|
277
396
|
reportStage(step);
|
|
397
|
+
const morphologyKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
|
|
278
398
|
const element = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', react_native_fast_opencv_1.MorphShapes.MORPH_RECT, morphologyKernel);
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
// MORPH_CLOSE to fill small holes in edges
|
|
282
|
-
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', mat, mat, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
283
|
-
// MORPH_OPEN to remove small noise
|
|
284
|
-
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', mat, mat, react_native_fast_opencv_1.MorphTypes.MORPH_OPEN, element);
|
|
285
|
-
// Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
|
|
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
|
|
286
401
|
step = 'bilateralFilter';
|
|
287
402
|
reportStage(step);
|
|
403
|
+
let filteredMat = mat;
|
|
288
404
|
try {
|
|
289
405
|
const tempMat = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat);
|
|
290
406
|
react_native_fast_opencv_1.OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
|
|
291
|
-
|
|
407
|
+
filteredMat = tempMat;
|
|
292
408
|
}
|
|
293
409
|
catch (error) {
|
|
294
410
|
if (__DEV__) {
|
|
295
411
|
console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
|
|
296
412
|
}
|
|
297
|
-
step = 'gaussianBlurFallback';
|
|
298
|
-
reportStage(step);
|
|
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
413
|
}
|
|
302
|
-
step = '
|
|
303
|
-
reportStage(step);
|
|
304
|
-
// Configurable Canny parameters for adaptive edge detection
|
|
305
|
-
react_native_fast_opencv_1.OpenCV.invoke('Canny', mat, mat, CANNY_LOW, CANNY_HIGH);
|
|
306
|
-
step = 'createContours';
|
|
414
|
+
step = 'gaussianBlur';
|
|
307
415
|
reportStage(step);
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
let best = null;
|
|
311
|
-
let maxArea = 0;
|
|
312
|
-
const frameArea = width * height;
|
|
313
|
-
step = 'toJSValue';
|
|
416
|
+
react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', filteredMat, filteredMat, blurKernelSize, 0);
|
|
417
|
+
step = 'morphologyClose';
|
|
314
418
|
reportStage(step);
|
|
315
|
-
|
|
316
|
-
const
|
|
317
|
-
|
|
318
|
-
|
|
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
|
+
const runCanny = (label, low, high) => {
|
|
422
|
+
'worklet';
|
|
423
|
+
const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseGray);
|
|
424
|
+
step = `${label}_canny`;
|
|
319
425
|
reportStage(step);
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
continue;
|
|
331
|
-
}
|
|
332
|
-
step = `contour_${i}_area`; // ratio stage
|
|
426
|
+
react_native_fast_opencv_1.OpenCV.invoke('Canny', working, working, low, high);
|
|
427
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', working, working, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
428
|
+
considerCandidate(evaluateContours(working, label));
|
|
429
|
+
};
|
|
430
|
+
runCanny('canny_primary', CANNY_LOW, CANNY_HIGH);
|
|
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) => {
|
|
433
|
+
'worklet';
|
|
434
|
+
const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseGray);
|
|
435
|
+
step = `${label}_adaptive`;
|
|
333
436
|
reportStage(step);
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
console.log('[DocScanner] area', area, 'ratio', areaRatio);
|
|
337
|
-
}
|
|
338
|
-
// Skip if area ratio is too small or too large
|
|
339
|
-
if (areaRatio < 0.0002 || areaRatio > 0.99) {
|
|
340
|
-
continue;
|
|
341
|
-
}
|
|
342
|
-
// Try to use convex hull for better corner detection
|
|
343
|
-
let contourToUse = contour;
|
|
344
|
-
try {
|
|
345
|
-
step = `contour_${i}_convexHull`;
|
|
346
|
-
reportStage(step);
|
|
347
|
-
const hull = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
348
|
-
react_native_fast_opencv_1.OpenCV.invoke('convexHull', contour, hull, false, true);
|
|
349
|
-
contourToUse = hull;
|
|
437
|
+
if (thresholdMode === THRESH_OTSU) {
|
|
438
|
+
react_native_fast_opencv_1.OpenCV.invoke('threshold', working, working, 0, 255, THRESH_BINARY | THRESH_OTSU);
|
|
350
439
|
}
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
if (__DEV__) {
|
|
354
|
-
console.warn('[DocScanner] convexHull failed, using original contour');
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
step = `contour_${i}_arcLength`;
|
|
358
|
-
reportStage(step);
|
|
359
|
-
const { value: perimeter } = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contourToUse, true);
|
|
360
|
-
const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
|
|
361
|
-
let approxArray = [];
|
|
362
|
-
// Try more epsilon values from 0.1% to 10% for difficult shapes
|
|
363
|
-
const epsilonValues = [
|
|
364
|
-
0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.007, 0.008, 0.009,
|
|
365
|
-
0.01, 0.012, 0.015, 0.018, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1
|
|
366
|
-
];
|
|
367
|
-
for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
|
|
368
|
-
const epsilon = epsilonValues[attempt] * perimeter;
|
|
369
|
-
step = `contour_${i}_approxPolyDP_attempt_${attempt}`;
|
|
370
|
-
reportStage(step);
|
|
371
|
-
react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
|
|
372
|
-
step = `contour_${i}_toJS_attempt_${attempt}`;
|
|
373
|
-
reportStage(step);
|
|
374
|
-
const approxValue = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
|
|
375
|
-
const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
|
|
376
|
-
if (__DEV__) {
|
|
377
|
-
console.log('[DocScanner] approx length', candidate.length, 'epsilon', epsilon);
|
|
378
|
-
}
|
|
379
|
-
if (candidate.length === 4) {
|
|
380
|
-
approxArray = candidate;
|
|
381
|
-
break;
|
|
382
|
-
}
|
|
440
|
+
else {
|
|
441
|
+
react_native_fast_opencv_1.OpenCV.invoke('adaptiveThreshold', working, working, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, blockSize, c);
|
|
383
442
|
}
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
reportStage(step);
|
|
390
|
-
// Validate points before processing
|
|
391
|
-
const isValidPoint = (pt) => {
|
|
392
|
-
return typeof pt.x === 'number' && typeof pt.y === 'number' &&
|
|
393
|
-
!isNaN(pt.x) && !isNaN(pt.y) &&
|
|
394
|
-
isFinite(pt.x) && isFinite(pt.y);
|
|
395
|
-
};
|
|
396
|
-
if (!approxArray.every(isValidPoint)) {
|
|
397
|
-
if (__DEV__) {
|
|
398
|
-
console.warn('[DocScanner] invalid points in approxArray', approxArray);
|
|
399
|
-
}
|
|
400
|
-
continue;
|
|
401
|
-
}
|
|
402
|
-
const points = approxArray.map((pt) => ({
|
|
403
|
-
x: pt.x / ratio,
|
|
404
|
-
y: pt.y / ratio,
|
|
405
|
-
}));
|
|
406
|
-
// Verify the quadrilateral is convex (valid document shape)
|
|
407
|
-
try {
|
|
408
|
-
if (!isConvexQuadrilateral(points)) {
|
|
409
|
-
if (__DEV__) {
|
|
410
|
-
console.log('[DocScanner] not convex, skipping:', points);
|
|
411
|
-
}
|
|
412
|
-
continue;
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
catch (err) {
|
|
416
|
-
if (__DEV__) {
|
|
417
|
-
console.warn('[DocScanner] convex check error:', err, 'points:', points);
|
|
418
|
-
}
|
|
419
|
-
continue;
|
|
420
|
-
}
|
|
421
|
-
if (area > maxArea) {
|
|
422
|
-
best = points;
|
|
423
|
-
maxArea = area;
|
|
424
|
-
}
|
|
425
|
-
}
|
|
443
|
+
react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', working, working, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
|
|
444
|
+
considerCandidate(evaluateContours(working, label));
|
|
445
|
+
};
|
|
446
|
+
runAdaptive('adaptive', 19, 7, THRESH_BINARY);
|
|
447
|
+
runAdaptive('otsu', 0, 0, THRESH_OTSU);
|
|
426
448
|
step = 'clearBuffers';
|
|
427
449
|
reportStage(step);
|
|
428
450
|
react_native_fast_opencv_1.OpenCV.clearBuffers();
|
|
429
451
|
step = 'updateQuad';
|
|
430
452
|
reportStage(step);
|
|
431
|
-
|
|
453
|
+
if (bestCandidate) {
|
|
454
|
+
updateQuad(bestCandidate.quad);
|
|
455
|
+
}
|
|
456
|
+
else {
|
|
457
|
+
updateQuad(null);
|
|
458
|
+
}
|
|
432
459
|
}
|
|
433
460
|
catch (error) {
|
|
434
461
|
reportError(step, error);
|
package/package.json
CHANGED
package/src/DocScanner.tsx
CHANGED
|
@@ -76,6 +76,12 @@ 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
|
+
|
|
79
85
|
/**
|
|
80
86
|
* Configuration for detection quality and behavior
|
|
81
87
|
*/
|
|
@@ -337,189 +343,226 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
337
343
|
reportStage(step);
|
|
338
344
|
OpenCV.invoke('cvtColor', mat, mat, ColorConversionCodes.COLOR_BGR2GRAY);
|
|
339
345
|
|
|
340
|
-
|
|
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
|
-
// MORPH_CLOSE to fill small holes in edges
|
|
348
|
-
OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_CLOSE, element);
|
|
349
|
-
// MORPH_OPEN to remove small noise
|
|
350
|
-
OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_OPEN, element);
|
|
346
|
+
let bestCandidate: DetectionCandidate | null = null;
|
|
351
347
|
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
const tempMat = OpenCV.createObject(ObjectType.Mat);
|
|
357
|
-
OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
|
|
358
|
-
mat = tempMat;
|
|
359
|
-
} catch (error) {
|
|
360
|
-
if (__DEV__) {
|
|
361
|
-
console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
|
|
362
|
-
}
|
|
363
|
-
step = 'gaussianBlurFallback';
|
|
348
|
+
const evaluateContours = (inputMat: unknown, attemptLabel: string): DetectionCandidate | null => {
|
|
349
|
+
'worklet';
|
|
350
|
+
|
|
351
|
+
step = `findContours_${attemptLabel}`;
|
|
364
352
|
reportStage(step);
|
|
365
|
-
const
|
|
366
|
-
OpenCV.invoke('
|
|
367
|
-
}
|
|
353
|
+
const contours = OpenCV.createObject(ObjectType.PointVectorOfVectors);
|
|
354
|
+
OpenCV.invoke('findContours', inputMat, contours, RetrievalModes.RETR_EXTERNAL, ContourApproximationModes.CHAIN_APPROX_SIMPLE);
|
|
368
355
|
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
// Configurable Canny parameters for adaptive edge detection
|
|
372
|
-
OpenCV.invoke('Canny', mat, mat, CANNY_LOW, CANNY_HIGH);
|
|
356
|
+
const contourVector = OpenCV.toJSValue(contours);
|
|
357
|
+
const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
|
|
373
358
|
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
359
|
+
let bestLocal: DetectionCandidate | null = null;
|
|
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);
|
|
378
363
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
364
|
+
for (let i = 0; i < contourArray.length; i += 1) {
|
|
365
|
+
step = `${attemptLabel}_contour_${i}_copy`;
|
|
366
|
+
reportStage(step);
|
|
367
|
+
const contour = OpenCV.copyObjectFromVector(contours, i);
|
|
382
368
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
369
|
+
step = `${attemptLabel}_contour_${i}_area`;
|
|
370
|
+
reportStage(step);
|
|
371
|
+
const { value: rawArea } = OpenCV.invoke('contourArea', contour, false);
|
|
372
|
+
if (typeof rawArea !== 'number' || !isFinite(rawArea) || rawArea < 40) {
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
387
375
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
376
|
+
const resizedAreaRatio = rawArea / resizedArea;
|
|
377
|
+
if (resizedAreaRatio < 0.0001 || resizedAreaRatio > 0.97) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
392
380
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
381
|
+
let contourToUse = contour;
|
|
382
|
+
try {
|
|
383
|
+
const hull = OpenCV.createObject(ObjectType.PointVector);
|
|
384
|
+
OpenCV.invoke('convexHull', contour, hull, false, true);
|
|
385
|
+
contourToUse = hull;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (__DEV__) {
|
|
388
|
+
console.warn('[DocScanner] convexHull failed, using original contour');
|
|
389
|
+
}
|
|
390
|
+
}
|
|
397
391
|
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
392
|
+
const { value: perimeter } = OpenCV.invoke('arcLength', contourToUse, true);
|
|
393
|
+
if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter < 40) {
|
|
394
|
+
continue;
|
|
395
|
+
}
|
|
402
396
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
397
|
+
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
|
+
];
|
|
406
401
|
|
|
407
|
-
|
|
408
|
-
reportStage(step);
|
|
409
|
-
const areaRatio = area / frameArea;
|
|
402
|
+
let approxArray: Array<{ x: number; y: number }> = [];
|
|
410
403
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
404
|
+
for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
|
|
405
|
+
const epsilon = epsilonValues[attempt] * perimeter;
|
|
406
|
+
step = `${attemptLabel}_contour_${i}_approx_${attempt}`;
|
|
407
|
+
reportStage(step);
|
|
408
|
+
OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
|
|
414
409
|
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
continue;
|
|
418
|
-
}
|
|
410
|
+
const approxValue = OpenCV.toJSValue(approx);
|
|
411
|
+
const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
|
|
419
412
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
reportStage(step);
|
|
425
|
-
const hull = OpenCV.createObject(ObjectType.PointVector);
|
|
426
|
-
OpenCV.invoke('convexHull', contour, hull, false, true);
|
|
427
|
-
contourToUse = hull;
|
|
428
|
-
} catch (err) {
|
|
429
|
-
// If convexHull fails, use original contour
|
|
430
|
-
if (__DEV__) {
|
|
431
|
-
console.warn('[DocScanner] convexHull failed, using original contour');
|
|
413
|
+
if (candidate.length === 4) {
|
|
414
|
+
approxArray = candidate as Array<{ x: number; y: number }>;
|
|
415
|
+
break;
|
|
416
|
+
}
|
|
432
417
|
}
|
|
433
|
-
}
|
|
434
418
|
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
const approx = OpenCV.createObject(ObjectType.PointVector);
|
|
419
|
+
if (approxArray.length !== 4) {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
439
422
|
|
|
440
|
-
|
|
423
|
+
const isValidPoint = (pt: { x: number; y: number }) =>
|
|
424
|
+
typeof pt.x === 'number' && typeof pt.y === 'number' && isFinite(pt.x) && isFinite(pt.y);
|
|
441
425
|
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
0.01, 0.012, 0.015, 0.018, 0.02, 0.025, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1
|
|
446
|
-
];
|
|
426
|
+
if (!approxArray.every(isValidPoint)) {
|
|
427
|
+
continue;
|
|
428
|
+
}
|
|
447
429
|
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
|
|
430
|
+
const normalizedPoints: Point[] = approxArray.map((pt) => ({
|
|
431
|
+
x: pt.x / ratio,
|
|
432
|
+
y: pt.y / ratio,
|
|
433
|
+
}));
|
|
453
434
|
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
|
|
435
|
+
if (!isConvexQuadrilateral(normalizedPoints)) {
|
|
436
|
+
continue;
|
|
437
|
+
}
|
|
458
438
|
|
|
459
|
-
|
|
460
|
-
|
|
439
|
+
const sanitized = sanitizeQuad(orderQuadPoints(normalizedPoints));
|
|
440
|
+
if (!isValidQuad(sanitized)) {
|
|
441
|
+
continue;
|
|
461
442
|
}
|
|
462
443
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
444
|
+
const quadEdges = quadEdgeLengths(sanitized);
|
|
445
|
+
const minEdge = Math.min(...quadEdges);
|
|
446
|
+
const maxEdge = Math.max(...quadEdges);
|
|
447
|
+
if (!Number.isFinite(minEdge) || minEdge < minEdgeThreshold) {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
const aspectRatio = maxEdge / Math.max(minEdge, 1);
|
|
451
|
+
if (!Number.isFinite(aspectRatio) || aspectRatio > 9) {
|
|
452
|
+
continue;
|
|
466
453
|
}
|
|
467
|
-
}
|
|
468
454
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
455
|
+
const quadAreaValue = quadArea(sanitized);
|
|
456
|
+
const areaRatioOriginal = originalArea > 0 ? quadAreaValue / originalArea : 0;
|
|
457
|
+
if (areaRatioOriginal < 0.00008 || areaRatioOriginal > 0.92) {
|
|
458
|
+
continue;
|
|
459
|
+
}
|
|
473
460
|
|
|
474
|
-
|
|
475
|
-
|
|
461
|
+
if (__DEV__) {
|
|
462
|
+
console.log('[DocScanner] candidate', attemptLabel, 'areaRatio', areaRatioOriginal);
|
|
463
|
+
}
|
|
476
464
|
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
};
|
|
465
|
+
const candidate: DetectionCandidate = {
|
|
466
|
+
quad: sanitized,
|
|
467
|
+
area: quadAreaValue,
|
|
468
|
+
label: attemptLabel,
|
|
469
|
+
};
|
|
483
470
|
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
console.warn('[DocScanner] invalid points in approxArray', approxArray);
|
|
471
|
+
if (!bestLocal || candidate.area > bestLocal.area) {
|
|
472
|
+
bestLocal = candidate;
|
|
487
473
|
}
|
|
488
|
-
continue;
|
|
489
474
|
}
|
|
490
475
|
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
y: pt.y / ratio,
|
|
494
|
-
}));
|
|
476
|
+
return bestLocal;
|
|
477
|
+
};
|
|
495
478
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
console.warn('[DocScanner] convex check error:', err, 'points:', points);
|
|
507
|
-
}
|
|
508
|
-
continue;
|
|
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;
|
|
509
489
|
}
|
|
490
|
+
};
|
|
510
491
|
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
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);
|
|
514
513
|
}
|
|
515
514
|
}
|
|
516
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
|
+
const runCanny = (label: string, low: number, high: number) => {
|
|
527
|
+
'worklet';
|
|
528
|
+
const working = OpenCV.invoke('clone', baseGray);
|
|
529
|
+
step = `${label}_canny`;
|
|
530
|
+
reportStage(step);
|
|
531
|
+
OpenCV.invoke('Canny', working, working, low, high);
|
|
532
|
+
OpenCV.invoke('morphologyEx', working, working, MorphTypes.MORPH_CLOSE, element);
|
|
533
|
+
considerCandidate(evaluateContours(working, label));
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
runCanny('canny_primary', CANNY_LOW, CANNY_HIGH);
|
|
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) => {
|
|
540
|
+
'worklet';
|
|
541
|
+
const working = OpenCV.invoke('clone', baseGray);
|
|
542
|
+
step = `${label}_adaptive`;
|
|
543
|
+
reportStage(step);
|
|
544
|
+
if (thresholdMode === THRESH_OTSU) {
|
|
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
|
+
}
|
|
549
|
+
OpenCV.invoke('morphologyEx', working, working, MorphTypes.MORPH_CLOSE, element);
|
|
550
|
+
considerCandidate(evaluateContours(working, label));
|
|
551
|
+
};
|
|
552
|
+
|
|
553
|
+
runAdaptive('adaptive', 19, 7, THRESH_BINARY);
|
|
554
|
+
runAdaptive('otsu', 0, 0, THRESH_OTSU);
|
|
555
|
+
|
|
517
556
|
step = 'clearBuffers';
|
|
518
557
|
reportStage(step);
|
|
519
558
|
OpenCV.clearBuffers();
|
|
520
559
|
step = 'updateQuad';
|
|
521
560
|
reportStage(step);
|
|
522
|
-
|
|
561
|
+
if (bestCandidate) {
|
|
562
|
+
updateQuad((bestCandidate as DetectionCandidate).quad);
|
|
563
|
+
} else {
|
|
564
|
+
updateQuad(null);
|
|
565
|
+
}
|
|
523
566
|
} catch (error) {
|
|
524
567
|
reportError(step, error);
|
|
525
568
|
}
|