react-native-rectangle-doc-scanner 0.56.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.
@@ -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
- // 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';
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
- step = 'morphologyEx';
280
- reportStage(step);
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
- mat = tempMat;
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 = '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';
414
+ step = 'gaussianBlur';
307
415
  reportStage(step);
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;
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
- 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`;
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
- 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;
328
- }
329
- if (area < 50) {
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
- 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`;
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
- catch (err) {
352
- // If convexHull fails, use original contour
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
- // 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);
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
- updateQuad(best);
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/dist/index.d.ts CHANGED
@@ -5,3 +5,4 @@ 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.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.FullDocScanner = exports.CropEditor = exports.DocScanner = void 0;
3
+ exports.DocumentDetector = 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,3 +14,5 @@ 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; } });
@@ -0,0 +1,23 @@
1
+ import type { Point } from '../types';
2
+ type Size = {
3
+ width: number;
4
+ height: number;
5
+ };
6
+ type Quad = [Point, Point, Point, Point];
7
+ /**
8
+ * Provides document detection utilities using react-native-fast-opencv.
9
+ */
10
+ export declare class DocumentDetector {
11
+ private static initialized;
12
+ /** Initialize OpenCV runtime once */
13
+ static initialize(): Promise<void>;
14
+ /** Find document contours and return the largest quadrilateral */
15
+ static findDocumentContours(imagePath: string): Promise<Quad | null>;
16
+ /** Apply a perspective transform using detected corners */
17
+ static perspectiveTransform(imagePath: string, corners: Quad, outputSize?: Size): Promise<string | null>;
18
+ /** Detect document and apply normalization */
19
+ static detectAndNormalize(imagePath: string, outputSize?: Size): Promise<string | null>;
20
+ /** Only detect document corners without transforming */
21
+ static getDocumentBounds(imagePath: string): Promise<Quad | null>;
22
+ }
23
+ export {};
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.DocumentDetector = void 0;
4
+ const react_native_fast_opencv_1 = require("react-native-fast-opencv");
5
+ const OUTPUT_SIZE = { width: 800, height: 600 };
6
+ const MIN_AREA = 1000;
7
+ const GAUSSIAN_KERNEL = { width: 5, height: 5 };
8
+ const MORPH_KERNEL = { width: 3, height: 3 };
9
+ const ADAPTIVE_THRESH_GAUSSIAN_C = 1;
10
+ const THRESH_BINARY = 0;
11
+ const safeRelease = (mat) => {
12
+ if (mat && typeof mat.release === 'function') {
13
+ mat.release();
14
+ }
15
+ };
16
+ const normalizePoint = (value) => {
17
+ if (!value) {
18
+ return null;
19
+ }
20
+ if (Array.isArray(value) && value.length >= 2) {
21
+ const [x, y] = value;
22
+ const px = Number(x);
23
+ const py = Number(y);
24
+ return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
25
+ }
26
+ if (typeof value === 'object') {
27
+ const maybePoint = value;
28
+ const px = Number(maybePoint.x);
29
+ const py = Number(maybePoint.y);
30
+ return Number.isFinite(px) && Number.isFinite(py) ? { x: px, y: py } : null;
31
+ }
32
+ return null;
33
+ };
34
+ const toPointArray = (value) => {
35
+ if (!value) {
36
+ return null;
37
+ }
38
+ if (Array.isArray(value)) {
39
+ const points = value.map(normalizePoint).filter((point) => point !== null);
40
+ return points.length === value.length ? points : null;
41
+ }
42
+ if (typeof value === 'object') {
43
+ const mat = value;
44
+ const data = mat.data32F ?? mat.data64F ?? mat.data32S;
45
+ if (!data || data.length < 8) {
46
+ return null;
47
+ }
48
+ const points = [];
49
+ for (let i = 0; i + 1 < data.length; i += 2) {
50
+ const x = data[i];
51
+ const y = data[i + 1];
52
+ if (Number.isFinite(x) && Number.isFinite(y)) {
53
+ points.push({ x, y });
54
+ }
55
+ }
56
+ return points.length >= 4 ? points.slice(0, 4) : null;
57
+ }
58
+ return null;
59
+ };
60
+ const ensureQuad = (points) => {
61
+ if (!points || points.length < 4) {
62
+ return null;
63
+ }
64
+ const quad = [points[0], points[1], points[2], points[3]];
65
+ for (const point of quad) {
66
+ if (typeof point.x !== 'number' || typeof point.y !== 'number') {
67
+ return null;
68
+ }
69
+ }
70
+ return quad;
71
+ };
72
+ /**
73
+ * Provides document detection utilities using react-native-fast-opencv.
74
+ */
75
+ class DocumentDetector {
76
+ static initialized = false;
77
+ /** Initialize OpenCV runtime once */
78
+ static async initialize() {
79
+ if (!DocumentDetector.initialized) {
80
+ await react_native_fast_opencv_1.OpenCV.initialize();
81
+ DocumentDetector.initialized = true;
82
+ }
83
+ }
84
+ /** Find document contours and return the largest quadrilateral */
85
+ static async findDocumentContours(imagePath) {
86
+ await DocumentDetector.initialize();
87
+ let image;
88
+ let gray;
89
+ let blurred;
90
+ let thresh;
91
+ let morphed;
92
+ let kernel;
93
+ try {
94
+ image = react_native_fast_opencv_1.OpenCV.imread(imagePath);
95
+ gray = react_native_fast_opencv_1.OpenCV.cvtColor(image, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
96
+ blurred = react_native_fast_opencv_1.OpenCV.GaussianBlur(gray, GAUSSIAN_KERNEL, 0);
97
+ thresh = react_native_fast_opencv_1.OpenCV.adaptiveThreshold(blurred, 255, ADAPTIVE_THRESH_GAUSSIAN_C, THRESH_BINARY, 11, 2);
98
+ kernel = react_native_fast_opencv_1.OpenCV.getStructuringElement(react_native_fast_opencv_1.MorphShapes.MORPH_RECT, MORPH_KERNEL);
99
+ morphed = react_native_fast_opencv_1.OpenCV.morphologyEx(thresh, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, kernel);
100
+ const contours = react_native_fast_opencv_1.OpenCV.findContours(morphed, react_native_fast_opencv_1.RetrievalModes.RETR_EXTERNAL, react_native_fast_opencv_1.ContourApproximationModes.CHAIN_APPROX_SIMPLE);
101
+ let largestQuad = null;
102
+ let maxArea = 0;
103
+ for (const contour of contours) {
104
+ const area = react_native_fast_opencv_1.OpenCV.contourArea(contour);
105
+ if (area <= maxArea || area <= MIN_AREA) {
106
+ continue;
107
+ }
108
+ const perimeter = react_native_fast_opencv_1.OpenCV.arcLength(contour, true);
109
+ const approx = react_native_fast_opencv_1.OpenCV.approxPolyDP(contour, 0.02 * perimeter, true);
110
+ const points = ensureQuad(toPointArray(approx));
111
+ safeRelease(approx);
112
+ if (!points) {
113
+ continue;
114
+ }
115
+ maxArea = area;
116
+ largestQuad = points;
117
+ }
118
+ return largestQuad;
119
+ }
120
+ catch (error) {
121
+ if (__DEV__) {
122
+ console.error('[DocumentDetector] findDocumentContours error', error);
123
+ }
124
+ return null;
125
+ }
126
+ finally {
127
+ safeRelease(kernel);
128
+ safeRelease(morphed);
129
+ safeRelease(thresh);
130
+ safeRelease(blurred);
131
+ safeRelease(gray);
132
+ safeRelease(image);
133
+ }
134
+ }
135
+ /** Apply a perspective transform using detected corners */
136
+ static async perspectiveTransform(imagePath, corners, outputSize = OUTPUT_SIZE) {
137
+ await DocumentDetector.initialize();
138
+ let image;
139
+ let srcPoints;
140
+ let dstPoints;
141
+ let transformMatrix;
142
+ let warped;
143
+ try {
144
+ image = react_native_fast_opencv_1.OpenCV.imread(imagePath);
145
+ srcPoints = react_native_fast_opencv_1.OpenCV.matFromArray(4, 1, react_native_fast_opencv_1.OpenCV.CV_32FC2, [
146
+ corners[0].x,
147
+ corners[0].y,
148
+ corners[1].x,
149
+ corners[1].y,
150
+ corners[2].x,
151
+ corners[2].y,
152
+ corners[3].x,
153
+ corners[3].y,
154
+ ]);
155
+ dstPoints = react_native_fast_opencv_1.OpenCV.matFromArray(4, 1, react_native_fast_opencv_1.OpenCV.CV_32FC2, [
156
+ 0,
157
+ 0,
158
+ outputSize.width,
159
+ 0,
160
+ outputSize.width,
161
+ outputSize.height,
162
+ 0,
163
+ outputSize.height,
164
+ ]);
165
+ transformMatrix = react_native_fast_opencv_1.OpenCV.getPerspectiveTransform(srcPoints, dstPoints);
166
+ warped = react_native_fast_opencv_1.OpenCV.warpPerspective(image, transformMatrix, outputSize);
167
+ const outputPath = imagePath.replace(/\.jpg$/i, '_normalized.jpg');
168
+ react_native_fast_opencv_1.OpenCV.imwrite(outputPath, warped);
169
+ return outputPath;
170
+ }
171
+ catch (error) {
172
+ if (__DEV__) {
173
+ console.error('[DocumentDetector] perspectiveTransform error', error);
174
+ }
175
+ return null;
176
+ }
177
+ finally {
178
+ safeRelease(warped);
179
+ safeRelease(transformMatrix);
180
+ safeRelease(dstPoints);
181
+ safeRelease(srcPoints);
182
+ safeRelease(image);
183
+ }
184
+ }
185
+ /** Detect document and apply normalization */
186
+ static async detectAndNormalize(imagePath, outputSize) {
187
+ try {
188
+ const corners = await DocumentDetector.findDocumentContours(imagePath);
189
+ if (!corners) {
190
+ if (__DEV__) {
191
+ console.log('[DocumentDetector] No document detected');
192
+ }
193
+ return null;
194
+ }
195
+ return DocumentDetector.perspectiveTransform(imagePath, corners, outputSize ?? OUTPUT_SIZE);
196
+ }
197
+ catch (error) {
198
+ if (__DEV__) {
199
+ console.error('[DocumentDetector] detectAndNormalize error', error);
200
+ }
201
+ return null;
202
+ }
203
+ }
204
+ /** Only detect document corners without transforming */
205
+ static async getDocumentBounds(imagePath) {
206
+ try {
207
+ return DocumentDetector.findDocumentContours(imagePath);
208
+ }
209
+ catch (error) {
210
+ if (__DEV__) {
211
+ console.error('[DocumentDetector] getDocumentBounds error', error);
212
+ }
213
+ return null;
214
+ }
215
+ }
216
+ }
217
+ exports.DocumentDetector = DocumentDetector;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "0.56.0",
3
+ "version": "0.58.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {
@@ -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
- // 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
- // 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
- // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
353
- step = 'bilateralFilter';
354
- reportStage(step);
355
- try {
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 blurKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
366
- OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
367
- }
353
+ const contours = OpenCV.createObject(ObjectType.PointVectorOfVectors);
354
+ OpenCV.invoke('findContours', inputMat, contours, RetrievalModes.RETR_EXTERNAL, ContourApproximationModes.CHAIN_APPROX_SIMPLE);
368
355
 
369
- step = 'Canny';
370
- reportStage(step);
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
- step = 'createContours';
375
- reportStage(step);
376
- const contours = OpenCV.createObject(ObjectType.PointVectorOfVectors);
377
- OpenCV.invoke('findContours', mat, contours, RetrievalModes.RETR_EXTERNAL, ContourApproximationModes.CHAIN_APPROX_SIMPLE);
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
- let best: Point[] | null = null;
380
- let maxArea = 0;
381
- const frameArea = width * height;
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
- step = 'toJSValue';
384
- reportStage(step);
385
- const contourVector = OpenCV.toJSValue(contours);
386
- const contourArray = Array.isArray(contourVector?.array) ? contourVector.array : [];
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
- for (let i = 0; i < contourArray.length; i += 1) {
389
- step = `contour_${i}_copy`;
390
- reportStage(step);
391
- const contour = OpenCV.copyObjectFromVector(contours, i);
376
+ const resizedAreaRatio = rawArea / resizedArea;
377
+ if (resizedAreaRatio < 0.0001 || resizedAreaRatio > 0.97) {
378
+ continue;
379
+ }
392
380
 
393
- // Compute absolute area first
394
- step = `contour_${i}_area_abs`;
395
- reportStage(step);
396
- const { value: area } = OpenCV.invoke('contourArea', contour, false);
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
- // Skip extremely small contours, but keep threshold very low to allow distant documents
399
- if (typeof area !== 'number' || !isFinite(area)) {
400
- continue;
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
- if (area < 50) {
404
- continue;
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
- step = `contour_${i}_area`; // ratio stage
408
- reportStage(step);
409
- const areaRatio = area / frameArea;
402
+ let approxArray: Array<{ x: number; y: number }> = [];
410
403
 
411
- if (__DEV__) {
412
- console.log('[DocScanner] area', area, 'ratio', areaRatio);
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
- // Skip if area ratio is too small or too large
416
- if (areaRatio < 0.0002 || areaRatio > 0.99) {
417
- continue;
418
- }
410
+ const approxValue = OpenCV.toJSValue(approx);
411
+ const candidate = Array.isArray(approxValue?.array) ? approxValue.array : [];
419
412
 
420
- // Try to use convex hull for better corner detection
421
- let contourToUse = contour;
422
- try {
423
- step = `contour_${i}_convexHull`;
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
- step = `contour_${i}_arcLength`;
436
- reportStage(step);
437
- const { value: perimeter } = OpenCV.invoke('arcLength', contourToUse, true);
438
- const approx = OpenCV.createObject(ObjectType.PointVector);
419
+ if (approxArray.length !== 4) {
420
+ continue;
421
+ }
439
422
 
440
- let approxArray: Array<{ x: number; y: number }> = [];
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
- // 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
- ];
426
+ if (!approxArray.every(isValidPoint)) {
427
+ continue;
428
+ }
447
429
 
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);
430
+ const normalizedPoints: Point[] = approxArray.map((pt) => ({
431
+ x: pt.x / ratio,
432
+ y: pt.y / ratio,
433
+ }));
453
434
 
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 : [];
435
+ if (!isConvexQuadrilateral(normalizedPoints)) {
436
+ continue;
437
+ }
458
438
 
459
- if (__DEV__) {
460
- console.log('[DocScanner] approx length', candidate.length, 'epsilon', epsilon);
439
+ const sanitized = sanitizeQuad(orderQuadPoints(normalizedPoints));
440
+ if (!isValidQuad(sanitized)) {
441
+ continue;
461
442
  }
462
443
 
463
- if (candidate.length === 4) {
464
- approxArray = candidate as Array<{ x: number; y: number }>;
465
- break;
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
- // Only proceed if we found exactly 4 corners
470
- if (approxArray.length !== 4) {
471
- continue;
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
- step = `contour_${i}_convex`;
475
- reportStage(step);
461
+ if (__DEV__) {
462
+ console.log('[DocScanner] candidate', attemptLabel, 'areaRatio', areaRatioOriginal);
463
+ }
476
464
 
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
- };
465
+ const candidate: DetectionCandidate = {
466
+ quad: sanitized,
467
+ area: quadAreaValue,
468
+ label: attemptLabel,
469
+ };
483
470
 
484
- if (!approxArray.every(isValidPoint)) {
485
- if (__DEV__) {
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
- const points: Point[] = approxArray.map((pt: { x: number; y: number }) => ({
492
- x: pt.x / ratio,
493
- y: pt.y / ratio,
494
- }));
476
+ return bestLocal;
477
+ };
495
478
 
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
- }
502
- continue;
503
- }
504
- } catch (err) {
505
- if (__DEV__) {
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
- if (area > maxArea) {
512
- best = points;
513
- maxArea = area;
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
- updateQuad(best);
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
  }
package/src/index.ts CHANGED
@@ -20,3 +20,4 @@ export {
20
20
  scaleCoordinates,
21
21
  scaleRectangle,
22
22
  } from './utils/coordinate';
23
+ export { DocumentDetector } from './utils/documentDetection';
@@ -0,0 +1,278 @@
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
+ }