react-native-rectangle-doc-scanner 0.45.0 → 0.47.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/README.md CHANGED
@@ -19,6 +19,7 @@ Install the module alongside these peer dependencies (your host app should alrea
19
19
  - `react-native-vision-camera` (v3+) with frame processors enabled
20
20
  - `vision-camera-resize-plugin`
21
21
  - `react-native-fast-opencv`
22
+ - `react-native-perspective-image-cropper`
22
23
  - `react-native-reanimated` + `react-native-worklets-core`
23
24
  - `@shopify/react-native-skia`
24
25
  - `react`, `react-native`
@@ -30,6 +31,7 @@ yarn add react-native-rectangle-doc-scanner \
30
31
  react-native-vision-camera \
31
32
  vision-camera-resize-plugin \
32
33
  react-native-fast-opencv \
34
+ react-native-perspective-image-cropper \
33
35
  react-native-reanimated \
34
36
  react-native-worklets-core \
35
37
  @shopify/react-native-skia
@@ -52,58 +54,133 @@ Follow each dependency’s native installation guide:
52
54
 
53
55
  ## Usage
54
56
 
57
+ ### Basic Document Scanning
58
+
55
59
  ```tsx
56
- import React from 'react';
60
+ import React, { useState } from 'react';
57
61
  import { StyleSheet, Text, TouchableOpacity, View } from 'react-native';
58
- import { DocScanner } from 'react-native-rectangle-doc-scanner';
59
-
60
- export const ScanScreen = () => (
61
- <View style={styles.container}>
62
- <DocScanner
63
- onCapture={({ path, quad }) => {
64
- console.log('Document captured at', path, quad);
65
- }}
66
- overlayColor="#ff8800"
67
- autoCapture
68
- minStableFrames={8}
69
- cameraProps={{ enableZoomGesture: true }}
70
- >
71
- <View style={styles.overlayControls}>
72
- <TouchableOpacity style={styles.button}>
73
- <Text style={styles.label}>Manual Capture</Text>
74
- </TouchableOpacity>
75
- </View>
76
- </DocScanner>
77
- </View>
78
- );
62
+ import { DocScanner, CropEditor, type CapturedDocument } from 'react-native-rectangle-doc-scanner';
63
+
64
+ export const ScanScreen = () => {
65
+ const [capturedDoc, setCapturedDoc] = useState<CapturedDocument | null>(null);
66
+
67
+ if (capturedDoc) {
68
+ // Show crop editor after capture
69
+ return (
70
+ <CropEditor
71
+ document={capturedDoc}
72
+ overlayColor="rgba(0,0,0,0.5)"
73
+ overlayStrokeColor="#e7a649"
74
+ handlerColor="#e7a649"
75
+ onCropChange={(rectangle) => {
76
+ console.log('User adjusted corners:', rectangle);
77
+ // Process the adjusted corners
78
+ }}
79
+ />
80
+ );
81
+ }
82
+
83
+ return (
84
+ <View style={styles.container}>
85
+ <DocScanner
86
+ onCapture={(doc) => {
87
+ console.log('Document captured:', doc);
88
+ setCapturedDoc(doc);
89
+ }}
90
+ overlayColor="#e7a649"
91
+ autoCapture
92
+ minStableFrames={8}
93
+ cameraProps={{ enableZoomGesture: true }}
94
+ >
95
+ <View style={styles.overlayControls}>
96
+ <Text style={styles.hint}>Position document in frame</Text>
97
+ </View>
98
+ </DocScanner>
99
+ </View>
100
+ );
101
+ };
79
102
 
80
103
  const styles = StyleSheet.create({
81
104
  container: { flex: 1 },
82
105
  overlayControls: {
83
106
  position: 'absolute',
84
- bottom: 32,
107
+ top: 60,
85
108
  alignSelf: 'center',
86
109
  },
87
- button: {
88
- paddingHorizontal: 24,
89
- paddingVertical: 12,
90
- borderRadius: 999,
91
- backgroundColor: 'rgba(0,0,0,0.7)',
110
+ hint: {
111
+ color: '#fff',
112
+ fontSize: 16,
113
+ fontWeight: '600',
114
+ textShadowColor: 'rgba(0,0,0,0.75)',
115
+ textShadowOffset: { width: 0, height: 1 },
116
+ textShadowRadius: 3,
92
117
  },
93
- label: { color: '#fff', fontWeight: '600' },
94
118
  });
95
119
  ```
96
120
 
121
+ ### Advanced Configuration
122
+
123
+ ```tsx
124
+ import { DocScanner, type DetectionConfig } from 'react-native-rectangle-doc-scanner';
125
+
126
+ const detectionConfig: DetectionConfig = {
127
+ processingWidth: 1280, // Higher = more accurate but slower
128
+ cannyLowThreshold: 40, // Lower = detect more edges
129
+ cannyHighThreshold: 120, // Edge strength threshold
130
+ snapDistance: 8, // Corner lock sensitivity
131
+ maxAnchorMisses: 20, // Frames to hold anchor when detection fails
132
+ maxCenterDelta: 200, // Max camera movement while maintaining lock
133
+ };
134
+
135
+ <DocScanner
136
+ detectionConfig={detectionConfig}
137
+ onCapture={(doc) => {
138
+ // doc includes: path, quad, width, height
139
+ console.log('Captured with size:', doc.width, 'x', doc.height);
140
+ }}
141
+ />
142
+ ```
143
+
97
144
  Passing `children` lets you render any UI on top of the camera preview, so you can freely add buttons, tutorials, or progress indicators without modifying the package.
98
145
 
99
- ### Props
146
+ ## API Reference
147
+
148
+ ### DocScanner Props
149
+
150
+ - `onCapture({ path, quad, width, height })` — called when a photo is taken
151
+ - `path`: file path to the captured image
152
+ - `quad`: detected corner coordinates (or `null` if none found)
153
+ - `width`, `height`: original frame dimensions for coordinate scaling
154
+ - `overlayColor` (default `#e7a649`) — stroke color for the contour overlay
155
+ - `autoCapture` (default `true`) — auto-captures after stability is reached
156
+ - `minStableFrames` (default `8`) — consecutive stable frames required before auto capture
157
+ - `cameraProps` — forwarded to underlying `Camera` (zoom, HDR, torch, etc.)
158
+ - `children` — custom UI rendered over the camera preview
159
+ - `detectionConfig` — advanced detection configuration (see below)
160
+
161
+ ### DetectionConfig
162
+
163
+ Fine-tune the detection algorithm for your specific use case:
164
+
165
+ ```typescript
166
+ interface DetectionConfig {
167
+ processingWidth?: number; // Default: 1280 (higher = more accurate but slower)
168
+ cannyLowThreshold?: number; // Default: 40 (lower = detect more edges)
169
+ cannyHighThreshold?: number; // Default: 120 (edge strength threshold)
170
+ snapDistance?: number; // Default: 8 (corner lock sensitivity in pixels)
171
+ maxAnchorMisses?: number; // Default: 20 (frames to hold anchor when detection fails)
172
+ maxCenterDelta?: number; // Default: 200 (max camera movement while maintaining lock)
173
+ }
174
+ ```
175
+
176
+ ### CropEditor Props
100
177
 
101
- - `onCapture({ path, quad })` — called when a photo is taken; `quad` contains the detected corner coordinates (or `null` if none were found).
102
- - `overlayColor` (default `#e7a649`) — stroke colour for the contour overlay.
103
- - `autoCapture` (default `true`) — when `true`, captures automatically after stability is reached; set to `false` to show the built-in shutter button.
104
- - `minStableFrames` (default `8`) — number of consecutive stable frames required before auto capture triggers.
105
- - `cameraProps` — forwarded to the underlying `Camera` (except for `frameProcessor`), enabling features such as zoom gestures, HDR, torch control, device selection, etc.
106
- - `children` — rendered over the camera/overlay for fully custom controls.
178
+ - `document` — `CapturedDocument` object from `onCapture` callback
179
+ - `overlayColor` (default `rgba(0,0,0,0.5)`)color of overlay outside crop area
180
+ - `overlayStrokeColor` (default `#e7a649`) — color of crop boundary lines
181
+ - `handlerColor` (default `#e7a649`) — color of corner drag handles
182
+ - `enablePanStrict` (default `false`) enable strict panning behavior
183
+ - `onCropChange(rectangle)` — callback when user adjusts corners
107
184
 
108
185
  ### Notes on camera behaviour
109
186
 
@@ -113,34 +190,46 @@ Passing `children` lets you render any UI on top of the camera preview, so you c
113
190
 
114
191
  ## Detection Algorithm
115
192
 
116
- The scanner uses a sophisticated multi-stage pipeline:
117
-
118
- 1. **Pre-processing** (1280p resolution for accuracy)
119
- - Converts frame to grayscale
120
- - Applies morphological opening to reduce noise
121
- - Gaussian blur for smoother edges
122
- - Canny edge detection with 50/150 thresholds
123
-
124
- 2. **Contour Detection**
125
- - Finds external contours using CHAIN_APPROX_SIMPLE
126
- - Applies convex hull for better corner detection
127
- - Tests multiple epsilon values (0.1%-10%) for approxPolyDP
128
- - Validates quadrilaterals for convexity
129
-
130
- 3. **Anchor Locking System**
131
- - Once corners are detected, they "snap" to stable positions
132
- - Maintains lock even when camera moves (up to 200px center delta)
133
- - Holds anchor for up to 20 missed detections
134
- - Adaptive blending between old and new positions for smooth transitions
135
- - Builds confidence over time (max 30 frames) for stronger locking
136
-
137
- 4. **Quad Validation**
138
- - Checks area ratio (0.02%-90% of frame)
139
- - Validates minimum edge lengths
140
- - Ensures reasonable aspect ratios
141
- - Filters out non-convex shapes
142
-
143
- This approach ensures corners remain locked once detected, allowing you to move the camera slightly without losing the document boundary.
193
+ The scanner uses a sophisticated multi-stage pipeline optimized for quality and stability:
194
+
195
+ ### 1. Pre-processing (Configurable Resolution)
196
+ - Resizes frame to `processingWidth` (default 1280p) for optimal accuracy
197
+ - Converts to grayscale
198
+ - **Enhanced morphological operations**:
199
+ - MORPH_CLOSE to fill small holes in edges (7x7 kernel)
200
+ - MORPH_OPEN to remove small noise
201
+ - **Bilateral filter** for edge-preserving smoothing (better than Gaussian)
202
+ - **Adaptive Canny edge detection** with configurable thresholds (default 40/120)
203
+
204
+ ### 2. Contour Detection
205
+ - Finds external contours using CHAIN_APPROX_SIMPLE
206
+ - Applies convex hull for improved corner accuracy
207
+ - Tests **23 epsilon values** (0.1%-10%) for approxPolyDP to find exact 4 corners
208
+ - Validates quadrilaterals for convexity and valid coordinates
209
+
210
+ ### 3. Advanced Anchor Locking System
211
+ Once corners are detected, the system maintains stability through:
212
+ - **Snap locking**: Corners lock to positions when movement is minimal
213
+ - **Camera movement tolerance**: Maintains lock during movement (up to 200px center delta)
214
+ - **Persistence**: Holds anchor for up to 20 consecutive failed detections
215
+ - **Adaptive blending**: Smoothly transitions between old and new positions
216
+ - **Confidence building**: Increases lock strength over time (max 30 frames)
217
+ - **Intelligent reset**: Only resets when document clearly changes
218
+
219
+ ### 4. Quad Validation
220
+ - Area ratio filtering (0.02%-90% of frame)
221
+ - Minimum edge length validation
222
+ - Aspect ratio constraints (max 7:1)
223
+ - Convexity checks to filter invalid shapes
224
+
225
+ ### 5. Post-Capture Editing
226
+ After capture, users can manually adjust corners using the `CropEditor` component:
227
+ - Grid-based interface with perspective view
228
+ - Draggable corner handles
229
+ - Real-time preview of adjusted crop area
230
+ - Exports adjusted coordinates for final processing
231
+
232
+ This multi-layered approach ensures high-quality detection with maximum flexibility for various document types and lighting conditions.
144
233
 
145
234
  ## Build
146
235
  ```sh
@@ -0,0 +1,25 @@
1
+ import React from 'react';
2
+ import type { Rectangle, CapturedDocument } from './types';
3
+ interface CropEditorProps {
4
+ document: CapturedDocument;
5
+ overlayColor?: string;
6
+ overlayStrokeColor?: string;
7
+ handlerColor?: string;
8
+ enablePanStrict?: boolean;
9
+ onCropChange?: (rectangle: Rectangle) => void;
10
+ }
11
+ /**
12
+ * CropEditor Component
13
+ *
14
+ * Displays a captured document image with adjustable corner handles.
15
+ * Uses react-native-perspective-image-cropper for the cropping UI.
16
+ *
17
+ * @param document - The captured document with path and detected quad
18
+ * @param overlayColor - Color of the overlay outside the crop area (default: 'rgba(0,0,0,0.5)')
19
+ * @param overlayStrokeColor - Color of the crop boundary lines (default: '#e7a649')
20
+ * @param handlerColor - Color of the corner handles (default: '#e7a649')
21
+ * @param enablePanStrict - Enable strict panning behavior
22
+ * @param onCropChange - Callback when user adjusts crop corners
23
+ */
24
+ export declare const CropEditor: React.FC<CropEditorProps>;
25
+ export {};
@@ -0,0 +1,113 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.CropEditor = void 0;
37
+ const react_1 = __importStar(require("react"));
38
+ const react_native_1 = require("react-native");
39
+ const react_native_perspective_image_cropper_1 = require("react-native-perspective-image-cropper");
40
+ const coordinate_1 = require("./utils/coordinate");
41
+ /**
42
+ * CropEditor Component
43
+ *
44
+ * Displays a captured document image with adjustable corner handles.
45
+ * Uses react-native-perspective-image-cropper for the cropping UI.
46
+ *
47
+ * @param document - The captured document with path and detected quad
48
+ * @param overlayColor - Color of the overlay outside the crop area (default: 'rgba(0,0,0,0.5)')
49
+ * @param overlayStrokeColor - Color of the crop boundary lines (default: '#e7a649')
50
+ * @param handlerColor - Color of the corner handles (default: '#e7a649')
51
+ * @param enablePanStrict - Enable strict panning behavior
52
+ * @param onCropChange - Callback when user adjusts crop corners
53
+ */
54
+ const CropEditor = ({ document, overlayColor = 'rgba(0,0,0,0.5)', overlayStrokeColor = '#e7a649', handlerColor = '#e7a649', enablePanStrict = false, onCropChange, }) => {
55
+ const [imageSize, setImageSize] = (0, react_1.useState)(null);
56
+ const [displaySize, setDisplaySize] = (0, react_1.useState)({
57
+ width: react_native_1.Dimensions.get('window').width,
58
+ height: react_native_1.Dimensions.get('window').height,
59
+ });
60
+ // Get initial rectangle from detected quad or use default
61
+ const getInitialRectangle = (0, react_1.useCallback)(() => {
62
+ if (!document.quad || !imageSize) {
63
+ return undefined;
64
+ }
65
+ const rect = (0, coordinate_1.quadToRectangle)(document.quad);
66
+ if (!rect) {
67
+ return undefined;
68
+ }
69
+ // Scale from original detection coordinates to image coordinates
70
+ const scaled = (0, coordinate_1.scaleRectangle)(rect, document.width, document.height, imageSize.width, imageSize.height);
71
+ return scaled;
72
+ }, [document.quad, document.width, document.height, imageSize]);
73
+ const handleImageLoad = (0, react_1.useCallback)((event) => {
74
+ const { width, height } = event.nativeEvent.source;
75
+ setImageSize({ width, height });
76
+ }, []);
77
+ const handleLayout = (0, react_1.useCallback)((event) => {
78
+ const { width, height } = event.nativeEvent.layout;
79
+ setDisplaySize({ width, height });
80
+ }, []);
81
+ const handleDragEnd = (0, react_1.useCallback)((coordinates) => {
82
+ if (!imageSize) {
83
+ return;
84
+ }
85
+ // Convert back to Rectangle type
86
+ const rect = {
87
+ topLeft: coordinates.topLeft,
88
+ topRight: coordinates.topRight,
89
+ bottomRight: coordinates.bottomRight,
90
+ bottomLeft: coordinates.bottomLeft,
91
+ };
92
+ onCropChange?.(rect);
93
+ }, [imageSize, onCropChange]);
94
+ // Wait for image to load to get dimensions
95
+ if (!imageSize) {
96
+ return (react_1.default.createElement(react_native_1.View, { style: styles.container, onLayout: handleLayout },
97
+ react_1.default.createElement(react_native_1.Image, { source: { uri: `file://${document.path}` }, style: styles.hiddenImage, onLoad: handleImageLoad, resizeMode: "contain" })));
98
+ }
99
+ return (react_1.default.createElement(react_native_1.View, { style: styles.container, onLayout: handleLayout },
100
+ react_1.default.createElement(react_native_perspective_image_cropper_1.CustomImageCropper, { height: displaySize.height, width: displaySize.width, image: `file://${document.path}`, rectangleCoordinates: getInitialRectangle(), overlayColor: overlayColor, overlayStrokeColor: overlayStrokeColor, handlerColor: handlerColor, enablePanStrict: enablePanStrict, onDragEnd: handleDragEnd })));
101
+ };
102
+ exports.CropEditor = CropEditor;
103
+ const styles = react_native_1.StyleSheet.create({
104
+ container: {
105
+ flex: 1,
106
+ backgroundColor: '#000',
107
+ },
108
+ hiddenImage: {
109
+ width: 1,
110
+ height: 1,
111
+ opacity: 0,
112
+ },
113
+ });
@@ -2,16 +2,37 @@ import React, { ReactNode } from 'react';
2
2
  import { Camera } from 'react-native-vision-camera';
