react-native-rectangle-doc-scanner 3.130.0 → 3.131.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/android/build.gradle +78 -0
- package/android/src/main/AndroidManifest.xml +8 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/CameraController.kt +229 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentDetector.kt +263 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerModule.kt +128 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerPackage.kt +16 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerView.kt +363 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/DocumentScannerViewManager.kt +102 -0
- package/android/src/main/kotlin/com/reactnativerectangledocscanner/ImageProcessor.kt +220 -0
- package/package.json +5 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
buildscript {
|
|
2
|
+
ext.kotlin_version = '1.8.21'
|
|
3
|
+
repositories {
|
|
4
|
+
google()
|
|
5
|
+
mavenCentral()
|
|
6
|
+
}
|
|
7
|
+
dependencies {
|
|
8
|
+
classpath "com.android.tools.build:gradle:7.4.2"
|
|
9
|
+
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
apply plugin: 'com.android.library'
|
|
14
|
+
apply plugin: 'kotlin-android'
|
|
15
|
+
|
|
16
|
+
def safeExtGet(prop, fallback) {
|
|
17
|
+
rootProject.ext.has(prop) ? rootProject.ext.get(prop) : fallback
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
android {
|
|
21
|
+
compileSdkVersion safeExtGet('compileSdkVersion', 33)
|
|
22
|
+
|
|
23
|
+
defaultConfig {
|
|
24
|
+
minSdkVersion safeExtGet('minSdkVersion', 21)
|
|
25
|
+
targetSdkVersion safeExtGet('targetSdkVersion', 33)
|
|
26
|
+
versionCode 1
|
|
27
|
+
versionName "1.0"
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
buildTypes {
|
|
31
|
+
release {
|
|
32
|
+
minifyEnabled false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
compileOptions {
|
|
37
|
+
sourceCompatibility JavaVersion.VERSION_1_8
|
|
38
|
+
targetCompatibility JavaVersion.VERSION_1_8
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
kotlinOptions {
|
|
42
|
+
jvmTarget = '1.8'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
sourceSets {
|
|
46
|
+
main {
|
|
47
|
+
java.srcDirs += 'src/main/kotlin'
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
repositories {
|
|
53
|
+
google()
|
|
54
|
+
mavenCentral()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
dependencies {
|
|
58
|
+
implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version"
|
|
59
|
+
implementation "com.facebook.react:react-native:+"
|
|
60
|
+
|
|
61
|
+
// CameraX dependencies
|
|
62
|
+
def camerax_version = "1.3.0"
|
|
63
|
+
implementation "androidx.camera:camera-core:${camerax_version}"
|
|
64
|
+
implementation "androidx.camera:camera-camera2:${camerax_version}"
|
|
65
|
+
implementation "androidx.camera:camera-lifecycle:${camerax_version}"
|
|
66
|
+
implementation "androidx.camera:camera-view:${camerax_version}"
|
|
67
|
+
|
|
68
|
+
// OpenCV for document detection
|
|
69
|
+
implementation 'org.opencv:opencv:4.8.0'
|
|
70
|
+
|
|
71
|
+
// Coroutines for async operations
|
|
72
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-core:1.7.3'
|
|
73
|
+
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3'
|
|
74
|
+
|
|
75
|
+
// AndroidX
|
|
76
|
+
implementation 'androidx.core:core-ktx:1.10.1'
|
|
77
|
+
implementation 'androidx.appcompat:appcompat:1.6.1'
|
|
78
|
+
}
|
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
package="com.reactnativerectangledocscanner">
|
|
4
4
|
|
|
5
5
|
<uses-permission android:name="android.permission.CAMERA" />
|
|
6
|
+
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
|
7
|
+
android:maxSdkVersion="28" />
|
|
8
|
+
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"
|
|
9
|
+
android:maxSdkVersion="32" />
|
|
10
|
+
|
|
11
|
+
<uses-feature android:name="android.hardware.camera" android:required="true" />
|
|
12
|
+
<uses-feature android:name="android.hardware.camera.autofocus" android:required="false" />
|
|
13
|
+
<uses-feature android:name="android.hardware.camera.flash" android:required="false" />
|
|
6
14
|
|
|
7
15
|
<application>
|
|
8
16
|
<!-- Placeholder application entry, not used for libraries. -->
|
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
package com.reactnativerectangledocscanner
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import android.util.Size
|
|
6
|
+
import androidx.camera.core.*
|
|
7
|
+
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
8
|
+
import androidx.camera.view.PreviewView
|
|
9
|
+
import androidx.core.content.ContextCompat
|
|
10
|
+
import androidx.lifecycle.LifecycleOwner
|
|
11
|
+
import java.io.File
|
|
12
|
+
import java.util.concurrent.ExecutorService
|
|
13
|
+
import java.util.concurrent.Executors
|
|
14
|
+
|
|
15
|
+
class CameraController(
|
|
16
|
+
private val context: Context,
|
|
17
|
+
private val lifecycleOwner: LifecycleOwner,
|
|
18
|
+
private val previewView: PreviewView
|
|
19
|
+
) {
|
|
20
|
+
private var camera: Camera? = null
|
|
21
|
+
private var cameraProvider: ProcessCameraProvider? = null
|
|
22
|
+
private var imageCapture: ImageCapture? = null
|
|
23
|
+
private var imageAnalysis: ImageAnalysis? = null
|
|
24
|
+
private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
25
|
+
|
|
26
|
+
private var useFrontCamera = false
|
|
27
|
+
private var torchEnabled = false
|
|
28
|
+
|
|
29
|
+
var onFrameAnalyzed: ((Rectangle?) -> Unit)? = null
|
|
30
|
+
|
|
31
|
+
companion object {
|
|
32
|
+
private const val TAG = "CameraController"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Start camera with preview and analysis
|
|
37
|
+
*/
|
|
38
|
+
fun startCamera(
|
|
39
|
+
useFrontCam: Boolean = false,
|
|
40
|
+
enableDetection: Boolean = true
|
|
41
|
+
) {
|
|
42
|
+
this.useFrontCamera = useFrontCam
|
|
43
|
+
|
|
44
|
+
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
|
45
|
+
|
|
46
|
+
cameraProviderFuture.addListener({
|
|
47
|
+
try {
|
|
48
|
+
cameraProvider = cameraProviderFuture.get()
|
|
49
|
+
bindCameraUseCases(enableDetection)
|
|
50
|
+
} catch (e: Exception) {
|
|
51
|
+
Log.e(TAG, "Failed to start camera", e)
|
|
52
|
+
}
|
|
53
|
+
}, ContextCompat.getMainExecutor(context))
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Stop camera and release resources
|
|
58
|
+
*/
|
|
59
|
+
fun stopCamera() {
|
|
60
|
+
cameraProvider?.unbindAll()
|
|
61
|
+
camera = null
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Bind camera use cases (preview, capture, analysis)
|
|
66
|
+
*/
|
|
67
|
+
private fun bindCameraUseCases(enableDetection: Boolean) {
|
|
68
|
+
val cameraProvider = cameraProvider ?: return
|
|
69
|
+
|
|
70
|
+
// Select camera
|
|
71
|
+
val cameraSelector = if (useFrontCamera) {
|
|
72
|
+
CameraSelector.DEFAULT_FRONT_CAMERA
|
|
73
|
+
} else {
|
|
74
|
+
CameraSelector.DEFAULT_BACK_CAMERA
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Preview use case
|
|
78
|
+
val preview = Preview.Builder()
|
|
79
|
+
.setTargetResolution(Size(1080, 1920))
|
|
80
|
+
.build()
|
|
81
|
+
.also {
|
|
82
|
+
it.setSurfaceProvider(previewView.surfaceProvider)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Image capture use case (high resolution for document scanning)
|
|
86
|
+
imageCapture = ImageCapture.Builder()
|
|
87
|
+
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
|
88
|
+
.setTargetResolution(Size(1920, 2560))
|
|
89
|
+
.build()
|
|
90
|
+
|
|
91
|
+
// Image analysis use case for rectangle detection
|
|
92
|
+
imageAnalysis = if (enableDetection) {
|
|
93
|
+
ImageAnalysis.Builder()
|
|
94
|
+
.setTargetResolution(Size(720, 1280))
|
|
95
|
+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
96
|
+
.build()
|
|
97
|
+
.also { analysis ->
|
|
98
|
+
analysis.setAnalyzer(cameraExecutor) { imageProxy ->
|
|
99
|
+
analyzeFrame(imageProxy)
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
} else {
|
|
103
|
+
null
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
// Unbind all use cases before rebinding
|
|
108
|
+
cameraProvider.unbindAll()
|
|
109
|
+
|
|
110
|
+
// Bind use cases to camera
|
|
111
|
+
val useCases = mutableListOf<UseCase>(preview, imageCapture!!)
|
|
112
|
+
if (imageAnalysis != null) {
|
|
113
|
+
useCases.add(imageAnalysis!!)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
camera = cameraProvider.bindToLifecycle(
|
|
117
|
+
lifecycleOwner,
|
|
118
|
+
cameraSelector,
|
|
119
|
+
*useCases.toTypedArray()
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
// Restore torch state if it was enabled
|
|
123
|
+
if (torchEnabled) {
|
|
124
|
+
setTorchEnabled(true)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
Log.d(TAG, "Camera started successfully")
|
|
128
|
+
} catch (e: Exception) {
|
|
129
|
+
Log.e(TAG, "Failed to bind camera use cases", e)
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Analyze frame for rectangle detection
|
|
135
|
+
*/
|
|
136
|
+
private fun analyzeFrame(imageProxy: ImageProxy) {
|
|
137
|
+
try {
|
|
138
|
+
val buffer = imageProxy.planes[0].buffer
|
|
139
|
+
val bytes = ByteArray(buffer.remaining())
|
|
140
|
+
buffer.get(bytes)
|
|
141
|
+
|
|
142
|
+
// Note: Simplified - in production you'd convert ImageProxy to proper format
|
|
143
|
+
// For now, we'll skip real-time detection in the analyzer and do it on capture
|
|
144
|
+
onFrameAnalyzed?.invoke(null)
|
|
145
|
+
} catch (e: Exception) {
|
|
146
|
+
Log.e(TAG, "Error analyzing frame", e)
|
|
147
|
+
} finally {
|
|
148
|
+
imageProxy.close()
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Capture photo
|
|
154
|
+
*/
|
|
155
|
+
fun capturePhoto(
|
|
156
|
+
outputDirectory: File,
|
|
157
|
+
onImageCaptured: (File) -> Unit,
|
|
158
|
+
onError: (Exception) -> Unit
|
|
159
|
+
) {
|
|
160
|
+
val imageCapture = imageCapture ?: run {
|
|
161
|
+
onError(Exception("Image capture not initialized"))
|
|
162
|
+
return
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
val photoFile = File(
|
|
166
|
+
outputDirectory,
|
|
167
|
+
"doc_scan_${System.currentTimeMillis()}.jpg"
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
|
171
|
+
|
|
172
|
+
imageCapture.takePicture(
|
|
173
|
+
outputOptions,
|
|
174
|
+
ContextCompat.getMainExecutor(context),
|
|
175
|
+
object : ImageCapture.OnImageSavedCallback {
|
|
176
|
+
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
|
177
|
+
Log.d(TAG, "Photo capture succeeded: ${photoFile.absolutePath}")
|
|
178
|
+
onImageCaptured(photoFile)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
override fun onError(exception: ImageCaptureException) {
|
|
182
|
+
Log.e(TAG, "Photo capture failed", exception)
|
|
183
|
+
onError(exception)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Enable or disable torch (flashlight)
|
|
191
|
+
*/
|
|
192
|
+
fun setTorchEnabled(enabled: Boolean) {
|
|
193
|
+
torchEnabled = enabled
|
|
194
|
+
camera?.cameraControl?.enableTorch(enabled)
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Switch between front and back camera
|
|
199
|
+
*/
|
|
200
|
+
fun switchCamera() {
|
|
201
|
+
useFrontCamera = !useFrontCamera
|
|
202
|
+
startCamera(useFrontCamera)
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Check if torch is available
|
|
207
|
+
*/
|
|
208
|
+
fun isTorchAvailable(): Boolean {
|
|
209
|
+
return camera?.cameraInfo?.hasFlashUnit() == true
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Focus at specific point
|
|
214
|
+
*/
|
|
215
|
+
fun focusAt(x: Float, y: Float) {
|
|
216
|
+
val factory = previewView.meteringPointFactory
|
|
217
|
+
val point = factory.createPoint(x, y)
|
|
218
|
+
val action = FocusMeteringAction.Builder(point).build()
|
|
219
|
+
camera?.cameraControl?.startFocusAndMetering(action)
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Cleanup resources
|
|
224
|
+
*/
|
|
225
|
+
fun shutdown() {
|
|
226
|
+
cameraExecutor.shutdown()
|
|
227
|
+
stopCamera()
|
|
228
|
+
}
|
|
229
|
+
}
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
package com.reactnativerectangledocscanner
|
|
2
|
+
|
|
3
|
+
import android.graphics.Bitmap
|
|
4
|
+
import android.util.Log
|
|
5
|
+
import org.opencv.android.Utils
|
|
6
|
+
import org.opencv.core.*
|
|
7
|
+
import org.opencv.imgproc.Imgproc
|
|
8
|
+
import kotlin.math.abs
|
|
9
|
+
import kotlin.math.sqrt
|
|
10
|
+
|
|
11
|
+
data class Rectangle(
|
|
12
|
+
val topLeft: Point,
|
|
13
|
+
val topRight: Point,
|
|
14
|
+
val bottomLeft: Point,
|
|
15
|
+
val bottomRight: Point
|
|
16
|
+
) {
|
|
17
|
+
fun toMap(): Map<String, Map<String, Double>> {
|
|
18
|
+
return mapOf(
|
|
19
|
+
"topLeft" to mapOf("x" to topLeft.x, "y" to topLeft.y),
|
|
20
|
+
"topRight" to mapOf("x" to topRight.x, "y" to topRight.y),
|
|
21
|
+
"bottomLeft" to mapOf("x" to bottomLeft.x, "y" to bottomLeft.y),
|
|
22
|
+
"bottomRight" to mapOf("x" to bottomRight.x, "y" to bottomRight.y)
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
enum class RectangleQuality {
|
|
28
|
+
GOOD,
|
|
29
|
+
BAD_ANGLE,
|
|
30
|
+
TOO_FAR
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
class DocumentDetector {
|
|
34
|
+
companion object {
|
|
35
|
+
private const val TAG = "DocumentDetector"
|
|
36
|
+
|
|
37
|
+
init {
|
|
38
|
+
try {
|
|
39
|
+
System.loadLibrary("opencv_java4")
|
|
40
|
+
Log.d(TAG, "OpenCV library loaded successfully")
|
|
41
|
+
} catch (e: Exception) {
|
|
42
|
+
Log.e(TAG, "Failed to load OpenCV library", e)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Detect document rectangle in the bitmap
|
|
48
|
+
* Returns the largest detected rectangle or null if none found
|
|
49
|
+
*/
|
|
50
|
+
fun detectRectangle(bitmap: Bitmap): Rectangle? {
|
|
51
|
+
val mat = Mat()
|
|
52
|
+
Utils.bitmapToMat(bitmap, mat)
|
|
53
|
+
|
|
54
|
+
val rectangle = detectRectangleInMat(mat)
|
|
55
|
+
|
|
56
|
+
mat.release()
|
|
57
|
+
|
|
58
|
+
return rectangle
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Detect document rectangle in YUV image format (from camera)
|
|
63
|
+
*/
|
|
64
|
+
fun detectRectangleInYUV(
|
|
65
|
+
yuvBytes: ByteArray,
|
|
66
|
+
width: Int,
|
|
67
|
+
height: Int,
|
|
68
|
+
rotation: Int
|
|
69
|
+
): Rectangle? {
|
|
70
|
+
// Create Mat from YUV data
|
|
71
|
+
val yuvMat = Mat(height + height / 2, width, CvType.CV_8UC1)
|
|
72
|
+
yuvMat.put(0, 0, yuvBytes)
|
|
73
|
+
|
|
74
|
+
// Convert YUV to RGB
|
|
75
|
+
val rgbMat = Mat()
|
|
76
|
+
Imgproc.cvtColor(yuvMat, rgbMat, Imgproc.COLOR_YUV2RGB_NV21)
|
|
77
|
+
|
|
78
|
+
// Rotate if needed
|
|
79
|
+
if (rotation != 0) {
|
|
80
|
+
val rotationCode = when (rotation) {
|
|
81
|
+
90 -> Core.ROTATE_90_CLOCKWISE
|
|
82
|
+
180 -> Core.ROTATE_180
|
|
83
|
+
270 -> Core.ROTATE_90_COUNTERCLOCKWISE
|
|
84
|
+
else -> null
|
|
85
|
+
}
|
|
86
|
+
if (rotationCode != null) {
|
|
87
|
+
Core.rotate(rgbMat, rgbMat, rotationCode)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
val rectangle = detectRectangleInMat(rgbMat)
|
|
92
|
+
|
|
93
|
+
yuvMat.release()
|
|
94
|
+
rgbMat.release()
|
|
95
|
+
|
|
96
|
+
return rectangle
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Core detection algorithm using OpenCV
|
|
101
|
+
*/
|
|
102
|
+
private fun detectRectangleInMat(srcMat: Mat): Rectangle? {
|
|
103
|
+
val grayMat = Mat()
|
|
104
|
+
val blurredMat = Mat()
|
|
105
|
+
val cannyMat = Mat()
|
|
106
|
+
|
|
107
|
+
try {
|
|
108
|
+
// Convert to grayscale
|
|
109
|
+
if (srcMat.channels() > 1) {
|
|
110
|
+
Imgproc.cvtColor(srcMat, grayMat, Imgproc.COLOR_RGB2GRAY)
|
|
111
|
+
} else {
|
|
112
|
+
srcMat.copyTo(grayMat)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Apply Gaussian blur to reduce noise
|
|
116
|
+
Imgproc.GaussianBlur(grayMat, blurredMat, Size(5.0, 5.0), 0.0)
|
|
117
|
+
|
|
118
|
+
// Apply Canny edge detection
|
|
119
|
+
Imgproc.Canny(blurredMat, cannyMat, 75.0, 200.0)
|
|
120
|
+
|
|
121
|
+
// Find contours
|
|
122
|
+
val contours = mutableListOf<MatOfPoint>()
|
|
123
|
+
val hierarchy = Mat()
|
|
124
|
+
Imgproc.findContours(
|
|
125
|
+
cannyMat,
|
|
126
|
+
contours,
|
|
127
|
+
hierarchy,
|
|
128
|
+
Imgproc.RETR_EXTERNAL,
|
|
129
|
+
Imgproc.CHAIN_APPROX_SIMPLE
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
// Find the largest contour that approximates to a quadrilateral
|
|
133
|
+
var largestRectangle: Rectangle? = null
|
|
134
|
+
var largestArea = 0.0
|
|
135
|
+
|
|
136
|
+
for (contour in contours) {
|
|
137
|
+
val contourArea = Imgproc.contourArea(contour)
|
|
138
|
+
|
|
139
|
+
// Filter small contours
|
|
140
|
+
if (contourArea < 1000) continue
|
|
141
|
+
|
|
142
|
+
// Approximate contour to polygon
|
|
143
|
+
val approx = MatOfPoint2f()
|
|
144
|
+
val contour2f = MatOfPoint2f(*contour.toArray())
|
|
145
|
+
val epsilon = 0.02 * Imgproc.arcLength(contour2f, true)
|
|
146
|
+
Imgproc.approxPolyDP(contour2f, approx, epsilon, true)
|
|
147
|
+
|
|
148
|
+
// Check if it's a quadrilateral
|
|
149
|
+
if (approx.total() == 4L && Imgproc.isContourConvex(MatOfPoint(*approx.toArray()))) {
|
|
150
|
+
val points = approx.toArray()
|
|
151
|
+
|
|
152
|
+
if (contourArea > largestArea) {
|
|
153
|
+
largestArea = contourArea
|
|
154
|
+
largestRectangle = orderPoints(points)
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
approx.release()
|
|
159
|
+
contour2f.release()
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
hierarchy.release()
|
|
163
|
+
contours.forEach { it.release() }
|
|
164
|
+
|
|
165
|
+
return largestRectangle
|
|
166
|
+
} finally {
|
|
167
|
+
grayMat.release()
|
|
168
|
+
blurredMat.release()
|
|
169
|
+
cannyMat.release()
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* Order points in consistent order: topLeft, topRight, bottomLeft, bottomRight
|
|
175
|
+
*/
|
|
176
|
+
private fun orderPoints(points: Array<Point>): Rectangle {
|
|
177
|
+
// Sort by y-coordinate
|
|
178
|
+
val sorted = points.sortedBy { it.y }
|
|
179
|
+
|
|
180
|
+
// Top two points
|
|
181
|
+
val topPoints = sorted.take(2).sortedBy { it.x }
|
|
182
|
+
val topLeft = topPoints[0]
|
|
183
|
+
val topRight = topPoints[1]
|
|
184
|
+
|
|
185
|
+
// Bottom two points
|
|
186
|
+
val bottomPoints = sorted.takeLast(2).sortedBy { it.x }
|
|
187
|
+
val bottomLeft = bottomPoints[0]
|
|
188
|
+
val bottomRight = bottomPoints[1]
|
|
189
|
+
|
|
190
|
+
return Rectangle(topLeft, topRight, bottomLeft, bottomRight)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Evaluate rectangle quality (matching iOS logic)
|
|
195
|
+
*/
|
|
196
|
+
fun evaluateRectangleQuality(
|
|
197
|
+
rectangle: Rectangle,
|
|
198
|
+
imageWidth: Int,
|
|
199
|
+
imageHeight: Int
|
|
200
|
+
): RectangleQuality {
|
|
201
|
+
// Check for bad angles
|
|
202
|
+
val topYDiff = abs(rectangle.topRight.y - rectangle.topLeft.y)
|
|
203
|
+
val bottomYDiff = abs(rectangle.bottomLeft.y - rectangle.bottomRight.y)
|
|
204
|
+
val leftXDiff = abs(rectangle.topLeft.x - rectangle.bottomLeft.x)
|
|
205
|
+
val rightXDiff = abs(rectangle.topRight.x - rectangle.bottomRight.x)
|
|
206
|
+
|
|
207
|
+
if (topYDiff > 100 || bottomYDiff > 100 || leftXDiff > 100 || rightXDiff > 100) {
|
|
208
|
+
return RectangleQuality.BAD_ANGLE
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Check if rectangle is too far from edges (too small)
|
|
212
|
+
val margin = 150.0
|
|
213
|
+
if (rectangle.topLeft.y > margin ||
|
|
214
|
+
rectangle.topRight.y > margin ||
|
|
215
|
+
rectangle.bottomLeft.y < (imageHeight - margin) ||
|
|
216
|
+
rectangle.bottomRight.y < (imageHeight - margin)
|
|
217
|
+
) {
|
|
218
|
+
return RectangleQuality.TOO_FAR
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
return RectangleQuality.GOOD
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Calculate perimeter of rectangle
|
|
226
|
+
*/
|
|
227
|
+
fun calculatePerimeter(rectangle: Rectangle): Double {
|
|
228
|
+
val width = distance(rectangle.topLeft, rectangle.topRight)
|
|
229
|
+
val height = distance(rectangle.topLeft, rectangle.bottomLeft)
|
|
230
|
+
return 2 * (width + height)
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Calculate distance between two points
|
|
235
|
+
*/
|
|
236
|
+
private fun distance(p1: Point, p2: Point): Double {
|
|
237
|
+
val dx = p1.x - p2.x
|
|
238
|
+
val dy = p1.y - p2.y
|
|
239
|
+
return sqrt(dx * dx + dy * dy)
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Transform rectangle coordinates from image space to view space
|
|
244
|
+
*/
|
|
245
|
+
fun transformRectangleToViewCoordinates(
|
|
246
|
+
rectangle: Rectangle,
|
|
247
|
+
imageWidth: Int,
|
|
248
|
+
imageHeight: Int,
|
|
249
|
+
viewWidth: Int,
|
|
250
|
+
viewHeight: Int
|
|
251
|
+
): Rectangle {
|
|
252
|
+
val scaleX = viewWidth.toDouble() / imageWidth
|
|
253
|
+
val scaleY = viewHeight.toDouble() / imageHeight
|
|
254
|
+
|
|
255
|
+
return Rectangle(
|
|
256
|
+
Point(rectangle.topLeft.x * scaleX, rectangle.topLeft.y * scaleY),
|
|
257
|
+
Point(rectangle.topRight.x * scaleX, rectangle.topRight.y * scaleY),
|
|
258
|
+
Point(rectangle.bottomLeft.x * scaleX, rectangle.bottomLeft.y * scaleY),
|
|
259
|
+
Point(rectangle.bottomRight.x * scaleX, rectangle.bottomRight.y * scaleY)
|
|
260
|
+
)
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|