react-native-rectangle-doc-scanner 0.60.0 → 0.64.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.
@@ -278,191 +278,157 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
278
278
  const element = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', react_native_fast_opencv_1.MorphShapes.MORPH_RECT, morphologyKernel);
279
279
  step = 'morphologyEx';
280
280
  reportStage(step);
281
+ // MORPH_CLOSE to fill small holes in edges
281
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
282
284
  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
285
  // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
287
286
  step = 'bilateralFilter';
288
287
  reportStage(step);
289
- let processed = mat;
290
288
  try {
291
289
  const tempMat = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat);
292
290
  react_native_fast_opencv_1.OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
293
- processed = tempMat;
291
+ mat = tempMat;
294
292
  }
295
293
  catch (error) {
296
294
  if (__DEV__) {
297
295
  console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
298
296
  }
297
+ step = 'gaussianBlurFallback';
298
+ reportStage(step);
299
299
  const blurKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
300
300
  react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
301
- processed = mat;
302
301
  }
303
- // Additional blur and close pass to smooth jagged edges
304
- step = 'gaussianBlur';
302
+ step = 'Canny';
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';
305
307
  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);
308
+ const contours = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVectorOfVectors);
309
+ react_native_fast_opencv_1.OpenCV.invoke('findContours', mat, contours, react_native_fast_opencv_1.RetrievalModes.RETR_EXTERNAL, react_native_fast_opencv_1.ContourApproximationModes.CHAIN_APPROX_SIMPLE);
310
+ let best = null;
311
+ let maxArea = 0;
310
312
  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;
313
+ step = 'toJSValue';
314
+ reportStage(step);
315
+ const contourVector = react_native_fast_opencv_1.OpenCV.toJSValue(contours);
316
+ const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
317
+ for (let i = 0; i < contourArray.length; i += 1) {
318
+ step = `contour_${i}_copy`;
319
+ reportStage(step);
320
+ const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contours, i);
321
+ // Compute absolute area first
322
+ step = `contour_${i}_area_abs`;
323
+ reportStage(step);
324
+ const { value: area } = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour, false);
325
+ // Skip extremely small contours, but keep threshold very low to allow distant documents
326
+ if (typeof area !== 'number' || !isFinite(area)) {
327
+ continue;
325
328
  }