3
3
  import type { Point } from './types';
4
4
  type CameraOverrides = Omit<React.ComponentProps<typeof Camera>, 'style' | 'ref' | 'frameProcessor'>;
5
+ /**
6
+ * Configuration for detection quality and behavior
7
+ */
8
+ export interface DetectionConfig {
9
+ /** Processing resolution width (default: 1280) - higher = more accurate but slower */
10
+ processingWidth?: number;
11
+ /** Canny edge detection lower threshold (default: 40) */
12
+ cannyLowThreshold?: number;
13
+ /** Canny edge detection upper threshold (default: 120) */
14
+ cannyHighThreshold?: number;
15
+ /** Snap distance in pixels for corner locking (default: 8) */
16
+ snapDistance?: number;
17
+ /** Max frames to hold anchor when detection fails (default: 20) */
18
+ maxAnchorMisses?: number;
19
+ /** Maximum center movement allowed while maintaining lock (default: 200px) */
20
+ maxCenterDelta?: number;
21
+ }
5
22
  interface Props {
6
23
  onCapture?: (photo: {
7
24
  path: string;
8
25
  quad: Point[] | null;
26
+ width: number;
27
+ height: number;
9
28
  }) => void;
10
29
  overlayColor?: string;
11
30
  autoCapture?: boolean;
12
31
  minStableFrames?: number;
13
32
  cameraProps?: CameraOverrides;
14
33
  children?: ReactNode;
34
+ /** Advanced detection configuration */
35
+ detectionConfig?: DetectionConfig;
15
36
  }
