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.
@@ -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
- // Add to smoothing buffer
101
- smoothingBufferRef.current.push(value);
102
- // Keep only last 3 frames for smoothing
103
- if (smoothingBufferRef.current.length > 3) {
104
- smoothingBufferRef.current.shift();
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
- // If we have a valid quad, smooth it
107
- if (value && value.length === 4) {
108
- // Average with previous frames if available
109
- if (smoothingBufferRef.current.length >= 2) {
110
- const validQuads = smoothingBufferRef.current.filter(q => q !== null && q.length === 4);
111
- if (validQuads.length >= 2) {
112
- // Average the positions
113
- const smoothed = value.map((_, idx) => {
114
- let sumX = 0;
115
- let sumY = 0;
116
- validQuads.forEach(quad => {
117
- sumX += quad[idx].x;
118
- sumY += quad[idx].y;
119
- });
120
- return {
121
- x: sumX / validQuads.length,
122
- y: sumY / validQuads.length,
123
- };
124
- });
125
- lastQuadRef.current = smoothed;
126
- setQuad(smoothed);
127
- return;
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
- lastQuadRef.current = value;
131
- setQuad(value);
150
+ smoothingBufferRef.current = [];
151
+ anchorQuadRef.current = null;
152
+ lastQuadRef.current = null;
153
+ setQuad(null);
154
+ return;
132
155
  }
133
- else if (lastQuadRef.current) {
134
- // Keep showing last quad for 1 frame to reduce flickering
135
- setQuad(lastQuadRef.current);
156
+ const lastMeasurement = lastMeasurementRef.current;
157
+ if (lastMeasurement && (0, quad_1.quadDistance)(lastMeasurement, sanitized) > HISTORY_RESET_DISTANCE) {
158
+ smoothingBufferRef.current = [];
136
159
  }
137
- else {
138
- setQuad(null);
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;
@@ -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 = Math.hypot(avg(current.map((p) => p.x)) - avg(last.map((p) => p.x)), avg(current.map((p) => p.y)) - avg(last.map((p) => p.y)));
18
- if (diff < 10) {
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "0.39.0",
3
+ "version": "0.41.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {
@@ -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<(Point[] | null)[]>([]);
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
- // Add to smoothing buffer
108
- smoothingBufferRef.current.push(value);
133
+ if (!isValidQuad(value)) {
134
+ missingFrameCountRef.current += 1;
109
135
 
110
- // Keep only last 3 frames for smoothing
111
- if (smoothingBufferRef.current.length > 3) {
112
- smoothingBufferRef.current.shift();
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
- // If we have a valid quad, smooth it
116
- if (value && value.length === 4) {
117
- // Average with previous frames if available
118
- if (smoothingBufferRef.current.length >= 2) {
119
- const validQuads = smoothingBufferRef.current.filter(q => q !== null && q.length === 4) as Point[][];
120
-
121
- if (validQuads.length >= 2) {
122
- // Average the positions
123
- const smoothed: Point[] = value.map((_, idx) => {
124
- let sumX = 0;
125
- let sumY = 0;
126
- validQuads.forEach(quad => {
127
- sumX += quad[idx].x;
128
- sumY += quad[idx].y;
129
- });
130
- return {
131
- x: sumX / validQuads.length,
132
- y: sumY / validQuads.length,
133
- };
134
- });
135
-
136
- lastQuadRef.current = smoothed;
137
- setQuad(smoothed);
138
- return;
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
- lastQuadRef.current = value;
143
- setQuad(value);
144
- } else if (lastQuadRef.current) {
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
+ };
@@ -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 = Math.hypot(
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 < 10) {
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;