326
- if (!bestQuad || candidate.area > bestArea) {
327
- bestQuad = candidate.quad;
328
- bestArea = candidate.area;
329
+ if (area < 50) {
330
+ continue;
329
331
  }
330
- };
331
- const evaluateContours = (inputMat, attemptLabel) => {
332
- 'worklet';
333
- step = `findContours_${attemptLabel}`;
332
+ step = `contour_${i}_area`; // ratio stage
334
333
  reportStage(step);
335
- const contours = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVectorOfVectors);
336
- 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);
337
- const contourVector = react_native_fast_opencv_1.OpenCV.toJSValue(contours);
338
- const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
339
- let bestLocal = null;
340
- for (let i = 0; i < contourArray.length; i += 1) {
341
- step = `${attemptLabel}_contour_${i}_copy`;
342
- reportStage(step);
343
- const contour = react_native_fast_opencv_1.OpenCV.copyObjectFromVector(contours, i);
344
- step = `${attemptLabel}_contour_${i}_area`;
334
+ const areaRatio = area / frameArea;
335
+ if (__DEV__) {
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`;
345
346
  reportStage(step);
346
- const { value: area } = react_native_fast_opencv_1.OpenCV.invoke('contourArea', contour, false);
347
- if (typeof area !== 'number' || !isFinite(area) || area < 60) {
348
- continue;
349
- }
350
- const resizedRatio = area / frameArea;
351
- if (resizedRatio < 0.00012 || resizedRatio > 0.98) {
352
- continue;
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;
350
+ }
351
+ catch (err) {
352
+ // If convexHull fails, use original contour
353
+ if (__DEV__) {
354
+ console.warn('[DocScanner] convexHull failed, using original contour');
353
355
  }
354
- let contourToUse = contour;
355
- try {
356
- const hull = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
357
- react_native_fast_opencv_1.OpenCV.invoke('convexHull', contour, hull, false, true);
358
- contourToUse = hull;
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);
359
378
  }
360
- catch (err) {
361
- if (__DEV__ && !convexHullWarned) {
362
- console.warn('[DocScanner] convexHull failed, using original contour');
363
- convexHullWarned = true;
364
- }
379
+ if (candidate.length === 4) {
380
+ approxArray = candidate;
381
+ break;
365
382
  }
366
- const { value: perimeter } = react_native_fast_opencv_1.OpenCV.invoke('arcLength', contourToUse, true);
367
- if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter < 80) {
368
- continue;
383
+ }
384
+ // Only proceed if we found exactly 4 corners
385
+ if (approxArray.length !== 4) {
386
+ continue;
387
+ }
388
+ step = `contour_${i}_convex`;
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);
369
399
  }
370
- const approx = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVector);
371
- let approxArray = [];
372
- for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
373
- const epsilon = epsilonValues[attempt] * perimeter;
374
- step = `${attemptLabel}_contour_${i}_approx_${attempt}`;
375
- reportStage(step);
376
- react_native_fast_opencv_1.OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
377
- const approxValue = react_native_fast_opencv_1.OpenCV.toJSValue(approx);
378
- const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
379
- if (candidate.length === 4) {
380
- approxArray = candidate;
381
- break;
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);
382
411
  }
383
- }
384
- if (approxArray.length !== 4) {
385
- continue;
386
- }
387
- const isValidPoint = (pt) => typeof pt.x === 'number' && typeof pt.y === 'number' && isFinite(pt.x) && isFinite(pt.y);
388
- if (!approxArray.every(isValidPoint)) {
389
- continue;
390
- }
391
- const normalizedPoints = approxArray.map((pt) => ({
392
- x: pt.x / ratio,
393
- y: pt.y / ratio,
394
- }));
395
- if (!isConvexQuadrilateral(normalizedPoints)) {
396
- continue;
397
- }
398
- const sanitized = (0, quad_1.sanitizeQuad)((0, quad_1.orderQuadPoints)(normalizedPoints));
399
- if (!(0, quad_1.isValidQuad)(sanitized)) {
400
- continue;
401
- }
402
- const edges = (0, quad_1.quadEdgeLengths)(sanitized);
403
- const minEdge = Math.min(...edges);
404
- const maxEdge = Math.max(...edges);
405
- if (!Number.isFinite(minEdge) || minEdge < minEdgeThreshold) {
406
412
  continue;
407
413
  }
408
- const aspectRatio = maxEdge / Math.max(minEdge, 1);
409
- if (!Number.isFinite(aspectRatio) || aspectRatio > 8.5) {
410
- continue;
411
- }
412
- const quadAreaValue = (0, quad_1.quadArea)(sanitized);
413
- const originalRatio = originalArea > 0 ? quadAreaValue / originalArea : 0;
414
- if (originalRatio < 0.00012 || originalRatio > 0.92) {
415
- continue;
416
- }
417
- const candidate = {
418
- quad: sanitized,
419
- area: quadAreaValue,
420
- };
421
- if (!bestLocal || candidate.area > bestLocal.area) {
422
- bestLocal = candidate;
414
+ }
415
+ catch (err) {
416
+ if (__DEV__) {
417
+ console.warn('[DocScanner] convex check error:', err, 'points:', points);
423
418
  }
419
+ continue;
424
420
  }
425
- return bestLocal;
426
- };
427
- const runCanny = (label, low, high) => {
428
- 'worklet';
429
- const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseMat);
430
- step = `${label}_canny`;
431
- reportStage(step);
432
- react_native_fast_opencv_1.OpenCV.invoke('Canny', working, working, low, high);
433
- react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', working, working, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
434
- considerCandidate(evaluateContours(working, label));
435
- };
436
- const runAdaptive = (label, blockSize, c) => {
437
- 'worklet';
438
- const working = react_native_fast_opencv_1.OpenCV.invoke('clone', baseMat);
439
- step = `${label}_adaptive`;
440
- reportStage(step);
441
- react_native_fast_opencv_1.OpenCV.invoke('adaptiveThreshold', working, working, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, blockSize, c);
442
- react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', working, working, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
443
- considerCandidate(evaluateContours(working, label));
444
- };
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();
421
+ if (area > maxArea) {
422
+ best = points;
423
+ maxArea = area;
424
+ }
425
+ }
460
426
  step = 'clearBuffers';
461
427
  reportStage(step);
462
428
  react_native_fast_opencv_1.OpenCV.clearBuffers();
463
429
  step = 'updateQuad';
464
430
  reportStage(step);
465
- updateQuad(bestQuad);
431
+ updateQuad(best);
466
432
  }
467
433
  catch (error) {
468
434
  reportError(step, error);
package/dist/index.d.ts CHANGED
@@ -5,4 +5,3 @@ export type { FullDocScannerResult, FullDocScannerProps, FullDocScannerStrings,
5
5
  export type { Point, Quad, Rectangle, CapturedDocument } from './types';
6
6
  export type { DetectionConfig } from './DocScanner';
7
7
  export { quadToRectangle, rectangleToQuad, scaleCoordinates, scaleRectangle, } from './utils/coordinate';
8
- export { DocumentDetector } from './utils/documentDetection';
package/dist/index.js CHANGED
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.DocumentDetector = exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.FullDocScanner = exports.CropEditor = exports.DocScanner = void 0;
3
+ exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.FullDocScanner = exports.CropEditor = exports.DocScanner = void 0;
4
4
  // Main components
5
5
  var DocScanner_1 = require("./DocScanner");
6
6
  Object.defineProperty(exports, "DocScanner", { enumerable: true, get: function () { return DocScanner_1.DocScanner; } });
@@ -14,5 +14,3 @@ Object.defineProperty(exports, "quadToRectangle", { enumerable: true, get: funct
14
14
  Object.defineProperty(exports, "rectangleToQuad", { enumerable: true, get: function () { return coordinate_1.rectangleToQuad; } });
15
15
  Object.defineProperty(exports, "scaleCoordinates", { enumerable: true, get: function () { return coordinate_1.scaleCoordinates; } });
16
16
  Object.defineProperty(exports, "scaleRectangle", { enumerable: true, get: function () { return coordinate_1.scaleRectangle; } });
17
- var documentDetection_1 = require("./utils/documentDetection");
18
- Object.defineProperty(exports, "DocumentDetector", { enumerable: true, get: function () { return documentDetection_1.DocumentDetector; } });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "0.60.0",
3
+ "version": "0.64.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {
@@ -344,225 +344,182 @@ export const DocScanner: React.FC<Props> = ({
344
344
  const element = OpenCV.invoke('getStructuringElement', MorphShapes.MORPH_RECT, morphologyKernel);
345
345
  step = 'morphologyEx';
346
346
  reportStage(step);
347
+ // MORPH_CLOSE to fill small holes in edges
347
348
  OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_CLOSE, element);
349
+ // MORPH_OPEN to remove small noise
348
350
  OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_OPEN, element);
349
351
 
350
- const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
351
- const THRESH_BINARY = 0;
352
- const THRESH_OTSU = 8;
353
-
354
352
  // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
355
353
  step = 'bilateralFilter';
356
354
  reportStage(step);
357
- let processed = mat;
358
355
  try {
359
356
  const tempMat = OpenCV.createObject(ObjectType.Mat);
360
357
  OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
361
- processed = tempMat;
358
+ mat = tempMat;
362
359
  } catch (error) {
363
360
  if (__DEV__) {
364
361
  console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
365
362
  }
363
+ step = 'gaussianBlurFallback';
364
+ reportStage(step);
366
365
  const blurKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
367
366
  OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
368
- processed = mat;
369
367
  }
370
368
 
371
- // Additional blur and close pass to smooth jagged edges
372
- step = 'gaussianBlur';
369
+ step = 'Canny';
370
+ reportStage(step);
371
+ // Configurable Canny parameters for adaptive edge detection
372
+ OpenCV.invoke('Canny', mat, mat, CANNY_LOW, CANNY_HIGH);
373
+
374
+ step = 'createContours';
373
375
  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);
376
+ const contours = OpenCV.createObject(ObjectType.PointVectorOfVectors);
377
+ OpenCV.invoke('findContours', mat, contours, RetrievalModes.RETR_EXTERNAL, ContourApproximationModes.CHAIN_APPROX_SIMPLE);
377
378
 
378
- const baseMat = OpenCV.invoke('clone', processed);
379
+ let best: Point[] | null = null;
380
+ let maxArea = 0;
379
381
  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
382
 
403
- const evaluateContours = (inputMat: unknown, attemptLabel: string): { quad: Point[]; area: number } | null => {
404
- 'worklet';
383
+ step = 'toJSValue';
384
+ reportStage(step);
385
+ const contourVector = OpenCV.toJSValue(contours);
386
+ const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
405
387
 
406
- step = `findContours_${attemptLabel}`;
388
+ for (let i = 0; i < contourArray.length; i += 1) {
389
+ step = `contour_${i}_copy`;
407
390
  reportStage(step);
408
- const contours = OpenCV.createObject(ObjectType.PointVectorOfVectors);
409
- OpenCV.invoke('findContours', inputMat, contours, RetrievalModes.RETR_EXTERNAL, ContourApproximationModes.CHAIN_APPROX_SIMPLE);
391
+ const contour = OpenCV.copyObjectFromVector(contours, i);
410
392
 
411
- const contourVector = OpenCV.toJSValue(contours);
412
- const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
393
+ // Compute absolute area first
394
+ step = `contour_${i}_area_abs`;
395
+ reportStage(step);
396
+ const { value: area } = OpenCV.invoke('contourArea', contour, false);
413
397
 
414
- let bestLocal: { quad: Point[]; area: number } | null = null;
398
+ // Skip extremely small contours, but keep threshold very low to allow distant documents
399
+ if (typeof area !== 'number' || !isFinite(area)) {
400
+ continue;
401
+ }
415
402
 
416
- for (let i = 0; i < contourArray.length; i += 1) {
417
- step = `${attemptLabel}_contour_${i}_copy`;
418
- reportStage(step);
419
- const contour = OpenCV.copyObjectFromVector(contours, i);
403
+ if (area < 50) {
404
+ continue;
405
+ }
420
406
 
421
- step = `${attemptLabel}_contour_${i}_area`;
407
+ step = `contour_${i}_area`; // ratio stage
408
+ reportStage(step);
409
+ const areaRatio = area / frameArea;
410
+
411
+ if (__DEV__) {
412
+ console.log('[DocScanner] area', area, 'ratio', areaRatio);
413
+ }
414
+
415
+ // Skip if area ratio is too small or too large
416
+ if (areaRatio < 0.0002 || areaRatio > 0.99) {
417
+ continue;
418
+ }
419
+
420
+ // Try to use convex hull for better corner detection
421
+ let contourToUse = contour;
422
+ try {
423
+ step = `contour_${i}_convexHull`;
422
424
  reportStage(step);
423
- const { value: area } = OpenCV.invoke('contourArea', contour, false);
424
- if (typeof area !== 'number' || !isFinite(area) || area < 60) {
425
- continue;
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');
426
432
  }
433
+ }
427
434
 
428
- const resizedRatio = area / frameArea;
429
- if (resizedRatio < 0.00012 || resizedRatio > 0.98) {
430
- continue;
431
- }
435
+ step = `contour_${i}_arcLength`;
436
+ reportStage(step);
437
+ const { value: perimeter } = OpenCV.invoke('arcLength', contourToUse, true);
438
+ const approx = OpenCV.createObject(ObjectType.PointVector);
432
439
 
433
- let contourToUse = contour;
434
- try {
435
- const hull = OpenCV.createObject(ObjectType.PointVector);
436
- OpenCV.invoke('convexHull', contour, hull, false, true);
437
- contourToUse = hull;
438
- } catch (err) {
439
- if (__DEV__ && !convexHullWarned) {
440
- console.warn('[DocScanner] convexHull failed, using original contour');
441
- convexHullWarned = true;
442
- }
443
- }
440
+ let approxArray: Array<{ x: number; y: number }> = [];
444
441
 
445
- const { value: perimeter } = OpenCV.invoke('arcLength', contourToUse, true);
446
- if (typeof perimeter !== 'number' || !isFinite(perimeter) || perimeter < 80) {
447
- continue;
448
- }
442
+ // Try more epsilon values from 0.1% to 10% for difficult shapes
443
+ const epsilonValues = [
444
+ 0.001, 0.002, 0.003, 0.004, 0.005, 0.006, 0.007, 0.008, 0.009,
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
+ ];
449
447
 
450
- const approx = OpenCV.createObject(ObjectType.PointVector);
451
- let approxArray: Array<{ x: number; y: number }> = [];
448
+ for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
449
+ const epsilon = epsilonValues[attempt] * perimeter;
450
+ step = `contour_${i}_approxPolyDP_attempt_${attempt}`;
451
+ reportStage(step);
452
+ OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
452
453
 
453
- for (let attempt = 0; attempt < epsilonValues.length; attempt += 1) {
454
- const epsilon = epsilonValues[attempt] * perimeter;
455
- step = `${attemptLabel}_contour_${i}_approx_${attempt}`;
456
- reportStage(step);
457
- OpenCV.invoke('approxPolyDP', contourToUse, approx, epsilon, true);
454
+ step = `contour_${i}_toJS_attempt_${attempt}`;
455
+ reportStage(step);
456
+ const approxValue = OpenCV.toJSValue(approx);
457
+ const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
458
458
 
459
- const approxValue = OpenCV.toJSValue(approx);
460
- const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
461
- if (candidate.length === 4) {
462
- approxArray = candidate as Array<{ x: number; y: number }>;
463
- break;
464
- }
459
+ if (__DEV__) {
460
+ console.log('[DocScanner] approx length', candidate.length, 'epsilon', epsilon);
465
461
  }
466
462
 
467
- if (approxArray.length !== 4) {
468
- continue;
463
+ if (candidate.length === 4) {
464
+ approxArray = candidate as Array<{ x: number; y: number }>;
465
+ break;
469
466
  }
467
+ }
470
468
 
471
- const isValidPoint = (pt: { x: number; y: number }) =>
472
- typeof pt.x === 'number' && typeof pt.y === 'number' && isFinite(pt.x) && isFinite(pt.y);
473
-
474
- if (!approxArray.every(isValidPoint)) {
475
- continue;
476
- }
469
+ // Only proceed if we found exactly 4 corners
470
+ if (approxArray.length !== 4) {
471
+ continue;
472
+ }
477
473
 
478
- const normalizedPoints: Point[] = approxArray.map((pt) => ({
479
- x: pt.x / ratio,
480
- y: pt.y / ratio,
481
- }));
474
+ step = `contour_${i}_convex`;
475
+ reportStage(step);
482
476
 
483
- if (!isConvexQuadrilateral(normalizedPoints)) {
484
- continue;
485
- }
477
+ // Validate points before processing
478
+ const isValidPoint = (pt: { x: number; y: number }) => {
479
+ return typeof pt.x === 'number' && typeof pt.y === 'number' &&
480
+ !isNaN(pt.x) && !isNaN(pt.y) &&
481
+ isFinite(pt.x) && isFinite(pt.y);
482
+ };
486
483
 
487
- const sanitized = sanitizeQuad(orderQuadPoints(normalizedPoints));
488
- if (!isValidQuad(sanitized)) {
489
- continue;
484
+ if (!approxArray.every(isValidPoint)) {
485
+ if (__DEV__) {
486
+ console.warn('[DocScanner] invalid points in approxArray', approxArray);
490
487
  }
488
+ continue;
489
+ }
491
490
 
492
- const edges = quadEdgeLengths(sanitized);
493
- const minEdge = Math.min(...edges);
494
- const maxEdge = Math.max(...edges);
495
- if (!Number.isFinite(minEdge) || minEdge < minEdgeThreshold) {
496
- continue;
497
- }
498
- const aspectRatio = maxEdge / Math.max(minEdge, 1);
499
- if (!Number.isFinite(aspectRatio) || aspectRatio > 8.5) {
500
- continue;
501
- }
491
+ const points: Point[] = approxArray.map((pt: { x: number; y: number }) => ({
492
+ x: pt.x / ratio,
493
+ y: pt.y / ratio,
494
+ }));
502
495
 
503
- const quadAreaValue = quadArea(sanitized);
504
- const originalRatio = originalArea > 0 ? quadAreaValue / originalArea : 0;
505
- if (originalRatio < 0.00012 || originalRatio > 0.92) {
496
+ // Verify the quadrilateral is convex (valid document shape)
497
+ try {
498
+ if (!isConvexQuadrilateral(points)) {
499
+ if (__DEV__) {
500
+ console.log('[DocScanner] not convex, skipping:', points);
501
+ }
506
502
  continue;
507
503
  }
508
-
509
- const candidate = {
510
- quad: sanitized,
511
- area: quadAreaValue,
512
- };
513
-
514
- if (!bestLocal || candidate.area > bestLocal.area) {
515
- bestLocal = candidate;
504
+ } catch (err) {
505
+ if (__DEV__) {
506
+ console.warn('[DocScanner] convex check error:', err, 'points:', points);
516
507
  }
508
+ continue;
517
509
  }
518
510
 
519
- return bestLocal;
520
- };
521
-
522
- const runCanny = (label: string, low: number, high: number) => {
523
- 'worklet';
524
- const working = OpenCV.invoke('clone', baseMat);
525
- step = `${label}_canny`;
526
- reportStage(step);
527
- OpenCV.invoke('Canny', working, working, low, high);
528
- OpenCV.invoke('morphologyEx', working, working, MorphTypes.MORPH_CLOSE, element);
529
- considerCandidate(evaluateContours(working, label));
530
- };
531
-
532
- const runAdaptive = (label: string, blockSize: number, c: number) => {
533
- 'worklet';
534
- const working = OpenCV.invoke('clone', baseMat);
535
- step = `${label}_adaptive`;
536
- reportStage(step);
537
- OpenCV.invoke('adaptiveThreshold', working, working, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, blockSize, c);
538
- OpenCV.invoke('morphologyEx', working, working, MorphTypes.MORPH_CLOSE, element);
539
- considerCandidate(evaluateContours(working, label));
540
- };
541
-
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();
511
+ if (area > maxArea) {
512
+ best = points;
513
+ maxArea = area;
514
+ }
515
+ }
559
516
 
560
517
  step = 'clearBuffers';
561
518
  reportStage(step);
562
519
  OpenCV.clearBuffers();
563
520
  step = 'updateQuad';
564
521
  reportStage(step);
565
- updateQuad(bestQuad);
522
+ updateQuad(best);
566
523
  } catch (error) {
567
524
  reportError(step, error);
568
525
  }
package/src/index.ts CHANGED
@@ -20,4 +20,3 @@ export {
20
20
  scaleCoordinates,
21
21
  scaleRectangle,
22
22
  } from './utils/coordinate';
23
- export { DocumentDetector } from './utils/documentDetection';
@@ -1,278 +0,0 @@
1
- import {
2
- OpenCV,
3
- ColorConversionCodes,
4
- MorphShapes,
5
- MorphTypes,
6
- RetrievalModes,
7
- ContourApproximationModes,
8
- } from 'react-native-fast-opencv';
9
- import type { Point } from '../types';
10
-
11
- type MatLike = { release?: () => void } | null | undefined;
12
-
13
- type Size = { width: number; height: number };
14
-
15
- type Quad = [Point, Point, Point, Point];
16
-
17
- const OUTPUT_SIZE: Size = { width: 800, height: 600 };
18
- const MIN_AREA = 1000;
19
- const GAUSSIAN_KERNEL: Size = { width: 5, height: 5 };
20
- const MORPH_KERNEL: Size = { width: 3, height: 3 };
21
- const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
22
- const THRESH_BINARY = 0;
23
-
24
- const safeRelease = (mat: MatLike) => {
25
- if (mat && typeof mat.release === 'function') {
26
- mat.release();
27
- }
28
- };
29
-
30
- const normalizePoint = (value: unknown): Point | null => {
31
- if (!value) {
32
- return null;
33
- }
34
-
35
- if (Array.isArray(value) && value.length >= 2) {
36
- const [x, y] = value;
37
- const px = Number(x);
38
- const py = Number(y);
39
- return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
40
- }
41
-
42
- if (typeof value === 'object') {
43
- const maybePoint = value as { x?: unknown; y?: unknown };
44
- const px = Number(maybePoint.x);
45
- const py = Number(maybePoint.y);
46
- return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
47
- }
48
-
49
- return null;
50
- };
51
-
52
- const toPointArray = (value: unknown): Point[] | null => {
53
- if (!value) {
54
- return null;
55
- }
56
-
57
- if (Array.isArray(value)) {
58
- const points = value.map(normalizePoint).filter((point): point is Point => point !== null);
59
- return points.length === value.length ? points : null;
60
- }
61
-
62
- if (typeof value === 'object') {
63
- const mat = value as { data32F?: number[]; data64F?: number[]; data32S?: number[] };
64
- const data = mat.data32F ?? mat.data64F ?? mat.data32S;
65
- if (!data || data.length < 8) {
66
- return null;
67
- }
68
-
69
- const points: Point[] = [];
70
- for (let i = 0; i + 1 < data.length; i += 2) {
71
- const x = data[i];
72
- const y = data[i + 1];
73
- if (Number.isFinite(x) && Number.isFinite(y)) {
74
- points.push({ x, y });
75
- }
76
- }
77
-
78
- return points.length >= 4 ? points.slice(0, 4) : null;
79
- }
80
-
81
- return null;
82
- };
83
-
84
- const ensureQuad = (points: Point[] | null): Quad | null => {
85
- if (!points || points.length < 4) {
86
- return null;
87
- }
88
-
89
- const quad: Quad = [points[0], points[1], points[2], points[3]];
90
- for (const point of quad) {
91
- if (typeof point.x !== 'number' || typeof point.y !== 'number') {
92
- return null;
93
- }
94
- }
95
-
96
- return quad;
97
- };
98
-
99
- /**
100
- * Provides document detection utilities using react-native-fast-opencv.
101
- */
102
- export class DocumentDetector {
103
- private static initialized = false;
104
-
105
- /** Initialize OpenCV runtime once */
106
- static async initialize(): Promise<void> {
107
- if (!DocumentDetector.initialized) {
108
- await OpenCV.initialize();
109
- DocumentDetector.initialized = true;
110
- }
111
- }
112
-
113
- /** Find document contours and return the largest quadrilateral */
114
- static async findDocumentContours(imagePath: string): Promise<Quad | null> {
115
- await DocumentDetector.initialize();
116
-
117
- let image: MatLike;
118
- let gray: MatLike;
119
- let blurred: MatLike;
120
- let thresh: MatLike;
121
- let morphed: MatLike;
122
- let kernel: MatLike;
123
-
124
- try {
125
- image = OpenCV.imread(imagePath);
126
- gray = OpenCV.cvtColor(image, ColorConversionCodes.COLOR_BGR2GRAY);
127
- blurred = OpenCV.GaussianBlur(gray, GAUSSIAN_KERNEL, 0);
128
-
129
- thresh = OpenCV.adaptiveThreshold(
130
- blurred,
131
- 255,
132
- ADAPTIVE_THRESH_GAUSSIAN_C,
133
- THRESH_BINARY,
134
- 11,
135
- 2,
136
- );
137
-
138
- kernel = OpenCV.getStructuringElement(MorphShapes.MORPH_RECT, MORPH_KERNEL);
139
- morphed = OpenCV.morphologyEx(thresh, MorphTypes.MORPH_CLOSE, kernel);
140
-
141
- const contours = OpenCV.findContours(
142
- morphed,
143
- RetrievalModes.RETR_EXTERNAL,
144
- ContourApproximationModes.CHAIN_APPROX_SIMPLE,
145
- );
146
-
147
- let largestQuad: Quad | null = null;
148
- let maxArea = 0;
149
-
150
- for (const contour of contours) {
151
- const area = OpenCV.contourArea(contour);
152
- if (area <= maxArea || area <= MIN_AREA) {
153
- continue;
154
- }
155
-
156
- const perimeter = OpenCV.arcLength(contour, true);
157
- const approx = OpenCV.approxPolyDP(contour, 0.02 * perimeter, true);
158
- const points = ensureQuad(toPointArray(approx));
159
-
160
- safeRelease(approx as MatLike);
161
-
162
- if (!points) {
163
- continue;
164
- }
165
-
166
- maxArea = area;
167
- largestQuad = points;
168
- }
169
-
170
- return largestQuad;
171
- } catch (error) {
172
- if (__DEV__) {
173
- console.error('[DocumentDetector] findDocumentContours error', error);
174
- }
175
- return null;
176
- } finally {
177
- safeRelease(kernel);
178
- safeRelease(morphed);
179
- safeRelease(thresh);
180
- safeRelease(blurred);
181
- safeRelease(gray);
182
- safeRelease(image);
183
- }
184
- }
185
-
186
- /** Apply a perspective transform using detected corners */
187
- static async perspectiveTransform(
188
- imagePath: string,
189
- corners: Quad,
190
- outputSize: Size = OUTPUT_SIZE,
191
- ): Promise<string | null> {
192
- await DocumentDetector.initialize();
193
-
194
- let image: MatLike;
195
- let srcPoints: MatLike;
196
- let dstPoints: MatLike;
197
- let transformMatrix: MatLike;
198
- let warped: MatLike;
199
-
200
- try {
201
- image = OpenCV.imread(imagePath);
202
-
203
- srcPoints = OpenCV.matFromArray(4, 1, OpenCV.CV_32FC2, [
204
- corners[0].x,
205
- corners[0].y,
206
- corners[1].x,
207
- corners[1].y,
208
- corners[2].x,
209
- corners[2].y,
210
- corners[3].x,
211
- corners[3].y,
212
- ]);
213
-
214
- dstPoints = OpenCV.matFromArray(4, 1, OpenCV.CV_32FC2, [
215
- 0,
216
- 0,
217
- outputSize.width,
218
- 0,
219
- outputSize.width,
220
- outputSize.height,
221
- 0,
222
- outputSize.height,
223
- ]);
224
-
225
- transformMatrix = OpenCV.getPerspectiveTransform(srcPoints, dstPoints);
226
-
227
- warped = OpenCV.warpPerspective(image, transformMatrix, outputSize);
228
-
229
- const outputPath = imagePath.replace(/\.jpg$/i, '_normalized.jpg');
230
- OpenCV.imwrite(outputPath, warped);
231
-
232
- return outputPath;
233
- } catch (error) {
234
- if (__DEV__) {
235
- console.error('[DocumentDetector] perspectiveTransform error', error);
236
- }
237
- return null;
238
- } finally {
239
- safeRelease(warped);
240
- safeRelease(transformMatrix);
241
- safeRelease(dstPoints);
242
- safeRelease(srcPoints);
243
- safeRelease(image);
244
- }
245
- }
246
-
247
- /** Detect document and apply normalization */
248
- static async detectAndNormalize(imagePath: string, outputSize?: Size): Promise<string | null> {
249
- try {
250
- const corners = await DocumentDetector.findDocumentContours(imagePath);
251
- if (!corners) {
252
- if (__DEV__) {
253
- console.log('[DocumentDetector] No document detected');
254
- }
255
- return null;
256
- }
257
-
258
- return DocumentDetector.perspectiveTransform(imagePath, corners, outputSize ?? OUTPUT_SIZE);
259
- } catch (error) {
260
- if (__DEV__) {
261
- console.error('[DocumentDetector] detectAndNormalize error', error);
262
- }
263
- return null;
264
- }
265
- }
266
-
267
- /** Only detect document corners without transforming */
268
- static async getDocumentBounds(imagePath: string): Promise<Quad | null> {
269
- try {
270
- return DocumentDetector.findDocumentContours(imagePath);
271
- } catch (error) {
272
- if (__DEV__) {
273
- console.error('[DocumentDetector] getDocumentBounds error', error);
274
- }
275
- return null;
276
- }
277
- }
278
- }