16
37
  export declare const DocScanner: React.FC<Props>;
17
38
  export {};
@@ -79,7 +79,7 @@ const isConvexQuadrilateral = (points) => {
79
79
  return false;
80
80
  }
81
81
  };
82
- const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, minStableFrames = 8, cameraProps, children, }) => {
82
+ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, minStableFrames = 8, cameraProps, children, detectionConfig = {}, }) => {
83
83
  const device = (0, react_native_vision_camera_1.useCameraDevice)('back');
84
84
  const { hasPermission, requestPermission } = (0, react_native_vision_camera_1.useCameraPermission)();
85
85
  const { resize } = (0, vision_camera_resize_plugin_1.useResizePlugin)();
@@ -99,20 +99,25 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
99
99
  const anchorConfidenceRef = (0, react_1.useRef)(0);
100
100
  const lastMeasurementRef = (0, react_1.useRef)(null);
101
101
  const frameSizeRef = (0, react_1.useRef)(null);
102
+ // Detection parameters - configurable via props with sensible defaults
103
+ const PROCESSING_WIDTH = detectionConfig.processingWidth ?? 1280;
104
+ const CANNY_LOW = detectionConfig.cannyLowThreshold ?? 40;
105
+ const CANNY_HIGH = detectionConfig.cannyHighThreshold ?? 120;
106
+ const SNAP_DISTANCE = detectionConfig.snapDistance ?? 8;
107
+ const MAX_ANCHOR_MISSES = detectionConfig.maxAnchorMisses ?? 20;
108
+ const REJECT_CENTER_DELTA = detectionConfig.maxCenterDelta ?? 200;
109
+ // Fixed parameters for algorithm stability
102
110
  const MAX_HISTORY = 5;
103
- const SNAP_DISTANCE = 8; // pixels; keep corners locked when movement is tiny (increased for stronger lock)
104
111
  const SNAP_CENTER_DISTANCE = 18;
105
- const BLEND_DISTANCE = 80; // pixels; softly ease between similar shapes (increased)
106
- const MAX_CENTER_DELTA = 120; // increased to allow more camera movement
107
- const REJECT_CENTER_DELTA = 200; // increased to maintain lock during camera movement
108
- const MAX_AREA_SHIFT = 0.55; // more tolerance for perspective changes
112
+ const BLEND_DISTANCE = 80;
113
+ const MAX_CENTER_DELTA = 120;
114
+ const MAX_AREA_SHIFT = 0.55;
109
115
  const HISTORY_RESET_DISTANCE = 90;
110
116
  const MIN_AREA_RATIO = 0.0002;
111
117
  const MAX_AREA_RATIO = 0.9;
112
118
  const MIN_EDGE_RATIO = 0.015;
113
- const MAX_ANCHOR_MISSES = 20; // increased to hold anchor longer when detection temporarily fails
114
119
  const MIN_CONFIDENCE_TO_HOLD = 2;
115
- const MAX_ANCHOR_CONFIDENCE = 30; // increased max confidence for stronger anchoring
120
+ const MAX_ANCHOR_CONFIDENCE = 30;
116
121
  const updateQuad = (0, react_native_worklets_core_1.useRunOnJS)((value) => {
117
122
  if (__DEV__) {
118
123
  console.log('[DocScanner] quad', value);
@@ -249,8 +254,8 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
249
254
  try {
250
255
  // Report frame size for coordinate transformation
251
256
  updateFrameSize(frame.width, frame.height);
252
- // Use higher resolution for better accuracy - 1280p for improved corner detection
253
- const ratio = 1280 / frame.width;
257
+ // Use configurable resolution for accuracy vs performance balance
258
+ const ratio = PROCESSING_WIDTH / frame.width;
254
259
  const width = Math.floor(frame.width * ratio);
255
260
  const height = Math.floor(frame.height * ratio);
256
261
  step = 'resize';
@@ -262,26 +267,42 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
262
267
  });
263
268
  step = 'frameBufferToMat';
264
269
  reportStage(step);
265
- const mat = react_native_fast_opencv_1.OpenCV.frameBufferToMat(height, width, 3, resized);
270
+ let mat = react_native_fast_opencv_1.OpenCV.frameBufferToMat(height, width, 3, resized);
266
271
  step = 'cvtColor';
267
272
  reportStage(step);
268
273
  react_native_fast_opencv_1.OpenCV.invoke('cvtColor', mat, mat, react_native_fast_opencv_1.ColorConversionCodes.COLOR_BGR2GRAY);
269
- const morphologyKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
274
+ // Enhanced morphological operations for noise reduction
275
+ const morphologyKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 7, 7);
270
276
  step = 'getStructuringElement';
271
277
  reportStage(step);
272
278
  const element = react_native_fast_opencv_1.OpenCV.invoke('getStructuringElement', react_native_fast_opencv_1.MorphShapes.MORPH_RECT, morphologyKernel);
273
279
  step = 'morphologyEx';
274
280
  reportStage(step);
281
+ // MORPH_CLOSE to fill small holes in edges
282
+ react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', mat, mat, react_native_fast_opencv_1.MorphTypes.MORPH_CLOSE, element);
283
+ // MORPH_OPEN to remove small noise
275
284
  react_native_fast_opencv_1.OpenCV.invoke('morphologyEx', mat, mat, react_native_fast_opencv_1.MorphTypes.MORPH_OPEN, element);
276
- const gaussianKernel = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Size, 5, 5);
277
- step = 'GaussianBlur';
285
+ // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
286
+ step = 'bilateralFilter';
278
287
  reportStage(step);
279
- react_native_fast_opencv_1.OpenCV.invoke('GaussianBlur', mat, mat, gaussianKernel, 0);
288
+ try {
289
+ const tempMat = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.Mat);
290
+ react_native_fast_opencv_1.OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
291
+ mat = tempMat;
292
+ }
293
+ catch (error) {
294
+ if (__DEV__) {
295
+ console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
296
+ }
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
+ }
280
302
  step = 'Canny';
