react-native-rectangle-doc-scanner 0.58.0 → 0.59.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.
@@ -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
- let bestCandidate = null;
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: rawArea } = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour, false);
294
- if (typeof rawArea !== 'number' || !isFinite(rawArea) || rawArea < 40) {
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 resizedAreaRatio = rawArea / resizedArea;
298
- if (resizedAreaRatio < 0.0001 || resizedAreaRatio > 0.97) {
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 < 40) {
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 quadEdges = (0, quad_1.quadEdgeLengths)(sanitized);
352
- const minEdge = Math.min(...quadEdges);
353
- const maxEdge = Math.max(...quadEdges);
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 > 9) {
409
+ if (!Number.isFinite(aspectRatio) || aspectRatio > 8.5) {
359
410
  continue;
360
411
  }
361
412
  const quadAreaValue = (0, quad_1.quadArea)(sanitized);
362
- const areaRatioOriginal = originalArea > 0 ? quadAreaValue / originalArea : 0;
363
- if (areaRatioOriginal < 0.00008 || areaRatioOriginal > 0.92) {
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', baseGray);
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
- 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) => {
436
+ const runAdaptive = (label, blockSize, c) => {
433
437
  'worklet';
434
- const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseGray);
438
+ const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseMat);
435
439
  step = `${label}_adaptive`;
436
440
  reportStage(step);
437
- if (thresholdMode === THRESH_OTSU) {
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
- runAdaptive('adaptive', 19, 7, THRESH_BINARY);
447
- runAdaptive('otsu', 0, 0, THRESH_OTSU);
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
- if (bestCandidate) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "0.58.0",
3
+ "version": "0.59.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {
@@ -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
- let bestCandidate: DetectionCandidate | null = null;
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
- const evaluateContours = (inputMat: unknown, attemptLabel: string): DetectionCandidate | null => {
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: 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);
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: rawArea } = OpenCV.invoke('contourArea', contour, false);
372
- if (typeof rawArea !== 'number' || !isFinite(rawArea) || rawArea < 40) {
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 resizedAreaRatio = rawArea / resizedArea;
377
- if (resizedAreaRatio < 0.0001 || resizedAreaRatio > 0.97) {
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 < 40) {
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 quadEdges = quadEdgeLengths(sanitized);
445
- const minEdge = Math.min(...quadEdges);
446
- const maxEdge = Math.max(...quadEdges);
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 > 9) {
499
+ if (!Number.isFinite(aspectRatio) || aspectRatio > 8.5) {
452
500
  continue;
453
501
  }
454
502
 
455
503
  const quadAreaValue = quadArea(sanitized);
456
- const areaRatioOriginal = originalArea > 0 ? quadAreaValue / originalArea : 0;
457
- if (areaRatioOriginal < 0.00008 || areaRatioOriginal > 0.92) {
504
+ const originalRatio = originalArea > 0 ? quadAreaValue / originalArea : 0;
505
+ if (originalRatio < 0.00012 || originalRatio > 0.92) {
458
506
  continue;
459
507
  }
460
508
 
461
- if (__DEV__) {
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', baseGray);
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
- 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) => {
532
+ const runAdaptive = (label: string, blockSize: number, c: number) => {
540
533
  'worklet';
541
- const working = OpenCV.invoke('clone', baseGray);
534
+ const working = OpenCV.invoke('clone', baseMat);
542
535
  step = `${label}_adaptive`;
543
536
  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
- }
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
- runAdaptive('adaptive', 19, 7, THRESH_BINARY);
554
- runAdaptive('otsu', 0, 0, THRESH_OTSU);
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
- if (bestCandidate) {
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
  }