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