281
303
  reportStage(step);
282
- // Improved Canny parameters for better edge detection accuracy
283
- // Lower threshold (50 -> more edges detected) and higher threshold (150 -> stronger edges kept)
284
- react_native_fast_opencv_1.OpenCV.invoke('Canny', mat, mat, 50, 150);
304
+ // Configurable Canny parameters for adaptive edge detection
305
+ react_native_fast_opencv_1.OpenCV.invoke('Canny', mat, mat, CANNY_LOW, CANNY_HIGH);
285
306
  step = 'createContours';
286
307
  reportStage(step);
287
308
  const contours = react_native_fast_opencv_1.OpenCV.createObject(react_native_fast_opencv_1.ObjectType.PointVectorOfVectors);
@@ -419,14 +440,19 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
419
440
  }, [quad]);
420
441
  (0, react_1.useEffect)(() => {
421
442
  const capture = async () => {
422
- if (autoCapture && quad && stable >= minStableFrames && camera.current) {
443
+ if (autoCapture && quad && stable >= minStableFrames && camera.current && frameSize) {
423
444
  const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
424
- onCapture?.({ path: photo.path, quad });
445
+ onCapture?.({
446
+ path: photo.path,
447
+ quad,
448
+ width: frameSize.width,
449
+ height: frameSize.height,
450
+ });
425
451
  setStable(0);
426
452
  }
427
453
  };
428
454
  capture();
429
- }, [autoCapture, minStableFrames, onCapture, quad, stable]);
455
+ }, [autoCapture, minStableFrames, onCapture, quad, stable, frameSize]);
430
456
  const { device: overrideDevice, ...cameraRestProps } = cameraProps ?? {};
431
457
  const resolvedDevice = overrideDevice ?? device;
