react-native-rectangle-doc-scanner 0.39.0 → 0.41.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 +89 -35
- package/dist/utils/quad.d.ts +12 -0
- package/dist/utils/quad.js +149 -0
- package/dist/utils/stability.js +5 -4
- package/package.json +1 -1
- package/src/DocScanner.tsx +113 -37
- package/src/utils/quad.ts +181 -0
- package/src/utils/stability.ts +6 -8
package/dist/DocScanner.js
CHANGED
|
@@ -42,6 +42,7 @@ const react_native_worklets_core_1 = require("react-native-worklets-core");
|
|
|
42
42
|
const react_native_fast_opencv_1 = require("react-native-fast-opencv");
|
|
43
43
|
const overlay_1 = require("./utils/overlay");
|
|
44
44
|
const stability_1 = require("./utils/stability");
|
|
45
|
+
const quad_1 = require("./utils/quad");
|
|
45
46
|
const isConvexQuadrilateral = (points) => {
|
|
46
47
|
'worklet';
|
|
47
48
|
try {
|
|
@@ -93,50 +94,102 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
|
|
|
93
94
|
}, [requestPermission]);
|
|
94
95
|
const lastQuadRef = (0, react_1.useRef)(null);
|
|
95
96
|
const smoothingBufferRef = (0, react_1.useRef)([]);
|
|
97
|
+
const anchorQuadRef = (0, react_1.useRef)(null);
|
|
98
|
+
const missingFrameCountRef = (0, react_1.useRef)(0);
|
|
99
|
+
const lastMeasurementRef = (0, react_1.useRef)(null);
|
|
100
|
+
const frameSizeRef = (0, react_1.useRef)(null);
|
|
101
|
+
const MAX_HISTORY = 5;
|
|
102
|
+
const SNAP_DISTANCE = 5; // pixels; keep corners locked when movement is tiny
|
|
103
|
+
const SNAP_CENTER_DISTANCE = 12;
|
|
104
|
+
const BLEND_DISTANCE = 65; // pixels; softly ease between similar shapes
|
|
105
|
+
const MAX_CENTER_DELTA = 85;
|
|
106
|
+
const MAX_AREA_SHIFT = 0.45;
|
|
107
|
+
const HISTORY_RESET_DISTANCE = 70;
|
|
108
|
+
const MIN_AREA_RATIO = 0.0035;
|
|
109
|
+
const MAX_AREA_RATIO = 0.9;
|
|
110
|
+
const MIN_EDGE_RATIO = 0.025;
|
|
96
111
|
const updateQuad = (0, react_native_worklets_core_1.useRunOnJS)((value) => {
|
|
97
112
|
if (__DEV__) {
|
|
98
113
|
console.log('[DocScanner] quad', value);
|
|
99
114
|
}
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
115
|
+
if (!(0, quad_1.isValidQuad)(value)) {
|
|
116
|
+
missingFrameCountRef.current += 1;
|
|
117
|
+
if (lastQuadRef.current && missingFrameCountRef.current <= 2) {
|
|
118
|
+
setQuad(lastQuadRef.current);
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
smoothingBufferRef.current = [];
|
|
122
|
+
anchorQuadRef.current = null;
|
|
123
|
+
lastQuadRef.current = null;
|
|
124
|
+
setQuad(null);
|
|
125
|
+
return;
|
|
105
126
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
}
|
|
127
|
+
missingFrameCountRef.current = 0;
|
|
128
|
+
const ordered = (0, quad_1.orderQuadPoints)(value);
|
|
129
|
+
const sanitized = (0, quad_1.sanitizeQuad)(ordered);
|
|
130
|
+
const frameSize = frameSizeRef.current;
|
|
131
|
+
const frameArea = frameSize ? frameSize.width * frameSize.height : null;
|
|
132
|
+
const area = (0, quad_1.quadArea)(sanitized);
|
|
133
|
+
const edges = (0, quad_1.quadEdgeLengths)(sanitized);
|
|
134
|
+
const minEdge = Math.min(...edges);
|
|
135
|
+
const maxEdge = Math.max(...edges);
|
|
136
|
+
const aspectRatio = maxEdge > 0 ? maxEdge / Math.max(minEdge, 1) : 0;
|
|
137
|
+
const minEdgeThreshold = frameSize
|
|
138
|
+
? Math.max(14, Math.min(frameSize.width, frameSize.height) * MIN_EDGE_RATIO)
|
|
139
|
+
: 14;
|
|
140
|
+
const areaTooSmall = frameArea ? area < frameArea * MIN_AREA_RATIO : area === 0;
|
|
141
|
+
const areaTooLarge = frameArea ? area > frameArea * MAX_AREA_RATIO : false;
|
|
142
|
+
const edgesTooShort = minEdge < minEdgeThreshold;
|
|
143
|
+
const aspectTooExtreme = aspectRatio > 7;
|
|
144
|
+
if (areaTooSmall || areaTooLarge || edgesTooShort || aspectTooExtreme) {
|
|
145
|
+
missingFrameCountRef.current += 1;
|
|
146
|
+
if (lastQuadRef.current && missingFrameCountRef.current <= 2) {
|
|
147
|
+
setQuad(lastQuadRef.current);
|
|
148
|
+
return;
|
|
129
149
|
}
|
|
130
|
-
|
|
131
|
-
|
|
150
|
+
smoothingBufferRef.current = [];
|
|
151
|
+
anchorQuadRef.current = null;
|
|
152
|
+
lastQuadRef.current = null;
|
|
153
|
+
setQuad(null);
|
|
154
|
+
return;
|
|
132
155
|
}
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
156
|
+
const lastMeasurement = lastMeasurementRef.current;
|
|
157
|
+
if (lastMeasurement && (0, quad_1.quadDistance)(lastMeasurement, sanitized) > HISTORY_RESET_DISTANCE) {
|
|
158
|
+
smoothingBufferRef.current = [];
|
|
136
159
|
}
|
|
137
|
-
|
|
138
|
-
|
|
160
|
+
lastMeasurementRef.current = sanitized;
|
|
161
|
+
smoothingBufferRef.current.push(sanitized);
|
|
162
|
+
if (smoothingBufferRef.current.length > MAX_HISTORY) {
|
|
163
|
+
smoothingBufferRef.current.shift();
|
|
164
|
+
}
|
|
165
|
+
const history = smoothingBufferRef.current;
|
|
166
|
+
const hasHistory = history.length >= 2;
|
|
167
|
+
let candidate = hasHistory ? (0, quad_1.weightedAverageQuad)(history) : sanitized;
|
|
168
|
+
const anchor = anchorQuadRef.current;
|
|
169
|
+
if (anchor && (0, quad_1.isValidQuad)(anchor)) {
|
|
170
|
+
const delta = (0, quad_1.quadDistance)(candidate, anchor);
|
|
171
|
+
const anchorCenter = (0, quad_1.quadCenter)(anchor);
|
|
172
|
+
const candidateCenter = (0, quad_1.quadCenter)(candidate);
|
|
173
|
+
const anchorArea = (0, quad_1.quadArea)(anchor);
|
|
174
|
+
const candidateArea = (0, quad_1.quadArea)(candidate);
|
|
175
|
+
const centerDelta = Math.hypot(candidateCenter.x - anchorCenter.x, candidateCenter.y - anchorCenter.y);
|
|
176
|
+
const areaShift = anchorArea > 0 ? Math.abs(anchorArea - candidateArea) / anchorArea : 0;
|
|
177
|
+
if (delta <= SNAP_DISTANCE && centerDelta <= SNAP_CENTER_DISTANCE && areaShift <= 0.08) {
|
|
178
|
+
candidate = anchor;
|
|
179
|
+
}
|
|
180
|
+
else if (delta <= BLEND_DISTANCE && centerDelta <= MAX_CENTER_DELTA && areaShift <= MAX_AREA_SHIFT) {
|
|
181
|
+
const normalizedDelta = Math.min(1, delta / BLEND_DISTANCE);
|
|
182
|
+
const adaptiveAlpha = 0.25 + normalizedDelta * 0.45; // 0.25..0.7 range
|
|
183
|
+
candidate = (0, quad_1.blendQuads)(anchor, candidate, adaptiveAlpha);
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
smoothingBufferRef.current = [sanitized];
|
|
187
|
+
}
|
|
139
188
|
}
|
|
189
|
+
candidate = (0, quad_1.orderQuadPoints)(candidate);
|
|
190
|
+
anchorQuadRef.current = candidate;
|
|
191
|
+
lastQuadRef.current = candidate;
|
|
192
|
+
setQuad(candidate);
|
|
140
193
|
}, []);
|
|
141
194
|
const reportError = (0, react_native_worklets_core_1.useRunOnJS)((step, error) => {
|
|
142
195
|
const message = error instanceof Error ? error.message : `${error}`;
|
|
@@ -147,6 +200,7 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
|
|
|
147
200
|
}, []);
|
|
148
201
|
const [frameSize, setFrameSize] = (0, react_1.useState)(null);
|
|
149
202
|
const updateFrameSize = (0, react_native_worklets_core_1.useRunOnJS)((width, height) => {
|
|
203
|
+
frameSizeRef.current = { width, height };
|
|
150
204
|
setFrameSize({ width, height });
|
|
151
205
|
}, []);
|
|
152
206
|
const frameProcessor = (0, react_native_vision_camera_1.useFrameProcessor)((frame) => {
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Point } from '../types';
|
|
2
|
+
export declare const isValidPoint: (point: Point | null | undefined) => point is Point;
|
|
3
|
+
export declare const isValidQuad: (quad: Point[] | null | undefined) => quad is Point[];
|
|
4
|
+
export declare const orderQuadPoints: (quad: Point[]) => Point[];
|
|
5
|
+
export declare const quadDistance: (a: Point[], b: Point[]) => number;
|
|
6
|
+
export declare const averageQuad: (quads: Point[][]) => Point[];
|
|
7
|
+
export declare const blendQuads: (base: Point[], target: Point[], alpha: number) => Point[];
|
|
8
|
+
export declare const sanitizeQuad: (quad: Point[]) => Point[];
|
|
9
|
+
export declare const quadArea: (quad: Point[]) => number;
|
|
10
|
+
export declare const quadCenter: (quad: Point[]) => Point;
|
|
11
|
+
export declare const quadEdgeLengths: (quad: Point[]) => number[];
|
|
12
|
+
export declare const weightedAverageQuad: (quads: Point[][]) => Point[];
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.weightedAverageQuad = exports.quadEdgeLengths = exports.quadCenter = exports.quadArea = exports.sanitizeQuad = exports.blendQuads = exports.averageQuad = exports.quadDistance = exports.orderQuadPoints = exports.isValidQuad = exports.isValidPoint = void 0;
|
|
4
|
+
const POINT_EPSILON = 1e-3;
|
|
5
|
+
const isFiniteNumber = (value) => Number.isFinite(value) && !Number.isNaN(value);
|
|
6
|
+
const isValidPoint = (point) => {
|
|
7
|
+
if (!point) {
|
|
8
|
+
return false;
|
|
9
|
+
}
|
|
10
|
+
if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y)) {
|
|
11
|
+
return false;
|
|
12
|
+
}
|
|
13
|
+
if (Math.abs(point.x) > 1_000_000 || Math.abs(point.y) > 1_000_000) {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
};
|
|
18
|
+
exports.isValidPoint = isValidPoint;
|
|
19
|
+
const isValidQuad = (quad) => {
|
|
20
|
+
return Array.isArray(quad) && quad.length === 4 && quad.every(exports.isValidPoint);
|
|
21
|
+
};
|
|
22
|
+
exports.isValidQuad = isValidQuad;
|
|
23
|
+
const clonePoint = (point) => ({ x: point.x, y: point.y });
|
|
24
|
+
const orderQuadPoints = (quad) => {
|
|
25
|
+
const centroid = quad.reduce((acc, point) => ({ x: acc.x + point.x / quad.length, y: acc.y + point.y / quad.length }), { x: 0, y: 0 });
|
|
26
|
+
const sorted = quad
|
|
27
|
+
.slice()
|
|
28
|
+
.map(clonePoint)
|
|
29
|
+
.sort((a, b) => {
|
|
30
|
+
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x);
|
|
31
|
+
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x);
|
|
32
|
+
return angleA - angleB;
|
|
33
|
+
});
|
|
34
|
+
const topLeftIndex = sorted.reduce((selectedIndex, point, index) => {
|
|
35
|
+
const currentScore = point.x + point.y;
|
|
36
|
+
const selectedPoint = sorted[selectedIndex];
|
|
37
|
+
const selectedScore = selectedPoint.x + selectedPoint.y;
|
|
38
|
+
if (currentScore < selectedScore - POINT_EPSILON) {
|
|
39
|
+
return index;
|
|
40
|
+
}
|
|
41
|
+
return selectedIndex;
|
|
42
|
+
}, 0);
|
|
43
|
+
return [...sorted.slice(topLeftIndex), ...sorted.slice(0, topLeftIndex)];
|
|
44
|
+
};
|
|
45
|
+
exports.orderQuadPoints = orderQuadPoints;
|
|
46
|
+
const quadDistance = (a, b) => {
|
|
47
|
+
if (!(0, exports.isValidQuad)(a) || !(0, exports.isValidQuad)(b)) {
|
|
48
|
+
return Number.POSITIVE_INFINITY;
|
|
49
|
+
}
|
|
50
|
+
let total = 0;
|
|
51
|
+
for (let i = 0; i < 4; i += 1) {
|
|
52
|
+
total += Math.hypot(a[i].x - b[i].x, a[i].y - b[i].y);
|
|
53
|
+
}
|
|
54
|
+
return total / 4;
|
|
55
|
+
};
|
|
56
|
+
exports.quadDistance = quadDistance;
|
|
57
|
+
const averageQuad = (quads) => {
|
|
58
|
+
if (!Array.isArray(quads) || quads.length === 0) {
|
|
59
|
+
throw new Error('Cannot average empty quad array');
|
|
60
|
+
}
|
|
61
|
+
const accum = quads[0].map(() => ({ x: 0, y: 0 }));
|
|
62
|
+
quads.forEach((quad) => {
|
|
63
|
+
quad.forEach((point, index) => {
|
|
64
|
+
accum[index].x += point.x;
|
|
65
|
+
accum[index].y += point.y;
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
return accum.map((point) => ({ x: point.x / quads.length, y: point.y / quads.length }));
|
|
69
|
+
};
|
|
70
|
+
exports.averageQuad = averageQuad;
|
|
71
|
+
const blendQuads = (base, target, alpha) => {
|
|
72
|
+
if (alpha <= 0) {
|
|
73
|
+
return base.map(clonePoint);
|
|
74
|
+
}
|
|
75
|
+
if (alpha >= 1) {
|
|
76
|
+
return target.map(clonePoint);
|
|
77
|
+
}
|
|
78
|
+
return base.map((point, index) => ({
|
|
79
|
+
x: point.x * (1 - alpha) + target[index].x * alpha,
|
|
80
|
+
y: point.y * (1 - alpha) + target[index].y * alpha,
|
|
81
|
+
}));
|
|
82
|
+
};
|
|
83
|
+
exports.blendQuads = blendQuads;
|
|
84
|
+
const sanitizeQuad = (quad) => {
|
|
85
|
+
if (!(0, exports.isValidQuad)(quad)) {
|
|
86
|
+
throw new Error('Cannot sanitise invalid quad');
|
|
87
|
+
}
|
|
88
|
+
return quad.map((point) => ({
|
|
89
|
+
x: Number.isFinite(point.x) ? point.x : 0,
|
|
90
|
+
y: Number.isFinite(point.y) ? point.y : 0,
|
|
91
|
+
}));
|
|
92
|
+
};
|
|
93
|
+
exports.sanitizeQuad = sanitizeQuad;
|
|
94
|
+
const quadArea = (quad) => {
|
|
95
|
+
if (!(0, exports.isValidQuad)(quad)) {
|
|
96
|
+
return 0;
|
|
97
|
+
}
|
|
98
|
+
let area = 0;
|
|
99
|
+
for (let i = 0; i < 4; i += 1) {
|
|
100
|
+
const current = quad[i];
|
|
101
|
+
const next = quad[(i + 1) % 4];
|
|
102
|
+
area += current.x * next.y - next.x * current.y;
|
|
103
|
+
}
|
|
104
|
+
return Math.abs(area) / 2;
|
|
105
|
+
};
|
|
106
|
+
exports.quadArea = quadArea;
|
|
107
|
+
const quadCenter = (quad) => {
|
|
108
|
+
if (!(0, exports.isValidQuad)(quad)) {
|
|
109
|
+
return { x: 0, y: 0 };
|
|
110
|
+
}
|
|
111
|
+
const sum = quad.reduce((acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }), { x: 0, y: 0 });
|
|
112
|
+
return {
|
|
113
|
+
x: sum.x / quad.length,
|
|
114
|
+
y: sum.y / quad.length,
|
|
115
|
+
};
|
|
116
|
+
};
|
|
117
|
+
exports.quadCenter = quadCenter;
|
|
118
|
+
const quadEdgeLengths = (quad) => {
|
|
119
|
+
if (!(0, exports.isValidQuad)(quad)) {
|
|
120
|
+
return [0, 0, 0, 0];
|
|
121
|
+
}
|
|
122
|
+
const lengths = [];
|
|
123
|
+
for (let i = 0; i < 4; i += 1) {
|
|
124
|
+
const current = quad[i];
|
|
125
|
+
const next = quad[(i + 1) % 4];
|
|
126
|
+
lengths.push(Math.hypot(next.x - current.x, next.y - current.y));
|
|
127
|
+
}
|
|
128
|
+
return lengths;
|
|
129
|
+
};
|
|
130
|
+
exports.quadEdgeLengths = quadEdgeLengths;
|
|
131
|
+
const weightedAverageQuad = (quads) => {
|
|
132
|
+
if (!Array.isArray(quads) || quads.length === 0) {
|
|
133
|
+
throw new Error('Cannot average empty quad array');
|
|
134
|
+
}
|
|
135
|
+
const weights = quads.map((_, index) => index + 1);
|
|
136
|
+
const totalWeight = weights.reduce((acc, weight) => acc + weight, 0);
|
|
137
|
+
const accum = quads[0].map(() => ({ x: 0, y: 0 }));
|
|
138
|
+
quads.forEach((quad, quadIndex) => {
|
|
139
|
+
quad.forEach((point, pointIndex) => {
|
|
140
|
+
accum[pointIndex].x += point.x * weights[quadIndex];
|
|
141
|
+
accum[pointIndex].y += point.y * weights[quadIndex];
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
return accum.map((point) => ({
|
|
145
|
+
x: point.x / totalWeight,
|
|
146
|
+
y: point.y / totalWeight,
|
|
147
|
+
}));
|
|
148
|
+
};
|
|
149
|
+
exports.weightedAverageQuad = weightedAverageQuad;
|
package/dist/utils/stability.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.checkStability = checkStability;
|
|
4
|
+
const quad_1 = require("./quad");
|
|
4
5
|
let last = null;
|
|
5
6
|
let stable = 0;
|
|
7
|
+
const STABILITY_DISTANCE = 8;
|
|
6
8
|
function checkStability(current) {
|
|
7
|
-
if (!current) {
|
|
9
|
+
if (!(0, quad_1.isValidQuad)(current)) {
|
|
8
10
|
stable = 0;
|
|
9
11
|
last = null;
|
|
10
12
|
return 0;
|
|
@@ -14,8 +16,8 @@ function checkStability(current) {
|
|
|
14
16
|
stable = 1;
|
|
15
17
|
return stable;
|
|
16
18
|
}
|
|
17
|
-
const diff =
|
|
18
|
-
if (diff <
|
|
19
|
+
const diff = (0, quad_1.quadDistance)(current, last);
|
|
20
|
+
if (diff < STABILITY_DISTANCE) {
|
|
19
21
|
stable++;
|
|
20
22
|
}
|
|
21
23
|
else {
|
|
@@ -24,4 +26,3 @@ function checkStability(current) {
|
|
|
24
26
|
last = current;
|
|
25
27
|
return stable;
|
|
26
28
|
}
|
|
27
|
-
const avg = (arr) => arr.reduce((a, b) => a + b, 0) / arr.length;
|
package/package.json
CHANGED
package/src/DocScanner.tsx
CHANGED
|
@@ -14,6 +14,17 @@ import {
|
|
|
14
14
|
} from 'react-native-fast-opencv';
|
|
15
15
|
import { Overlay } from './utils/overlay';
|
|
16
16
|
import { checkStability } from './utils/stability';
|
|
17
|
+
import {
|
|
18
|
+
blendQuads,
|
|
19
|
+
isValidQuad,
|
|
20
|
+
orderQuadPoints,
|
|
21
|
+
quadArea,
|
|
22
|
+
quadCenter,
|
|
23
|
+
quadDistance,
|
|
24
|
+
quadEdgeLengths,
|
|
25
|
+
sanitizeQuad,
|
|
26
|
+
weightedAverageQuad,
|
|
27
|
+
} from './utils/quad';
|
|
17
28
|
import type { Point } from './types';
|
|
18
29
|
|
|
19
30
|
const isConvexQuadrilateral = (points: Point[]) => {
|
|
@@ -97,56 +108,120 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
97
108
|
}, [requestPermission]);
|
|
98
109
|
|
|
99
110
|
const lastQuadRef = useRef<Point[] | null>(null);
|
|
100
|
-
const smoothingBufferRef = useRef<
|
|
111
|
+
const smoothingBufferRef = useRef<Point[][]>([]);
|
|
112
|
+
const anchorQuadRef = useRef<Point[] | null>(null);
|
|
113
|
+
const missingFrameCountRef = useRef(0);
|
|
114
|
+
const lastMeasurementRef = useRef<Point[] | null>(null);
|
|
115
|
+
const frameSizeRef = useRef<{ width: number; height: number } | null>(null);
|
|
116
|
+
|
|
117
|
+
const MAX_HISTORY = 5;
|
|
118
|
+
const SNAP_DISTANCE = 5; // pixels; keep corners locked when movement is tiny
|
|
119
|
+
const SNAP_CENTER_DISTANCE = 12;
|
|
120
|
+
const BLEND_DISTANCE = 65; // pixels; softly ease between similar shapes
|
|
121
|
+
const MAX_CENTER_DELTA = 85;
|
|
122
|
+
const MAX_AREA_SHIFT = 0.45;
|
|
123
|
+
const HISTORY_RESET_DISTANCE = 70;
|
|
124
|
+
const MIN_AREA_RATIO = 0.0035;
|
|
125
|
+
const MAX_AREA_RATIO = 0.9;
|
|
126
|
+
const MIN_EDGE_RATIO = 0.025;
|
|
101
127
|
|
|
102
128
|
const updateQuad = useRunOnJS((value: Point[] | null) => {
|
|
103
129
|
if (__DEV__) {
|
|
104
130
|
console.log('[DocScanner] quad', value);
|
|
105
131
|
}
|
|
106
132
|
|
|
107
|
-
|
|
108
|
-
|
|
133
|
+
if (!isValidQuad(value)) {
|
|
134
|
+
missingFrameCountRef.current += 1;
|
|
109
135
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
136
|
+
if (lastQuadRef.current && missingFrameCountRef.current <= 2) {
|
|
137
|
+
setQuad(lastQuadRef.current);
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
smoothingBufferRef.current = [];
|
|
142
|
+
anchorQuadRef.current = null;
|
|
143
|
+
lastQuadRef.current = null;
|
|
144
|
+
setQuad(null);
|
|
145
|
+
return;
|
|
113
146
|
}
|
|
114
147
|
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
148
|
+
missingFrameCountRef.current = 0;
|
|
149
|
+
|
|
150
|
+
const ordered = orderQuadPoints(value);
|
|
151
|
+
const sanitized = sanitizeQuad(ordered);
|
|
152
|
+
|
|
153
|
+
const frameSize = frameSizeRef.current;
|
|
154
|
+
const frameArea = frameSize ? frameSize.width * frameSize.height : null;
|
|
155
|
+
const area = quadArea(sanitized);
|
|
156
|
+
const edges = quadEdgeLengths(sanitized);
|
|
157
|
+
const minEdge = Math.min(...edges);
|
|
158
|
+
const maxEdge = Math.max(...edges);
|
|
159
|
+
const aspectRatio = maxEdge > 0 ? maxEdge / Math.max(minEdge, 1) : 0;
|
|
160
|
+
|
|
161
|
+
const minEdgeThreshold = frameSize
|
|
162
|
+
? Math.max(14, Math.min(frameSize.width, frameSize.height) * MIN_EDGE_RATIO)
|
|
163
|
+
: 14;
|
|
164
|
+
|
|
165
|
+
const areaTooSmall = frameArea ? area < frameArea * MIN_AREA_RATIO : area === 0;
|
|
166
|
+
const areaTooLarge = frameArea ? area > frameArea * MAX_AREA_RATIO : false;
|
|
167
|
+
const edgesTooShort = minEdge < minEdgeThreshold;
|
|
168
|
+
const aspectTooExtreme = aspectRatio > 7;
|
|
169
|
+
|
|
170
|
+
if (areaTooSmall || areaTooLarge || edgesTooShort || aspectTooExtreme) {
|
|
171
|
+
missingFrameCountRef.current += 1;
|
|
172
|
+
|
|
173
|
+
if (lastQuadRef.current && missingFrameCountRef.current <= 2) {
|
|
174
|
+
setQuad(lastQuadRef.current);
|
|
175
|
+
return;
|
|
140
176
|
}
|
|
141
177
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
// Keep showing last quad for 1 frame to reduce flickering
|
|
146
|
-
setQuad(lastQuadRef.current);
|
|
147
|
-
} else {
|
|
178
|
+
smoothingBufferRef.current = [];
|
|
179
|
+
anchorQuadRef.current = null;
|
|
180
|
+
lastQuadRef.current = null;
|
|
148
181
|
setQuad(null);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const lastMeasurement = lastMeasurementRef.current;
|
|
186
|
+
if (lastMeasurement && quadDistance(lastMeasurement, sanitized) > HISTORY_RESET_DISTANCE) {
|
|
187
|
+
smoothingBufferRef.current = [];
|
|
188
|
+
}
|
|
189
|
+
lastMeasurementRef.current = sanitized;
|
|
190
|
+
|
|
191
|
+
smoothingBufferRef.current.push(sanitized);
|
|
192
|
+
if (smoothingBufferRef.current.length > MAX_HISTORY) {
|
|
193
|
+
smoothingBufferRef.current.shift();
|
|
149
194
|
}
|
|
195
|
+
|
|
196
|
+
const history = smoothingBufferRef.current;
|
|
197
|
+
const hasHistory = history.length >= 2;
|
|
198
|
+
let candidate = hasHistory ? weightedAverageQuad(history) : sanitized;
|
|
199
|
+
|
|
200
|
+
const anchor = anchorQuadRef.current;
|
|
201
|
+
if (anchor && isValidQuad(anchor)) {
|
|
202
|
+
const delta = quadDistance(candidate, anchor);
|
|
203
|
+
const anchorCenter = quadCenter(anchor);
|
|
204
|
+
const candidateCenter = quadCenter(candidate);
|
|
205
|
+
const anchorArea = quadArea(anchor);
|
|
206
|
+
const candidateArea = quadArea(candidate);
|
|
207
|
+
const centerDelta = Math.hypot(candidateCenter.x - anchorCenter.x, candidateCenter.y - anchorCenter.y);
|
|
208
|
+
const areaShift = anchorArea > 0 ? Math.abs(anchorArea - candidateArea) / anchorArea : 0;
|
|
209
|
+
|
|
210
|
+
if (delta <= SNAP_DISTANCE && centerDelta <= SNAP_CENTER_DISTANCE && areaShift <= 0.08) {
|
|
211
|
+
candidate = anchor;
|
|
212
|
+
} else if (delta <= BLEND_DISTANCE && centerDelta <= MAX_CENTER_DELTA && areaShift <= MAX_AREA_SHIFT) {
|
|
213
|
+
const normalizedDelta = Math.min(1, delta / BLEND_DISTANCE);
|
|
214
|
+
const adaptiveAlpha = 0.25 + normalizedDelta * 0.45; // 0.25..0.7 range
|
|
215
|
+
candidate = blendQuads(anchor, candidate, adaptiveAlpha);
|
|
216
|
+
} else {
|
|
217
|
+
smoothingBufferRef.current = [sanitized];
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
candidate = orderQuadPoints(candidate);
|
|
222
|
+
anchorQuadRef.current = candidate;
|
|
223
|
+
lastQuadRef.current = candidate;
|
|
224
|
+
setQuad(candidate);
|
|
150
225
|
}, []);
|
|
151
226
|
|
|
152
227
|
const reportError = useRunOnJS((step: string, error: unknown) => {
|
|
@@ -160,6 +235,7 @@ export const DocScanner: React.FC<Props> = ({
|
|
|
160
235
|
|
|
161
236
|
const [frameSize, setFrameSize] = useState<{ width: number; height: number } | null>(null);
|
|
162
237
|
const updateFrameSize = useRunOnJS((width: number, height: number) => {
|
|
238
|
+
frameSizeRef.current = { width, height };
|
|
163
239
|
setFrameSize({ width, height });
|
|
164
240
|
}, []);
|
|
165
241
|
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { Point } from '../types';
|
|
2
|
+
|
|
3
|
+
const POINT_EPSILON = 1e-3;
|
|
4
|
+
|
|
5
|
+
const isFiniteNumber = (value: number): boolean => Number.isFinite(value) && !Number.isNaN(value);
|
|
6
|
+
|
|
7
|
+
export const isValidPoint = (point: Point | null | undefined): point is Point => {
|
|
8
|
+
if (!point) {
|
|
9
|
+
return false;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
if (!isFiniteNumber(point.x) || !isFiniteNumber(point.y)) {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
if (Math.abs(point.x) > 1_000_000 || Math.abs(point.y) > 1_000_000) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return true;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const isValidQuad = (quad: Point[] | null | undefined): quad is Point[] => {
|
|
24
|
+
return Array.isArray(quad) && quad.length === 4 && quad.every(isValidPoint);
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const clonePoint = (point: Point): Point => ({ x: point.x, y: point.y });
|
|
28
|
+
|
|
29
|
+
export const orderQuadPoints = (quad: Point[]): Point[] => {
|
|
30
|
+
const centroid = quad.reduce(
|
|
31
|
+
(acc, point) => ({ x: acc.x + point.x / quad.length, y: acc.y + point.y / quad.length }),
|
|
32
|
+
{ x: 0, y: 0 },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
const sorted = quad
|
|
36
|
+
.slice()
|
|
37
|
+
.map(clonePoint)
|
|
38
|
+
.sort((a, b) => {
|
|
39
|
+
const angleA = Math.atan2(a.y - centroid.y, a.x - centroid.x);
|
|
40
|
+
const angleB = Math.atan2(b.y - centroid.y, b.x - centroid.x);
|
|
41
|
+
return angleA - angleB;
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
const topLeftIndex = sorted.reduce((selectedIndex, point, index) => {
|
|
45
|
+
const currentScore = point.x + point.y;
|
|
46
|
+
const selectedPoint = sorted[selectedIndex];
|
|
47
|
+
const selectedScore = selectedPoint.x + selectedPoint.y;
|
|
48
|
+
if (currentScore < selectedScore - POINT_EPSILON) {
|
|
49
|
+
return index;
|
|
50
|
+
}
|
|
51
|
+
return selectedIndex;
|
|
52
|
+
}, 0);
|
|
53
|
+
|
|
54
|
+
return [...sorted.slice(topLeftIndex), ...sorted.slice(0, topLeftIndex)];
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const quadDistance = (a: Point[], b: Point[]): number => {
|
|
58
|
+
if (!isValidQuad(a) || !isValidQuad(b)) {
|
|
59
|
+
return Number.POSITIVE_INFINITY;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let total = 0;
|
|
63
|
+
for (let i = 0; i < 4; i += 1) {
|
|
64
|
+
total += Math.hypot(a[i].x - b[i].x, a[i].y - b[i].y);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return total / 4;
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
export const averageQuad = (quads: Point[][]): Point[] => {
|
|
71
|
+
if (!Array.isArray(quads) || quads.length === 0) {
|
|
72
|
+
throw new Error('Cannot average empty quad array');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const accum: Point[] = quads[0].map(() => ({ x: 0, y: 0 }));
|
|
76
|
+
|
|
77
|
+
quads.forEach((quad) => {
|
|
78
|
+
quad.forEach((point, index) => {
|
|
79
|
+
accum[index].x += point.x;
|
|
80
|
+
accum[index].y += point.y;
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
return accum.map((point) => ({ x: point.x / quads.length, y: point.y / quads.length }));
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const blendQuads = (base: Point[], target: Point[], alpha: number): Point[] => {
|
|
88
|
+
if (alpha <= 0) {
|
|
89
|
+
return base.map(clonePoint);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (alpha >= 1) {
|
|
93
|
+
return target.map(clonePoint);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return base.map((point, index) => ({
|
|
97
|
+
x: point.x * (1 - alpha) + target[index].x * alpha,
|
|
98
|
+
y: point.y * (1 - alpha) + target[index].y * alpha,
|
|
99
|
+
}));
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const sanitizeQuad = (quad: Point[]): Point[] => {
|
|
103
|
+
if (!isValidQuad(quad)) {
|
|
104
|
+
throw new Error('Cannot sanitise invalid quad');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return quad.map((point) => ({
|
|
108
|
+
x: Number.isFinite(point.x) ? point.x : 0,
|
|
109
|
+
y: Number.isFinite(point.y) ? point.y : 0,
|
|
110
|
+
}));
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
export const quadArea = (quad: Point[]): number => {
|
|
114
|
+
if (!isValidQuad(quad)) {
|
|
115
|
+
return 0;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
let area = 0;
|
|
119
|
+
for (let i = 0; i < 4; i += 1) {
|
|
120
|
+
const current = quad[i];
|
|
121
|
+
const next = quad[(i + 1) % 4];
|
|
122
|
+
area += current.x * next.y - next.x * current.y;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return Math.abs(area) / 2;
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
export const quadCenter = (quad: Point[]): Point => {
|
|
129
|
+
if (!isValidQuad(quad)) {
|
|
130
|
+
return { x: 0, y: 0 };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const sum = quad.reduce(
|
|
134
|
+
(acc, point) => ({ x: acc.x + point.x, y: acc.y + point.y }),
|
|
135
|
+
{ x: 0, y: 0 },
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
x: sum.x / quad.length,
|
|
140
|
+
y: sum.y / quad.length,
|
|
141
|
+
};
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const quadEdgeLengths = (quad: Point[]): number[] => {
|
|
145
|
+
if (!isValidQuad(quad)) {
|
|
146
|
+
return [0, 0, 0, 0];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const lengths: number[] = [];
|
|
150
|
+
|
|
151
|
+
for (let i = 0; i < 4; i += 1) {
|
|
152
|
+
const current = quad[i];
|
|
153
|
+
const next = quad[(i + 1) % 4];
|
|
154
|
+
lengths.push(Math.hypot(next.x - current.x, next.y - current.y));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
return lengths;
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export const weightedAverageQuad = (quads: Point[][]): Point[] => {
|
|
161
|
+
if (!Array.isArray(quads) || quads.length === 0) {
|
|
162
|
+
throw new Error('Cannot average empty quad array');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const weights = quads.map((_, index) => index + 1);
|
|
166
|
+
const totalWeight = weights.reduce((acc, weight) => acc + weight, 0);
|
|
167
|
+
|
|
168
|
+
const accum: Point[] = quads[0].map(() => ({ x: 0, y: 0 }));
|
|
169
|
+
|
|
170
|
+
quads.forEach((quad, quadIndex) => {
|
|
171
|
+
quad.forEach((point, pointIndex) => {
|
|
172
|
+
accum[pointIndex].x += point.x * weights[quadIndex];
|
|
173
|
+
accum[pointIndex].y += point.y * weights[quadIndex];
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return accum.map((point) => ({
|
|
178
|
+
x: point.x / totalWeight,
|
|
179
|
+
y: point.y / totalWeight,
|
|
180
|
+
}));
|
|
181
|
+
};
|
package/src/utils/stability.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import type { Point } from '../types';
|
|
2
|
+
import { isValidQuad, quadDistance } from './quad';
|
|
2
3
|
|
|
3
4
|
let last: Point[] | null = null;
|
|
4
5
|
let stable = 0;
|
|
5
6
|
|
|
7
|
+
const STABILITY_DISTANCE = 8;
|
|
8
|
+
|
|
6
9
|
export function checkStability(current: Point[] | null): number {
|
|
7
|
-
if (!current) {
|
|
10
|
+
if (!isValidQuad(current)) {
|
|
8
11
|
stable = 0;
|
|
9
12
|
last = null;
|
|
10
13
|
return 0;
|
|
@@ -16,12 +19,9 @@ export function checkStability(current: Point[] | null): number {
|
|
|
16
19
|
return stable;
|
|
17
20
|
}
|
|
18
21
|
|
|
19
|
-
const diff =
|
|
20
|
-
avg(current.map((p) => p.x)) - avg(last.map((p) => p.x)),
|
|
21
|
-
avg(current.map((p) => p.y)) - avg(last.map((p) => p.y))
|
|
22
|
-
);
|
|
22
|
+
const diff = quadDistance(current, last);
|
|
23
23
|
|
|
24
|
-
if (diff <
|
|
24
|
+
if (diff < STABILITY_DISTANCE) {
|
|
25
25
|
stable++;
|
|
26
26
|
} else {
|
|
27
27
|
stable = 0;
|
|
@@ -30,5 +30,3 @@ export function checkStability(current: Point[] | null): number {
|
|
|
30
30
|
last = current;
|
|
31
31
|
return stable;
|
|
32
32
|
}
|
|
33
|
-
|
|
34
|
-
const avg = (arr: number[]) => arr.reduce((a, b) => a + b, 0) / arr.length;
|