432
458
  if (!resolvedDevice || !hasPermission) {
@@ -436,11 +462,16 @@ const DocScanner = ({ onCapture, overlayColor = '#e7a649', autoCapture = true, m
436
462
  react_1.default.createElement(react_native_vision_camera_1.Camera, { ref: handleCameraRef, style: react_native_1.StyleSheet.absoluteFillObject, device: resolvedDevice, isActive: true, photo: true, frameProcessor: frameProcessor, frameProcessorFps: 15, ...cameraRestProps }),
437
463
  react_1.default.createElement(overlay_1.Overlay, { quad: quad, color: overlayColor, frameSize: frameSize }),
438
464
  !autoCapture && (react_1.default.createElement(react_native_1.TouchableOpacity, { style: styles.button, onPress: async () => {
439
- if (!camera.current) {
465
+ if (!camera.current || !frameSize) {
440
466
  return;
441
467
  }
442
468
  const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
443
- onCapture?.({ path: photo.path, quad });
469
+ onCapture?.({
470
+ path: photo.path,
471
+ quad,
472
+ width: frameSize.width,
473
+ height: frameSize.height,
474
+ });
444
475
  } })),
445
476
  children));
446
477
  };
package/dist/index.d.ts CHANGED
@@ -1 +1,5 @@
1
- export * from './DocScanner';
1
+ export { DocScanner } from './DocScanner';
2
+ export { CropEditor } from './CropEditor';
3
+ export type { Point, Quad, Rectangle, CapturedDocument } from './types';
4
+ export type { DetectionConfig } from './DocScanner';
5
+ export { quadToRectangle, rectangleToQuad, scaleCoordinates, scaleRectangle, } from './utils/coordinate';
package/dist/index.js CHANGED
@@ -1,17 +1,14 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __exportStar = (this && this.__exportStar) || function(m, exports) {
14
- for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
15
- };
16
2
  Object.defineProperty(exports, "__esModule", { value: true });
17
- __exportStar(require("./DocScanner"), exports);
3
+ exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = exports.CropEditor = exports.DocScanner = void 0;
4
+ // Main components
5
+ var DocScanner_1 = require("./DocScanner");
6
+ Object.defineProperty(exports, "DocScanner", { enumerable: true, get: function () { return DocScanner_1.DocScanner; } });
7
+ var CropEditor_1 = require("./CropEditor");
8
+ Object.defineProperty(exports, "CropEditor", { enumerable: true, get: function () { return CropEditor_1.CropEditor; } });
9
+ // Utilities
10
+ var coordinate_1 = require("./utils/coordinate");
11
+ Object.defineProperty(exports, "quadToRectangle", { enumerable: true, get: function () { return coordinate_1.quadToRectangle; } });
12
+ Object.defineProperty(exports, "rectangleToQuad", { enumerable: true, get: function () { return coordinate_1.rectangleToQuad; } });
13
+ Object.defineProperty(exports, "scaleCoordinates", { enumerable: true, get: function () { return coordinate_1.scaleCoordinates; } });
14
+ Object.defineProperty(exports, "scaleRectangle", { enumerable: true, get: function () { return coordinate_1.scaleRectangle; } });
package/dist/types.d.ts CHANGED
@@ -2,3 +2,16 @@ export type Point = {
2
2
  x: number;
3
3
  y: number;
4
4
  };
5
+ export type Quad = [Point, Point, Point, Point];
6
+ export type Rectangle = {
7
+ topLeft: Point;
8
+ topRight: Point;
9
+ bottomRight: Point;
10
+ bottomLeft: Point;
11
+ };
12
+ export type CapturedDocument = {
13
+ path: string;
14
+ quad: Point[] | null;
15
+ width: number;
16
+ height: number;
17
+ };
@@ -0,0 +1,19 @@
1
+ import type { Point, Rectangle } from '../types';
2
+ /**
3
+ * Convert quad points array to Rectangle format for perspective cropper
4
+ * Assumes quad points are ordered: [topLeft, topRight, bottomRight, bottomLeft]
5
+ */
6
+ export declare const quadToRectangle: (quad: Point[]) => Rectangle | null;
7
+ /**
8
+ * Convert Rectangle format back to quad points array
9
+ */
10
+ export declare const rectangleToQuad: (rect: Rectangle) => Point[];
11
+ /**
12
+ * Scale coordinates from one dimension to another
13
+ * Useful when image dimensions differ from display dimensions
14
+ */
15
+ export declare const scaleCoordinates: (points: Point[], fromWidth: number, fromHeight: number, toWidth: number, toHeight: number) => Point[];
16
+ /**
17
+ * Scale a rectangle
18
+ */
19
+ export declare const scaleRectangle: (rect: Rectangle, fromWidth: number, fromHeight: number, toWidth: number, toHeight: number) => Rectangle;
@@ -0,0 +1,58 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.scaleRectangle = exports.scaleCoordinates = exports.rectangleToQuad = exports.quadToRectangle = void 0;
4
+ /**
5
+ * Convert quad points array to Rectangle format for perspective cropper
6
+ * Assumes quad points are ordered: [topLeft, topRight, bottomRight, bottomLeft]
7
+ */
8
+ const quadToRectangle = (quad) => {
9
+ if (!quad || quad.length !== 4) {
10
+ return null;
11
+ }
12
+ return {
13
+ topLeft: quad[0],
14
+ topRight: quad[1],
15
+ bottomRight: quad[2],
16
+ bottomLeft: quad[3],
17
+ };
18
+ };
19
+ exports.quadToRectangle = quadToRectangle;
20
+ /**
21
+ * Convert Rectangle format back to quad points array
22
+ */
23
+ const rectangleToQuad = (rect) => {
24
+ return [
25
+ rect.topLeft,
26
+ rect.topRight,
27
+ rect.bottomRight,
28
+ rect.bottomLeft,
29
+ ];
30
+ };
31
+ exports.rectangleToQuad = rectangleToQuad;
32
+ /**
33
+ * Scale coordinates from one dimension to another
34
+ * Useful when image dimensions differ from display dimensions
35
+ */
36
+ const scaleCoordinates = (points, fromWidth, fromHeight, toWidth, toHeight) => {
37
+ const scaleX = toWidth / fromWidth;
38
+ const scaleY = toHeight / fromHeight;
39
+ return points.map(p => ({
40
+ x: p.x * scaleX,
41
+ y: p.y * scaleY,
42
+ }));
43
+ };
44
+ exports.scaleCoordinates = scaleCoordinates;
45
+ /**
46
+ * Scale a rectangle
47
+ */
48
+ const scaleRectangle = (rect, fromWidth, fromHeight, toWidth, toHeight) => {
49
+ const scaleX = toWidth / fromWidth;
50
+ const scaleY = toHeight / fromHeight;
51
+ return {
52
+ topLeft: { x: rect.topLeft.x * scaleX, y: rect.topLeft.y * scaleY },
53
+ topRight: { x: rect.topRight.x * scaleX, y: rect.topRight.y * scaleY },
54
+ bottomRight: { x: rect.bottomRight.x * scaleX, y: rect.bottomRight.y * scaleY },
55
+ bottomLeft: { x: rect.bottomLeft.x * scaleX, y: rect.bottomLeft.y * scaleY },
56
+ };
57
+ };
58
+ exports.scaleRectangle = scaleRectangle;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "0.45.0",
3
+ "version": "0.47.0",
4
4
  "main": "dist/index.js",
5
5
  "types": "dist/index.d.ts",
6
6
  "repository": {
@@ -20,6 +20,7 @@
20
20
  "react": "*",
21
21
  "react-native": "*",
22
22
  "react-native-fast-opencv": "*",
23
+ "react-native-perspective-image-cropper": "*",
23
24
  "react-native-reanimated": "*",
24
25
  "react-native-vision-camera": "*",
25
26
  "react-native-worklets-core": "*",
@@ -0,0 +1,134 @@
1
+ import React, { useState, useCallback } from 'react';
2
+ import { View, StyleSheet, Image, Dimensions } from 'react-native';
3
+ import { CustomImageCropper } from 'react-native-perspective-image-cropper';
4
+ import type { Rectangle as CropperRectangle } from 'react-native-perspective-image-cropper';
5
+ import type { Point, Rectangle, CapturedDocument } from './types';
6
+ import { quadToRectangle, scaleRectangle } from './utils/coordinate';
7
+
8
+ interface CropEditorProps {
9
+ document: CapturedDocument;
10
+ overlayColor?: string;
11
+ overlayStrokeColor?: string;
12
+ handlerColor?: string;
13
+ enablePanStrict?: boolean;
14
+ onCropChange?: (rectangle: Rectangle) => void;
15
+ }
16
+
17
+ /**
18
+ * CropEditor Component
19
+ *
20
+ * Displays a captured document image with adjustable corner handles.
21
+ * Uses react-native-perspective-image-cropper for the cropping UI.
22
+ *
23
+ * @param document - The captured document with path and detected quad
24
+ * @param overlayColor - Color of the overlay outside the crop area (default: 'rgba(0,0,0,0.5)')
25
+ * @param overlayStrokeColor - Color of the crop boundary lines (default: '#e7a649')
26
+ * @param handlerColor - Color of the corner handles (default: '#e7a649')
27
+ * @param enablePanStrict - Enable strict panning behavior
28
+ * @param onCropChange - Callback when user adjusts crop corners
29
+ */
30
+ export const CropEditor: React.FC<CropEditorProps> = ({
31
+ document,
32
+ overlayColor = 'rgba(0,0,0,0.5)',
33
+ overlayStrokeColor = '#e7a649',
34
+ handlerColor = '#e7a649',
35
+ enablePanStrict = false,
36
+ onCropChange,
37
+ }) => {
38
+ const [imageSize, setImageSize] = useState<{ width: number; height: number } | null>(null);
39
+ const [displaySize, setDisplaySize] = useState<{ width: number; height: number }>({
40
+ width: Dimensions.get('window').width,
41
+ height: Dimensions.get('window').height,
42
+ });
43
+
44
+ // Get initial rectangle from detected quad or use default
45
+ const getInitialRectangle = useCallback((): CropperRectangle | undefined => {
46
+ if (!document.quad || !imageSize) {
47
+ return undefined;
48
+ }
49
+
50
+ const rect = quadToRectangle(document.quad);
51
+ if (!rect) {
52
+ return undefined;
53
+ }
54
+
55
+ // Scale from original detection coordinates to image coordinates
56
+ const scaled = scaleRectangle(
57
+ rect,
58
+ document.width,
59
+ document.height,
60
+ imageSize.width,
61
+ imageSize.height
62
+ );
63
+
64
+ return scaled as CropperRectangle;
65
+ }, [document.quad, document.width, document.height, imageSize]);
66
+
67
+ const handleImageLoad = useCallback((event: any) => {
68
+ const { width, height } = event.nativeEvent.source;
69
+ setImageSize({ width, height });
70
+ }, []);
71
+
72
+ const handleLayout = useCallback((event: any) => {
73
+ const { width, height } = event.nativeEvent.layout;
74
+ setDisplaySize({ width, height });
75
+ }, []);
76
+
77
+ const handleDragEnd = useCallback((coordinates: CropperRectangle) => {
78
+ if (!imageSize) {
79
+ return;
80
+ }
81
+
82
+ // Convert back to Rectangle type
83
+ const rect: Rectangle = {
84
+ topLeft: coordinates.topLeft,
85
+ topRight: coordinates.topRight,
86
+ bottomRight: coordinates.bottomRight,
87
+ bottomLeft: coordinates.bottomLeft,
88
+ };
89
+
90
+ onCropChange?.(rect);
91
+ }, [imageSize, onCropChange]);
92
+
93
+ // Wait for image to load to get dimensions
94
+ if (!imageSize) {
95
+ return (
96
+ <View style={styles.container} onLayout={handleLayout}>
97
+ <Image
98
+ source={{ uri: `file://${document.path}` }}
99
+ style={styles.hiddenImage}
100
+ onLoad={handleImageLoad}
101
+ resizeMode="contain"
102
+ />
103
+ </View>
104
+ );
105
+ }
106
+
107
+ return (
108
+ <View style={styles.container} onLayout={handleLayout}>
109
+ <CustomImageCropper
110
+ height={displaySize.height}
111
+ width={displaySize.width}
112
+ image={`file://${document.path}`}
113
+ rectangleCoordinates={getInitialRectangle()}
114
+ overlayColor={overlayColor}
115
+ overlayStrokeColor={overlayStrokeColor}
116
+ handlerColor={handlerColor}
117
+ enablePanStrict={enablePanStrict}
118
+ onDragEnd={handleDragEnd}
119
+ />
120
+ </View>
121
+ );
122
+ };
123
+
124
+ const styles = StyleSheet.create({
125
+ container: {
126
+ flex: 1,
127
+ backgroundColor: '#000',
128
+ },
129
+ hiddenImage: {
130
+ width: 1,
131
+ height: 1,
132
+ opacity: 0,
133
+ },
134
+ });
@@ -76,13 +76,33 @@ type CameraRef = {
76
76
 
77
77
  type CameraOverrides = Omit<React.ComponentProps<typeof Camera>, 'style' | 'ref' | 'frameProcessor'>;
78
78
 
79
+ /**
80
+ * Configuration for detection quality and behavior
81
+ */
82
+ export interface DetectionConfig {
83
+ /** Processing resolution width (default: 1280) - higher = more accurate but slower */
84
+ processingWidth?: number;
85
+ /** Canny edge detection lower threshold (default: 40) */
86
+ cannyLowThreshold?: number;
87
+ /** Canny edge detection upper threshold (default: 120) */
88
+ cannyHighThreshold?: number;
89
+ /** Snap distance in pixels for corner locking (default: 8) */
90
+ snapDistance?: number;
91
+ /** Max frames to hold anchor when detection fails (default: 20) */
92
+ maxAnchorMisses?: number;
93
+ /** Maximum center movement allowed while maintaining lock (default: 200px) */
94
+ maxCenterDelta?: number;
95
+ }
96
+
79
97
  interface Props {
80
- onCapture?: (photo: { path: string; quad: Point[] | null }) => void;
98
+ onCapture?: (photo: { path: string; quad: Point[] | null; width: number; height: number }) => void;
81
99
  overlayColor?: string;
82
100
  autoCapture?: boolean;
83
101
  minStableFrames?: number;
84
102
  cameraProps?: CameraOverrides;
85
103
  children?: ReactNode;
104
+ /** Advanced detection configuration */
105
+ detectionConfig?: DetectionConfig;
86
106
  }
87
107
 
88
108
  export const DocScanner: React.FC<Props> = ({
@@ -92,6 +112,7 @@ export const DocScanner: React.FC<Props> = ({
92
112
  minStableFrames = 8,
93
113
  cameraProps,
94
114
  children,
115
+ detectionConfig = {},
95
116
  }) => {
96
117
  const device = useCameraDevice('back');
97
118
  const { hasPermission, requestPermission } = useCameraPermission();
@@ -115,20 +136,26 @@ export const DocScanner: React.FC<Props> = ({
115
136
  const lastMeasurementRef = useRef<Point[] | null>(null);
116
137
  const frameSizeRef = useRef<{ width: number; height: number } | null>(null);
117
138
 
139
+ // Detection parameters - configurable via props with sensible defaults
140
+ const PROCESSING_WIDTH = detectionConfig.processingWidth ?? 1280;
141
+ const CANNY_LOW = detectionConfig.cannyLowThreshold ?? 40;
142
+ const CANNY_HIGH = detectionConfig.cannyHighThreshold ?? 120;
143
+ const SNAP_DISTANCE = detectionConfig.snapDistance ?? 8;
144
+ const MAX_ANCHOR_MISSES = detectionConfig.maxAnchorMisses ?? 20;
145
+ const REJECT_CENTER_DELTA = detectionConfig.maxCenterDelta ?? 200;
146
+
147
+ // Fixed parameters for algorithm stability
118
148
  const MAX_HISTORY = 5;
119
- const SNAP_DISTANCE = 8; // pixels; keep corners locked when movement is tiny (increased for stronger lock)
120
149
  const SNAP_CENTER_DISTANCE = 18;
121
- const BLEND_DISTANCE = 80; // pixels; softly ease between similar shapes (increased)
122
- const MAX_CENTER_DELTA = 120; // increased to allow more camera movement
123
- const REJECT_CENTER_DELTA = 200; // increased to maintain lock during camera movement
124
- const MAX_AREA_SHIFT = 0.55; // more tolerance for perspective changes
150
+ const BLEND_DISTANCE = 80;
151
+ const MAX_CENTER_DELTA = 120;
152
+ const MAX_AREA_SHIFT = 0.55;
125
153
  const HISTORY_RESET_DISTANCE = 90;
126
154
  const MIN_AREA_RATIO = 0.0002;
127
155
  const MAX_AREA_RATIO = 0.9;
128
156
  const MIN_EDGE_RATIO = 0.015;
129
- const MAX_ANCHOR_MISSES = 20; // increased to hold anchor longer when detection temporarily fails
130
157
  const MIN_CONFIDENCE_TO_HOLD = 2;
131
- const MAX_ANCHOR_CONFIDENCE = 30; // increased max confidence for stronger anchoring
158
+ const MAX_ANCHOR_CONFIDENCE = 30;
132
159
 
133
160
  const updateQuad = useRunOnJS((value: Point[] | null) => {
134
161
  if (__DEV__) {
@@ -290,8 +317,8 @@ export const DocScanner: React.FC<Props> = ({
290
317
  // Report frame size for coordinate transformation
291
318
  updateFrameSize(frame.width, frame.height);
292
319
 
293
- // Use higher resolution for better accuracy - 1280p for improved corner detection
294
- const ratio = 1280 / frame.width;
320
+ // Use configurable resolution for accuracy vs performance balance
321
+ const ratio = PROCESSING_WIDTH / frame.width;
295
322
  const width = Math.floor(frame.width * ratio);
296
323
  const height = Math.floor(frame.height * ratio);
297
324
  step = 'resize';
@@ -304,29 +331,45 @@ export const DocScanner: React.FC<Props> = ({
304
331
 
305
332
  step = 'frameBufferToMat';
306
333
  reportStage(step);
307
- const mat = OpenCV.frameBufferToMat(height, width, 3, resized);
334
+ let mat = OpenCV.frameBufferToMat(height, width, 3, resized);
308
335
 
309
336
  step = 'cvtColor';
310
337
  reportStage(step);
311
338
  OpenCV.invoke('cvtColor', mat, mat, ColorConversionCodes.COLOR_BGR2GRAY);
312
339
 
313
- const morphologyKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
340
+ // Enhanced morphological operations for noise reduction
341
+ const morphologyKernel = OpenCV.createObject(ObjectType.Size, 7, 7);
314
342
  step = 'getStructuringElement';
315
343
  reportStage(step);
316
344
  const element = OpenCV.invoke('getStructuringElement', MorphShapes.MORPH_RECT, morphologyKernel);
317
345
  step = 'morphologyEx';
318
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
319
350
  OpenCV.invoke('morphologyEx', mat, mat, MorphTypes.MORPH_OPEN, element);
320
351
 
321
- const gaussianKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
322
- step = 'GaussianBlur';
352
+ // Bilateral filter for edge-preserving smoothing (better quality than Gaussian)
353
+ step = 'bilateralFilter';
323
354
  reportStage(step);
324
- OpenCV.invoke('GaussianBlur', mat, mat, gaussianKernel, 0);
355
+ try {
356
+ const tempMat = OpenCV.createObject(ObjectType.Mat);
357
+ OpenCV.invoke('bilateralFilter', mat, tempMat, 9, 75, 75);
358
+ mat = tempMat;
359
+ } catch (error) {
360
+ if (__DEV__) {
361
+ console.warn('[DocScanner] bilateralFilter unavailable, falling back to GaussianBlur', error);
362
+ }
363
+ step = 'gaussianBlurFallback';
364
+ reportStage(step);
365
+ const blurKernel = OpenCV.createObject(ObjectType.Size, 5, 5);
366
+ OpenCV.invoke('GaussianBlur', mat, mat, blurKernel, 0);
367
+ }
368
+
325
369
  step = 'Canny';
326
370
  reportStage(step);
327
- // Improved Canny parameters for better edge detection accuracy
328
- // Lower threshold (50 -> more edges detected) and higher threshold (150 -> stronger edges kept)
329
- OpenCV.invoke('Canny', mat, mat, 50, 150);
371
+ // Configurable Canny parameters for adaptive edge detection
372
+ OpenCV.invoke('Canny', mat, mat, CANNY_LOW, CANNY_HIGH);
330
373
 
331
374
  step = 'createContours';
332
375
  reportStage(step);
@@ -489,15 +532,20 @@ export const DocScanner: React.FC<Props> = ({
489
532
 
490
533
  useEffect(() => {
491
534
  const capture = async () => {
492
- if (autoCapture && quad && stable >= minStableFrames && camera.current) {
535
+ if (autoCapture && quad && stable >= minStableFrames && camera.current && frameSize) {
493
536
  const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
494
- onCapture?.({ path: photo.path, quad });
537
+ onCapture?.({
538
+ path: photo.path,
539
+ quad,
540
+ width: frameSize.width,
541
+ height: frameSize.height,
542
+ });
495
543
  setStable(0);
496
544
  }
497
545
  };
498
546
 
499
547
  capture();
500
- }, [autoCapture, minStableFrames, onCapture, quad, stable]);
548
+ }, [autoCapture, minStableFrames, onCapture, quad, stable, frameSize]);
501
549
 
502
550
  const { device: overrideDevice, ...cameraRestProps } = cameraProps ?? {};
503
551
  const resolvedDevice = overrideDevice ?? device;
@@ -523,12 +571,17 @@ export const DocScanner: React.FC<Props> = ({
523
571
  <TouchableOpacity
524
572
  style={styles.button}
525
573
  onPress={async () => {
526
- if (!camera.current) {
574
+ if (!camera.current || !frameSize) {
527
575
  return;
528
576
  }
529
577
 
530
578
  const photo = await camera.current.takePhoto({ qualityPrioritization: 'quality' });
531
- onCapture?.({ path: photo.path, quad });
579
+ onCapture?.({
580
+ path: photo.path,
581
+ quad,
582
+ width: frameSize.width,
583
+ height: frameSize.height,
584
+ });
532
585
  }}
533
586
  />
534
587
  )}
package/src/external.d.ts CHANGED
@@ -107,3 +107,28 @@ declare module '@shopify/react-native-skia' {
107
107
 
108
108
  export const Path: ComponentType<PathProps>;
109
109
  }
110
+
111
+ declare module 'react-native-perspective-image-cropper' {
112
+ import type { ComponentType } from 'react';
113
+
114
+ export type Rectangle = {
115
+ topLeft: { x: number; y: number };
116
+ topRight: { x: number; y: number };
117
+ bottomLeft: { x: number; y: number };
118
+ bottomRight: { x: number; y: number };
119
+ };
120
+
121
+ export type CustomImageCropperProps = {
122
+ height: number;
123
+ width: number;
124
+ image: string;
125
+ rectangleCoordinates?: Rectangle;
126
+ overlayColor?: string;
127
+ overlayStrokeColor?: string;
128
+ handlerColor?: string;
129
+ enablePanStrict?: boolean;
130
+ onDragEnd?: (coordinates: Rectangle) => void;
131
+ };
132
+
133
+ export const CustomImageCropper: ComponentType<CustomImageCropperProps>;
134
+ }
package/src/index.ts CHANGED
@@ -1 +1,15 @@
1
- export * from './DocScanner';
1
+ // Main components
2
+ export { DocScanner } from './DocScanner';
3
+ export { CropEditor } from './CropEditor';
4
+
5
+ // Types
6
+ export type { Point, Quad, Rectangle, CapturedDocument } from './types';
7
+ export type { DetectionConfig } from './DocScanner';
8
+
9
+ // Utilities
10
+ export {
11
+ quadToRectangle,
12
+ rectangleToQuad,
13
+ scaleCoordinates,
14
+ scaleRectangle,
15
+ } from './utils/coordinate';
package/src/types.ts CHANGED
@@ -1 +1,17 @@
1
1
  export type Point = { x: number; y: number };
2
+
3
+ export type Quad = [Point, Point, Point, Point];
4
+
5
+ export type Rectangle = {
6
+ topLeft: Point;
7
+ topRight: Point;
8
+ bottomRight: Point;
9
+ bottomLeft: Point;
10
+ };
11
+
12
+ export type CapturedDocument = {
13
+ path: string;
14
+ quad: Point[] | null;
15
+ width: number;
16
+ height: number;
17
+ };
@@ -0,0 +1,71 @@
1
+ import type { Point, Rectangle } from '../types';
2
+
3
+ /**
4
+ * Convert quad points array to Rectangle format for perspective cropper
5
+ * Assumes quad points are ordered: [topLeft, topRight, bottomRight, bottomLeft]
6
+ */
7
+ export const quadToRectangle = (quad: Point[]): Rectangle | null => {
8
+ if (!quad || quad.length !== 4) {
9
+ return null;
10
+ }
11
+
12
+ return {
13
+ topLeft: quad[0],
14
+ topRight: quad[1],
15
+ bottomRight: quad[2],
16
+ bottomLeft: quad[3],
17
+ };
18
+ };
19
+
20
+ /**
21
+ * Convert Rectangle format back to quad points array
22
+ */
23
+ export const rectangleToQuad = (rect: Rectangle): Point[] => {
24
+ return [
25
+ rect.topLeft,
26
+ rect.topRight,
27
+ rect.bottomRight,
28
+ rect.bottomLeft,
29
+ ];
30
+ };
31
+
32
+ /**
33
+ * Scale coordinates from one dimension to another
34
+ * Useful when image dimensions differ from display dimensions
35
+ */
36
+ export const scaleCoordinates = (
37
+ points: Point[],
38
+ fromWidth: number,
39
+ fromHeight: number,
40
+ toWidth: number,
41
+ toHeight: number
42
+ ): Point[] => {
43
+ const scaleX = toWidth / fromWidth;
44
+ const scaleY = toHeight / fromHeight;
45
+
46
+ return points.map(p => ({
47
+ x: p.x * scaleX,
48
+ y: p.y * scaleY,
49
+ }));
50
+ };
51
+
52
+ /**
53
+ * Scale a rectangle
54
+ */
55
+ export const scaleRectangle = (
56
+ rect: Rectangle,
57
+ fromWidth: number,
58
+ fromHeight: number,
59
+ toWidth: number,
60
+ toHeight: number
61
+ ): Rectangle => {
62
+ const scaleX = toWidth / fromWidth;
63
+ const scaleY = toHeight / fromHeight;
64
+
65
+ return {
66
+ topLeft: { x: rect.topLeft.x * scaleX, y: rect.topLeft.y * scaleY },
67
+ topRight: { x: rect.topRight.x * scaleX, y: rect.topRight.y * scaleY },
68
+ bottomRight: { x: rect.bottomRight.x * scaleX, y: rect.bottomRight.y * scaleY },
69
+ bottomLeft: { x: rect.bottomLeft.x * scaleX, y: rect.bottomLeft.y * scaleY },
70
+ };
71
+ };