react-native-rectangle-doc-scanner 7.65.0 → 9.0.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.
|
@@ -1,90 +1,53 @@
|
|
|
1
1
|
package com.reactnativerectangledocscanner
|
|
2
2
|
|
|
3
|
-
import android.Manifest
|
|
4
3
|
import android.content.Context
|
|
5
|
-
import android.content.pm.PackageManager
|
|
6
|
-
import android.graphics.Bitmap
|
|
7
|
-
import android.graphics.BitmapFactory
|
|
8
|
-
import android.graphics.Matrix
|
|
9
|
-
import android.graphics.SurfaceTexture
|
|
10
|
-
import android.graphics.Rect
|
|
11
|
-
import android.graphics.RectF
|
|
12
|
-
import android.graphics.ImageFormat
|
|
13
|
-
import android.hardware.camera2.CameraCaptureSession
|
|
14
|
-
import android.hardware.camera2.CameraCharacteristics
|
|
15
|
-
import android.hardware.camera2.CameraDevice
|
|
16
|
-
import android.hardware.camera2.CameraManager
|
|
17
|
-
import android.hardware.camera2.CaptureRequest
|
|
18
|
-
import android.media.Image
|
|
19
|
-
import android.media.ImageReader
|
|
20
|
-
import android.os.Handler
|
|
21
|
-
import android.os.HandlerThread
|
|
22
4
|
import android.util.Log
|
|
23
|
-
import
|
|
24
|
-
import
|
|
25
|
-
import
|
|
5
|
+
import androidx.camera.core.*
|
|
6
|
+
import androidx.camera.lifecycle.ProcessCameraProvider
|
|
7
|
+
import androidx.camera.view.PreviewView
|
|
26
8
|
import androidx.core.content.ContextCompat
|
|
27
|
-
import androidx.
|
|
9
|
+
import androidx.lifecycle.LifecycleOwner
|
|
28
10
|
import com.google.mlkit.vision.common.InputImage
|
|
29
11
|
import com.google.mlkit.vision.objects.ObjectDetection
|
|
30
12
|
import com.google.mlkit.vision.objects.defaults.ObjectDetectorOptions
|
|
31
|
-
import org.opencv.core.Point
|
|
32
13
|
import java.io.File
|
|
33
|
-
import java.
|
|
34
|
-
import java.
|
|
35
|
-
import java.util.concurrent.atomic.AtomicReference
|
|
36
|
-
import java.util.concurrent.atomic.AtomicBoolean
|
|
37
|
-
import kotlin.math.abs
|
|
38
|
-
import kotlin.math.max
|
|
39
|
-
import kotlin.math.min
|
|
14
|
+
import java.util.concurrent.ExecutorService
|
|
15
|
+
import java.util.concurrent.Executors
|
|
40
16
|
|
|
17
|
+
/**
|
|
18
|
+
* CameraX-based camera controller for document scanning
|
|
19
|
+
* Handles Preview, ImageAnalysis (ML Kit + OpenCV), and ImageCapture
|
|
20
|
+
*/
|
|
41
21
|
class CameraController(
|
|
42
22
|
private val context: Context,
|
|
43
|
-
private val lifecycleOwner:
|
|
44
|
-
private val previewView:
|
|
23
|
+
private val lifecycleOwner: LifecycleOwner,
|
|
24
|
+
private val previewView: PreviewView
|
|
45
25
|
) {
|
|
46
|
-
private
|
|
47
|
-
private var
|
|
48
|
-
private var
|
|
49
|
-
private var
|
|
26
|
+
private var camera: Camera? = null
|
|
27
|
+
private var cameraProvider: ProcessCameraProvider? = null
|
|
28
|
+
private var preview: Preview? = null
|
|
29
|
+
private var imageAnalyzer: ImageAnalysis? = null
|
|
30
|
+
private var imageCapture: ImageCapture? = null
|
|
50
31
|
|
|
51
|
-
private
|
|
52
|
-
private var analysisSize: Size? = null
|
|
53
|
-
private var captureSize: Size? = null
|
|
54
|
-
private var sensorOrientation: Int = 0
|
|
55
|
-
|
|
56
|
-
private var yuvReader: ImageReader? = null
|
|
57
|
-
private var jpegReader: ImageReader? = null
|
|
58
|
-
|
|
59
|
-
private val cameraThread = HandlerThread("Camera2Thread").apply { start() }
|
|
60
|
-
private val cameraHandler = Handler(cameraThread.looper)
|
|
61
|
-
private val analysisThread = HandlerThread("Camera2Analysis").apply { start() }
|
|
62
|
-
private val analysisHandler = Handler(analysisThread.looper)
|
|
32
|
+
private val cameraExecutor: ExecutorService = Executors.newSingleThreadExecutor()
|
|
63
33
|
|
|
64
34
|
private var useFrontCamera = false
|
|
65
35
|
private var detectionEnabled = true
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
private val pendingCapture = AtomicReference<PendingCapture?>()
|
|
69
|
-
private val analysisInFlight = AtomicBoolean(false)
|
|
70
|
-
private var latestTransform: Matrix? = null
|
|
71
|
-
private var latestBufferWidth = 0
|
|
72
|
-
private var latestBufferHeight = 0
|
|
73
|
-
private var latestTransformRotation = 0
|
|
36
|
+
|
|
74
37
|
private val objectDetector = ObjectDetection.getClient(
|
|
75
38
|
ObjectDetectorOptions.Builder()
|
|
76
39
|
.setDetectorMode(ObjectDetectorOptions.STREAM_MODE)
|
|
77
40
|
.enableMultipleObjects()
|
|
78
41
|
.build()
|
|
79
42
|
)
|
|
80
|
-
private var lastRectangle: Rectangle? = null
|
|
81
|
-
private var lastRectangleTimestamp = 0L
|
|
82
43
|
|
|
83
44
|
var onFrameAnalyzed: ((Rectangle?, Int, Int) -> Unit)? = null
|
|
84
45
|
|
|
46
|
+
private var pendingCapture: PendingCapture? = null
|
|
47
|
+
|
|
85
48
|
companion object {
|
|
86
49
|
private const val TAG = "CameraController"
|
|
87
|
-
private const val
|
|
50
|
+
private const val ANALYSIS_TARGET_RESOLUTION = 1280 // Max dimension for analysis
|
|
88
51
|
}
|
|
89
52
|
|
|
90
53
|
private data class PendingCapture(
|
|
@@ -93,859 +56,297 @@ class CameraController(
|
|
|
93
56
|
val onError: (Exception) -> Unit
|
|
94
57
|
)
|
|
95
58
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
openCamera()
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
override fun onSurfaceTextureSizeChanged(surface: SurfaceTexture, width: Int, height: Int) {
|
|
102
|
-
configureTransform()
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
override fun onSurfaceTextureDestroyed(surface: SurfaceTexture): Boolean {
|
|
106
|
-
return true
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
override fun onSurfaceTextureUpdated(surface: SurfaceTexture) {
|
|
110
|
-
// no-op
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
fun startCamera(
|
|
115
|
-
useFrontCam: Boolean = false,
|
|
116
|
-
enableDetection: Boolean = true
|
|
117
|
-
) {
|
|
118
|
-
Log.d(TAG, "[CAMERA2] startCamera called")
|
|
119
|
-
this.useFrontCamera = useFrontCam
|
|
120
|
-
this.detectionEnabled = enableDetection
|
|
121
|
-
|
|
122
|
-
if (!hasCameraPermission()) {
|
|
123
|
-
Log.e(TAG, "[CAMERA2] Camera permission not granted")
|
|
124
|
-
return
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Always set the listener so we get size-change callbacks for transform updates.
|
|
128
|
-
previewView.surfaceTextureListener = textureListener
|
|
129
|
-
if (previewView.isAvailable) {
|
|
130
|
-
openCamera()
|
|
131
|
-
}
|
|
132
|
-
}
|
|
59
|
+
fun startCamera(useFront: Boolean = false, enableDetection: Boolean = true) {
|
|
60
|
+
Log.d(TAG, "[CAMERAX] startCamera called: useFront=$useFront, enableDetection=$enableDetection")
|
|
133
61
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
previewView.surfaceTextureListener = null
|
|
137
|
-
closeSession()
|
|
138
|
-
}
|
|
62
|
+
useFrontCamera = useFront
|
|
63
|
+
detectionEnabled = enableDetection
|
|
139
64
|
|
|
140
|
-
|
|
141
|
-
configureTransform()
|
|
142
|
-
}
|
|
65
|
+
val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
|
|
143
66
|
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
val session = captureSession
|
|
151
|
-
val reader = jpegReader
|
|
152
|
-
if (device == null || session == null || reader == null) {
|
|
153
|
-
onError(IllegalStateException("Camera not ready for capture"))
|
|
154
|
-
return
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
if (!pendingCapture.compareAndSet(null, PendingCapture(outputDirectory, onImageCaptured, onError))) {
|
|
158
|
-
onError(IllegalStateException("Capture already in progress"))
|
|
159
|
-
return
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
try {
|
|
163
|
-
// Match JPEG orientation to current device rotation and sensor orientation.
|
|
164
|
-
val jpegOrientation = computeRotationDegrees()
|
|
165
|
-
Log.d(TAG, "[CAPTURE] Setting JPEG_ORIENTATION to $jpegOrientation")
|
|
166
|
-
|
|
167
|
-
val requestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_STILL_CAPTURE).apply {
|
|
168
|
-
addTarget(reader.surface)
|
|
169
|
-
set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
|
|
170
|
-
set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
|
|
171
|
-
if (torchEnabled) {
|
|
172
|
-
set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH)
|
|
173
|
-
}
|
|
174
|
-
set(CaptureRequest.JPEG_ORIENTATION, jpegOrientation)
|
|
67
|
+
cameraProviderFuture.addListener({
|
|
68
|
+
try {
|
|
69
|
+
cameraProvider = cameraProviderFuture.get()
|
|
70
|
+
bindCameraUseCases()
|
|
71
|
+
} catch (e: Exception) {
|
|
72
|
+
Log.e(TAG, "[CAMERAX] Failed to start camera", e)
|
|
175
73
|
}
|
|
176
|
-
|
|
177
|
-
session.capture(requestBuilder.build(), object : CameraCaptureSession.CaptureCallback() {}, cameraHandler)
|
|
178
|
-
} catch (e: Exception) {
|
|
179
|
-
pendingCapture.getAndSet(null)?.onError?.invoke(e)
|
|
180
|
-
}
|
|
74
|
+
}, ContextCompat.getMainExecutor(context))
|
|
181
75
|
}
|
|
182
76
|
|
|
183
|
-
fun
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
fun switchCamera() {
|
|
189
|
-
useFrontCamera = !useFrontCamera
|
|
190
|
-
closeSession()
|
|
191
|
-
openCamera()
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
fun isTorchAvailable(): Boolean {
|
|
195
|
-
return try {
|
|
196
|
-
val cameraId = selectCameraId() ?: return false
|
|
197
|
-
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
|
198
|
-
characteristics.get(CameraCharacteristics.FLASH_INFO_AVAILABLE) == true
|
|
199
|
-
} catch (e: Exception) {
|
|
200
|
-
false
|
|
201
|
-
}
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
fun focusAt(x: Float, y: Float) {
|
|
205
|
-
// Optional: implement touch-to-focus if needed.
|
|
206
|
-
}
|
|
207
|
-
|
|
208
|
-
fun shutdown() {
|
|
209
|
-
stopCamera()
|
|
210
|
-
objectDetector.close()
|
|
211
|
-
cameraThread.quitSafely()
|
|
212
|
-
analysisThread.quitSafely()
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
fun mapRectangleToView(rectangle: Rectangle?, imageWidth: Int, imageHeight: Int): Rectangle? {
|
|
216
|
-
val transform = latestTransform ?: return null
|
|
217
|
-
if (rectangle == null || imageWidth <= 0 || imageHeight <= 0) return null
|
|
218
|
-
if (latestBufferWidth <= 0 || latestBufferHeight <= 0) return null
|
|
219
|
-
|
|
220
|
-
val rotationDegrees = latestTransformRotation
|
|
221
|
-
val inverseRotation = (360 - rotationDegrees) % 360
|
|
222
|
-
|
|
223
|
-
fun rotatePoint(point: Point): Point {
|
|
224
|
-
return when (inverseRotation) {
|
|
225
|
-
90 -> Point(imageHeight - point.y, point.x)
|
|
226
|
-
180 -> Point(imageWidth - point.x, imageHeight - point.y)
|
|
227
|
-
270 -> Point(point.y, imageWidth - point.x)
|
|
228
|
-
else -> point
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
val rotated = Rectangle(
|
|
233
|
-
rotatePoint(rectangle.topLeft),
|
|
234
|
-
rotatePoint(rectangle.topRight),
|
|
235
|
-
rotatePoint(rectangle.bottomLeft),
|
|
236
|
-
rotatePoint(rectangle.bottomRight)
|
|
237
|
-
)
|
|
238
|
-
|
|
239
|
-
val bufferWidth = if (inverseRotation == 90 || inverseRotation == 270) {
|
|
240
|
-
imageHeight.toDouble()
|
|
241
|
-
} else {
|
|
242
|
-
imageWidth.toDouble()
|
|
243
|
-
}
|
|
244
|
-
val bufferHeight = if (inverseRotation == 90 || inverseRotation == 270) {
|
|
245
|
-
imageWidth.toDouble()
|
|
246
|
-
} else {
|
|
247
|
-
imageHeight.toDouble()
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
val scaleX = latestBufferWidth.toDouble() / bufferWidth
|
|
251
|
-
val scaleY = latestBufferHeight.toDouble() / bufferHeight
|
|
252
|
-
|
|
253
|
-
fun scalePoint(point: Point): Point {
|
|
254
|
-
return Point(point.x * scaleX, point.y * scaleY)
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
val scaled = Rectangle(
|
|
258
|
-
scalePoint(rotated.topLeft),
|
|
259
|
-
scalePoint(rotated.topRight),
|
|
260
|
-
scalePoint(rotated.bottomLeft),
|
|
261
|
-
scalePoint(rotated.bottomRight)
|
|
262
|
-
)
|
|
263
|
-
|
|
264
|
-
val pts = floatArrayOf(
|
|
265
|
-
scaled.topLeft.x.toFloat(), scaled.topLeft.y.toFloat(),
|
|
266
|
-
scaled.topRight.x.toFloat(), scaled.topRight.y.toFloat(),
|
|
267
|
-
scaled.bottomLeft.x.toFloat(), scaled.bottomLeft.y.toFloat(),
|
|
268
|
-
scaled.bottomRight.x.toFloat(), scaled.bottomRight.y.toFloat()
|
|
269
|
-
)
|
|
270
|
-
transform.mapPoints(pts)
|
|
271
|
-
|
|
272
|
-
return Rectangle(
|
|
273
|
-
Point(pts[0].toDouble(), pts[1].toDouble()),
|
|
274
|
-
Point(pts[2].toDouble(), pts[3].toDouble()),
|
|
275
|
-
Point(pts[4].toDouble(), pts[5].toDouble()),
|
|
276
|
-
Point(pts[6].toDouble(), pts[7].toDouble())
|
|
277
|
-
)
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
fun getPreviewViewport(): RectF? {
|
|
281
|
-
val transform = latestTransform ?: return null
|
|
282
|
-
if (latestBufferWidth <= 0 || latestBufferHeight <= 0) return null
|
|
283
|
-
val rotation = latestTransformRotation
|
|
284
|
-
val isSwapped = rotation == 90 || rotation == 270
|
|
285
|
-
val bufferWidth = if (isSwapped) latestBufferHeight.toFloat() else latestBufferWidth.toFloat()
|
|
286
|
-
val bufferHeight = if (isSwapped) latestBufferWidth.toFloat() else latestBufferHeight.toFloat()
|
|
287
|
-
|
|
288
|
-
val pts = floatArrayOf(
|
|
289
|
-
0f, 0f,
|
|
290
|
-
bufferWidth, 0f,
|
|
291
|
-
0f, bufferHeight,
|
|
292
|
-
bufferWidth, bufferHeight
|
|
293
|
-
)
|
|
294
|
-
transform.mapPoints(pts)
|
|
295
|
-
|
|
296
|
-
val minX = min(min(pts[0], pts[2]), min(pts[4], pts[6]))
|
|
297
|
-
val maxX = max(max(pts[0], pts[2]), max(pts[4], pts[6]))
|
|
298
|
-
val minY = min(min(pts[1], pts[3]), min(pts[5], pts[7]))
|
|
299
|
-
val maxY = max(max(pts[1], pts[3]), max(pts[5], pts[7]))
|
|
300
|
-
|
|
301
|
-
return RectF(minX, minY, maxX, maxY)
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
private fun openCamera() {
|
|
305
|
-
if (cameraDevice != null) {
|
|
77
|
+
private fun bindCameraUseCases() {
|
|
78
|
+
val cameraProvider = cameraProvider ?: run {
|
|
79
|
+
Log.e(TAG, "[CAMERAX] CameraProvider is null")
|
|
306
80
|
return
|
|
307
81
|
}
|
|
308
|
-
val cameraId = selectCameraId() ?: return
|
|
309
|
-
try {
|
|
310
|
-
val characteristics = cameraManager.getCameraCharacteristics(cameraId)
|
|
311
|
-
val streamConfigMap = characteristics.get(CameraCharacteristics.SCALER_STREAM_CONFIGURATION_MAP)
|
|
312
|
-
?: return
|
|
313
|
-
sensorOrientation = characteristics.get(CameraCharacteristics.SENSOR_ORIENTATION) ?: 0
|
|
314
|
-
|
|
315
|
-
// Calculate view aspect ratio considering sensor orientation
|
|
316
|
-
// For portrait mode with 90/270 degree sensor, we need to swap width/height
|
|
317
|
-
val displayRotation = displayRotationDegrees()
|
|
318
|
-
val totalRotation = if (useFrontCamera) {
|
|
319
|
-
(sensorOrientation + displayRotation) % 360
|
|
320
|
-
} else {
|
|
321
|
-
(sensorOrientation - displayRotation + 360) % 360
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
val viewWidth = previewView.width.takeIf { it > 0 } ?: 1200
|
|
325
|
-
val viewHeight = previewView.height.takeIf { it > 0 } ?: 1928
|
|
326
|
-
|
|
327
|
-
// If total rotation is 90 or 270, the sensor output is rotated, so we need to match against swapped aspect
|
|
328
|
-
val viewAspect = if (totalRotation == 90 || totalRotation == 270) {
|
|
329
|
-
// Sensor outputs landscape (e.g., 1920x1080), but we display portrait
|
|
330
|
-
// So we want to find sensor size with aspect ~= viewHeight/viewWidth
|
|
331
|
-
viewHeight.toDouble() / viewWidth.toDouble()
|
|
332
|
-
} else {
|
|
333
|
-
viewWidth.toDouble() / viewHeight.toDouble()
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
Log.d(TAG, "[CAMERA2] sensorOrientation=$sensorOrientation displayRotation=$displayRotation totalRotation=$totalRotation")
|
|
337
|
-
Log.d(TAG, "[CAMERA2] viewAspect=$viewAspect (view: ${viewWidth}x${viewHeight})")
|
|
338
|
-
|
|
339
|
-
val previewSizes = streamConfigMap.getOutputSizes(SurfaceTexture::class.java)
|
|
340
|
-
Log.d(TAG, "[CAMERA2] Available preview sizes: ${previewSizes?.take(10)?.joinToString { "${it.width}x${it.height}" }}")
|
|
341
|
-
|
|
342
|
-
// Prefer 4:3 so height-based scaling fills the screen without stretching.
|
|
343
|
-
val targetPreviewAspect = 4.0 / 3.0
|
|
344
|
-
val minPreviewArea = 960 * 720
|
|
345
|
-
previewSize = chooseBestSize(previewSizes, targetPreviewAspect, null, minPreviewArea, preferClosestAspect = true)
|
|
346
|
-
?: chooseBestSize(previewSizes, viewAspect, null, preferClosestAspect = true)
|
|
347
|
-
?: previewSizes?.maxByOrNull { it.width * it.height }
|
|
348
|
-
Log.d(TAG, "[CAMERA2] Selected preview size: ${previewSize?.width}x${previewSize?.height}")
|
|
349
|
-
|
|
350
|
-
val previewAspect = previewSize?.let { it.width.toDouble() / it.height.toDouble() } ?: viewAspect
|
|
351
|
-
val analysisSizes = streamConfigMap.getOutputSizes(ImageFormat.YUV_420_888)
|
|
352
|
-
analysisSize = chooseBestSize(analysisSizes, previewAspect, null, preferClosestAspect = true)
|
|
353
|
-
|
|
354
|
-
val captureSizes = streamConfigMap.getOutputSizes(ImageFormat.JPEG)
|
|
355
|
-
captureSize = chooseBestSize(captureSizes, previewAspect, null, preferClosestAspect = true)
|
|
356
|
-
?: captureSizes?.maxByOrNull { it.width * it.height }
|
|
357
|
-
|
|
358
|
-
val viewAspectNormalized = max(viewAspect, 1.0 / viewAspect)
|
|
359
|
-
val previewAspectNormalized = max(previewAspect, 1.0 / previewAspect)
|
|
360
|
-
val previewDiff = abs(previewAspectNormalized - viewAspectNormalized)
|
|
361
|
-
Log.d(
|
|
362
|
-
TAG,
|
|
363
|
-
"[SIZE_SELECTION] targetAspect=$viewAspectNormalized viewAspect=$viewAspectNormalized " +
|
|
364
|
-
"previewAspect=$previewAspectNormalized diff=$previewDiff selected=${previewSize?.width}x${previewSize?.height}"
|
|
365
|
-
)
|
|
366
|
-
|
|
367
|
-
setupImageReaders()
|
|
368
|
-
Log.d(
|
|
369
|
-
TAG,
|
|
370
|
-
"[CAMERA2] view=${previewView.width}x${previewView.height} " +
|
|
371
|
-
"preview=${previewSize?.width}x${previewSize?.height} " +
|
|
372
|
-
"analysis=${analysisSize?.width}x${analysisSize?.height} " +
|
|
373
|
-
"capture=${captureSize?.width}x${captureSize?.height}"
|
|
374
|
-
)
|
|
375
|
-
|
|
376
|
-
if (ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) != PackageManager.PERMISSION_GRANTED) {
|
|
377
|
-
Log.e(TAG, "[CAMERA2] Camera permission not granted")
|
|
378
|
-
return
|
|
379
|
-
}
|
|
380
82
|
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
override fun onDisconnected(camera: CameraDevice) {
|
|
388
|
-
camera.close()
|
|
389
|
-
cameraDevice = null
|
|
390
|
-
}
|
|
391
|
-
|
|
392
|
-
override fun onError(camera: CameraDevice, error: Int) {
|
|
393
|
-
Log.e(TAG, "[CAMERA2] CameraDevice error: $error")
|
|
394
|
-
camera.close()
|
|
395
|
-
cameraDevice = null
|
|
396
|
-
}
|
|
397
|
-
}, cameraHandler)
|
|
398
|
-
} catch (e: Exception) {
|
|
399
|
-
Log.e(TAG, "[CAMERA2] Failed to open camera", e)
|
|
83
|
+
// Select camera
|
|
84
|
+
val cameraSelector = if (useFrontCamera) {
|
|
85
|
+
CameraSelector.DEFAULT_FRONT_CAMERA
|
|
86
|
+
} else {
|
|
87
|
+
CameraSelector.DEFAULT_BACK_CAMERA
|
|
400
88
|
}
|
|
401
|
-
}
|
|
402
89
|
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
yuvReader = ImageReader.newInstance(analysis.width, analysis.height, ImageFormat.YUV_420_888, 2).apply {
|
|
412
|
-
setOnImageAvailableListener({ reader ->
|
|
413
|
-
if (!detectionEnabled || onFrameAnalyzed == null) {
|
|
414
|
-
try {
|
|
415
|
-
reader.acquireLatestImage()?.close()
|
|
416
|
-
} catch (e: Exception) {
|
|
417
|
-
Log.w(TAG, "[CAMERA2] Failed to drain analysis image", e)
|
|
418
|
-
}
|
|
419
|
-
return@setOnImageAvailableListener
|
|
420
|
-
}
|
|
421
|
-
if (!analysisInFlight.compareAndSet(false, true)) {
|
|
422
|
-
try {
|
|
423
|
-
reader.acquireLatestImage()?.close()
|
|
424
|
-
} catch (e: Exception) {
|
|
425
|
-
Log.w(TAG, "[CAMERA2] Failed to drop analysis image", e)
|
|
426
|
-
}
|
|
427
|
-
return@setOnImageAvailableListener
|
|
428
|
-
}
|
|
429
|
-
val image = try {
|
|
430
|
-
reader.acquireLatestImage()
|
|
431
|
-
} catch (e: Exception) {
|
|
432
|
-
analysisInFlight.set(false)
|
|
433
|
-
Log.w(TAG, "[CAMERA2] acquireLatestImage failed", e)
|
|
434
|
-
null
|
|
435
|
-
}
|
|
436
|
-
if (image == null) {
|
|
437
|
-
analysisInFlight.set(false)
|
|
438
|
-
return@setOnImageAvailableListener
|
|
439
|
-
}
|
|
440
|
-
analysisHandler.post { analyzeImage(image) }
|
|
441
|
-
}, cameraHandler)
|
|
90
|
+
// Preview UseCase
|
|
91
|
+
Log.d(TAG, "[CAMERAX] PreviewView size: ${previewView.width}x${previewView.height}")
|
|
92
|
+
Log.d(TAG, "[CAMERAX] PreviewView visibility: ${previewView.visibility}")
|
|
93
|
+
preview = Preview.Builder()
|
|
94
|
+
.build()
|
|
95
|
+
.also {
|
|
96
|
+
it.setSurfaceProvider(previewView.surfaceProvider)
|
|
97
|
+
Log.d(TAG, "[CAMERAX] SurfaceProvider set successfully")
|
|
442
98
|
}
|
|
443
|
-
}
|
|
444
99
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
100
|
+
// ImageAnalysis UseCase for document detection
|
|
101
|
+
imageAnalyzer = ImageAnalysis.Builder()
|
|
102
|
+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
103
|
+
.build()
|
|
104
|
+
.also {
|
|
105
|
+
it.setAnalyzer(cameraExecutor) { imageProxy ->
|
|
106
|
+
if (detectionEnabled) {
|
|
107
|
+
analyzeImage(imageProxy)
|
|
108
|
+
} else {
|
|
109
|
+
imageProxy.close()
|
|
453
110
|
}
|
|
454
|
-
|
|
455
|
-
}, cameraHandler)
|
|
111
|
+
}
|
|
456
112
|
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
private fun createCaptureSession() {
|
|
461
|
-
val device = cameraDevice ?: return
|
|
462
|
-
val surfaceTexture = previewView.surfaceTexture ?: return
|
|
463
|
-
val preview = previewSize ?: return
|
|
464
113
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
val targets = mutableListOf(previewSurface)
|
|
470
|
-
yuvReader?.surface?.let { targets.add(it) }
|
|
471
|
-
jpegReader?.surface?.let { targets.add(it) }
|
|
114
|
+
// ImageCapture UseCase
|
|
115
|
+
imageCapture = ImageCapture.Builder()
|
|
116
|
+
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
|
117
|
+
.build()
|
|
472
118
|
|
|
473
119
|
try {
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
} catch (e: Exception) {
|
|
486
|
-
Log.e(TAG, "[CAMERA2] Failed to create capture session", e)
|
|
487
|
-
}
|
|
488
|
-
}
|
|
120
|
+
// Unbind all use cases before rebinding
|
|
121
|
+
cameraProvider.unbindAll()
|
|
122
|
+
|
|
123
|
+
// Bind use cases to camera
|
|
124
|
+
camera = cameraProvider.bindToLifecycle(
|
|
125
|
+
lifecycleOwner,
|
|
126
|
+
cameraSelector,
|
|
127
|
+
preview,
|
|
128
|
+
imageAnalyzer,
|
|
129
|
+
imageCapture
|
|
130
|
+
)
|
|
489
131
|
|
|
490
|
-
|
|
491
|
-
val device = cameraDevice ?: return
|
|
492
|
-
try {
|
|
493
|
-
previewRequestBuilder = device.createCaptureRequest(CameraDevice.TEMPLATE_PREVIEW).apply {
|
|
494
|
-
addTarget(previewSurface)
|
|
495
|
-
yuvReader?.surface?.let { addTarget(it) }
|
|
496
|
-
set(CaptureRequest.CONTROL_AF_MODE, CaptureRequest.CONTROL_AF_MODE_CONTINUOUS_PICTURE)
|
|
497
|
-
set(CaptureRequest.CONTROL_AE_MODE, CaptureRequest.CONTROL_AE_MODE_ON)
|
|
498
|
-
if (torchEnabled) {
|
|
499
|
-
set(CaptureRequest.FLASH_MODE, CaptureRequest.FLASH_MODE_TORCH)
|
|
500
|
-
}
|
|
501
|
-
}
|
|
502
|
-
captureSession?.setRepeatingRequest(previewRequestBuilder?.build() ?: return, null, cameraHandler)
|
|
503
|
-
} catch (e: Exception) {
|
|
504
|
-
Log.e(TAG, "[CAMERA2] Failed to start repeating request", e)
|
|
505
|
-
}
|
|
506
|
-
}
|
|
132
|
+
Log.d(TAG, "[CAMERAX] Camera bound successfully")
|
|
507
133
|
|
|
508
|
-
private fun updateRepeatingRequest() {
|
|
509
|
-
val builder = previewRequestBuilder ?: return
|
|
510
|
-
builder.set(CaptureRequest.FLASH_MODE, if (torchEnabled) CaptureRequest.FLASH_MODE_TORCH else CaptureRequest.FLASH_MODE_OFF)
|
|
511
|
-
try {
|
|
512
|
-
captureSession?.setRepeatingRequest(builder.build(), null, cameraHandler)
|
|
513
134
|
} catch (e: Exception) {
|
|
514
|
-
Log.e(TAG, "[
|
|
135
|
+
Log.e(TAG, "[CAMERAX] Use case binding failed", e)
|
|
515
136
|
}
|
|
516
137
|
}
|
|
517
138
|
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
val
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
imageToNv21(image)
|
|
524
|
-
} catch (e: Exception) {
|
|
525
|
-
Log.e(TAG, "[CAMERA2] Failed to read image buffer", e)
|
|
526
|
-
try {
|
|
527
|
-
image.close()
|
|
528
|
-
} catch (closeError: Exception) {
|
|
529
|
-
Log.w(TAG, "[CAMERA2] Failed to close image", closeError)
|
|
530
|
-
}
|
|
531
|
-
analysisInFlight.set(false)
|
|
139
|
+
@androidx.annotation.OptIn(ExperimentalGetImage::class)
|
|
140
|
+
private fun analyzeImage(imageProxy: ImageProxy) {
|
|
141
|
+
val mediaImage = imageProxy.image
|
|
142
|
+
if (mediaImage == null) {
|
|
143
|
+
imageProxy.close()
|
|
532
144
|
return
|
|
533
|
-
} finally {
|
|
534
|
-
try {
|
|
535
|
-
image.close()
|
|
536
|
-
} catch (e: Exception) {
|
|
537
|
-
Log.w(TAG, "[CAMERA2] Failed to close image", e)
|
|
538
|
-
}
|
|
539
145
|
}
|
|
540
146
|
|
|
541
|
-
val
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
InputImage.IMAGE_FORMAT_NV21
|
|
548
|
-
)
|
|
549
|
-
} catch (e: Exception) {
|
|
550
|
-
Log.e(TAG, "[CAMERA2] Failed to create InputImage", e)
|
|
551
|
-
analysisInFlight.set(false)
|
|
552
|
-
return
|
|
553
|
-
}
|
|
147
|
+
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
|
148
|
+
val imageWidth = imageProxy.width
|
|
149
|
+
val imageHeight = imageProxy.height
|
|
150
|
+
|
|
151
|
+
// Try ML Kit first
|
|
152
|
+
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
|
|
554
153
|
|
|
555
154
|
objectDetector.process(inputImage)
|
|
556
155
|
.addOnSuccessListener { objects ->
|
|
156
|
+
if (objects.isEmpty()) {
|
|
157
|
+
// No objects detected, fallback to OpenCV
|
|
158
|
+
fallbackToOpenCV(imageProxy, rotationDegrees)
|
|
159
|
+
return@addOnSuccessListener
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Find largest object
|
|
557
163
|
val best = objects.maxByOrNull { obj ->
|
|
558
164
|
val box = obj.boundingBox
|
|
559
165
|
box.width() * box.height()
|
|
560
166
|
}
|
|
561
167
|
val mlBox = best?.boundingBox
|
|
562
|
-
val rectangle = refineWithOpenCv(nv21, imageWidth, imageHeight, rotationDegrees, mlBox)
|
|
563
168
|
|
|
564
|
-
|
|
565
|
-
val
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
169
|
+
// Refine with OpenCV
|
|
170
|
+
val nv21 = imageProxyToNV21(imageProxy)
|
|
171
|
+
val rectangle = if (nv21 != null) {
|
|
172
|
+
try {
|
|
173
|
+
refineWithOpenCv(nv21, imageWidth, imageHeight, rotationDegrees, mlBox)
|
|
174
|
+
} catch (e: Exception) {
|
|
175
|
+
Log.w(TAG, "[CAMERAX] OpenCV refinement failed", e)
|
|
176
|
+
null
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
574
179
|
null
|
|
575
180
|
}
|
|
181
|
+
|
|
576
182
|
val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth
|
|
577
183
|
val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight
|
|
578
|
-
onFrameAnalyzed?.invoke(smoothRectangle(rectangle), frameWidth, frameHeight)
|
|
579
|
-
}
|
|
580
|
-
.addOnCompleteListener {
|
|
581
|
-
analysisInFlight.set(false)
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
184
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
val buffer = image.planes[0].buffer
|
|
588
|
-
val bytes = ByteArray(buffer.remaining())
|
|
589
|
-
buffer.get(bytes)
|
|
590
|
-
val exifRotation = readExifRotation(bytes)
|
|
591
|
-
val bitmap = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
|
|
592
|
-
?: throw IllegalStateException("Failed to decode JPEG")
|
|
593
|
-
|
|
594
|
-
val rotation = if (exifRotation != 0) exifRotation else computeRotationDegrees()
|
|
595
|
-
val shouldRotate = if (rotation == 90 || rotation == 270) {
|
|
596
|
-
bitmap.width > bitmap.height
|
|
597
|
-
} else {
|
|
598
|
-
bitmap.height > bitmap.width
|
|
599
|
-
}
|
|
600
|
-
val appliedRotation = if (shouldRotate) rotation else 0
|
|
601
|
-
val rotated = rotateAndMirror(bitmap, appliedRotation, useFrontCamera)
|
|
602
|
-
val photoFile = File(pending.outputDirectory, "doc_scan_${System.currentTimeMillis()}.jpg")
|
|
603
|
-
FileOutputStream(photoFile).use { out ->
|
|
604
|
-
rotated.compress(Bitmap.CompressFormat.JPEG, 95, out)
|
|
185
|
+
onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
|
|
186
|
+
imageProxy.close()
|
|
605
187
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
188
|
+
.addOnFailureListener { e ->
|
|
189
|
+
Log.w(TAG, "[CAMERAX] ML Kit detection failed, using OpenCV", e)
|
|
190
|
+
fallbackToOpenCV(imageProxy, rotationDegrees)
|
|
609
191
|
}
|
|
610
|
-
bitmap.recycle()
|
|
611
|
-
|
|
612
|
-
pending.onImageCaptured(photoFile)
|
|
613
|
-
} catch (e: Exception) {
|
|
614
|
-
pending.onError(e)
|
|
615
|
-
} finally {
|
|
616
|
-
image.close()
|
|
617
|
-
}
|
|
618
192
|
}
|
|
619
193
|
|
|
620
|
-
private fun
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
previewRequestBuilder = null
|
|
634
|
-
}
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
private fun computeRotationDegrees(): Int {
|
|
638
|
-
val displayRotation = displayRotationDegrees()
|
|
639
|
-
val rotation = if (useFrontCamera) {
|
|
640
|
-
(sensorOrientation + displayRotation) % 360
|
|
641
|
-
} else {
|
|
642
|
-
(sensorOrientation - displayRotation + 360) % 360
|
|
643
|
-
}
|
|
644
|
-
Log.d(TAG, "[ROTATION] sensor=$sensorOrientation display=$displayRotation front=$useFrontCamera -> rotation=$rotation")
|
|
645
|
-
return rotation
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
private fun displayRotationDegrees(): Int {
|
|
649
|
-
val rotation = previewView.display?.rotation ?: Surface.ROTATION_0
|
|
650
|
-
return when (rotation) {
|
|
651
|
-
Surface.ROTATION_0 -> 0
|
|
652
|
-
Surface.ROTATION_90 -> 90
|
|
653
|
-
Surface.ROTATION_180 -> 180
|
|
654
|
-
Surface.ROTATION_270 -> 270
|
|
655
|
-
else -> 0
|
|
656
|
-
}
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
private fun configureTransform() {
|
|
660
|
-
val viewWidth = previewView.width.toFloat()
|
|
661
|
-
val viewHeight = previewView.height.toFloat()
|
|
662
|
-
val preview = previewSize ?: return
|
|
663
|
-
if (viewWidth == 0f || viewHeight == 0f) return
|
|
664
|
-
|
|
665
|
-
val rotationDegrees = computeRotationDegrees()
|
|
666
|
-
val displayRotation = displayRotationDegrees()
|
|
667
|
-
|
|
668
|
-
val viewRect = RectF(0f, 0f, viewWidth, viewHeight)
|
|
669
|
-
val centerX = viewRect.centerX()
|
|
670
|
-
val centerY = viewRect.centerY()
|
|
671
|
-
|
|
672
|
-
// Portrait-only mode: swap buffer dimensions based on sensor orientation
|
|
673
|
-
// sensorOrientation=90 means camera is rotated 90° from device natural orientation
|
|
674
|
-
val swap = sensorOrientation == 90 || sensorOrientation == 270
|
|
675
|
-
val bufferWidth = if (swap) preview.height.toFloat() else preview.width.toFloat()
|
|
676
|
-
val bufferHeight = if (swap) preview.width.toFloat() else preview.height.toFloat()
|
|
677
|
-
val bufferRect = RectF(0f, 0f, bufferWidth, bufferHeight)
|
|
678
|
-
bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY())
|
|
679
|
-
|
|
680
|
-
val matrix = Matrix()
|
|
681
|
-
matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL)
|
|
682
|
-
val scale = max(viewWidth / bufferRect.width(), viewHeight / bufferRect.height())
|
|
683
|
-
matrix.postScale(scale, scale, centerX, centerY)
|
|
684
|
-
// Portrait-only: no additional rotation needed, sensor orientation is already handled by buffer swap
|
|
685
|
-
|
|
686
|
-
val pts = floatArrayOf(
|
|
687
|
-
0f, 0f,
|
|
688
|
-
bufferWidth, 0f,
|
|
689
|
-
0f, bufferHeight,
|
|
690
|
-
bufferWidth, bufferHeight
|
|
691
|
-
)
|
|
692
|
-
matrix.mapPoints(pts)
|
|
693
|
-
|
|
694
|
-
Log.d(
|
|
695
|
-
TAG,
|
|
696
|
-
"[TRANSFORM] sensor=$sensorOrientation display=$displayRotation swap=$swap " +
|
|
697
|
-
"view=${viewWidth}x${viewHeight} preview=${preview.width}x${preview.height} buffer=${bufferWidth}x${bufferHeight}"
|
|
698
|
-
)
|
|
699
|
-
|
|
700
|
-
previewView.setTransform(matrix)
|
|
701
|
-
latestTransform = Matrix(matrix)
|
|
702
|
-
latestBufferWidth = preview.width
|
|
703
|
-
latestBufferHeight = preview.height
|
|
704
|
-
latestTransformRotation = 0 // Portrait-only mode, no rotation applied
|
|
705
|
-
|
|
706
|
-
Log.d(
|
|
707
|
-
TAG,
|
|
708
|
-
"[TRANSFORM] scale=$scale matrix=$matrix"
|
|
709
|
-
)
|
|
710
|
-
Log.d(TAG, "[TRANSFORM] Matrix applied successfully")
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
private fun chooseBestSize(
|
|
714
|
-
sizes: Array<Size>?,
|
|
715
|
-
targetAspect: Double,
|
|
716
|
-
maxArea: Int?,
|
|
717
|
-
minArea: Int? = null,
|
|
718
|
-
preferClosestAspect: Boolean = false
|
|
719
|
-
): Size? {
|
|
720
|
-
if (sizes == null || sizes.isEmpty()) return null
|
|
721
|
-
val sorted = sizes.sortedByDescending { it.width * it.height }
|
|
722
|
-
|
|
723
|
-
val capped = if (maxArea != null) {
|
|
724
|
-
sorted.filter { it.width * it.height <= maxArea }
|
|
725
|
-
} else {
|
|
726
|
-
sorted
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
if (capped.isEmpty()) {
|
|
730
|
-
return sorted.first()
|
|
731
|
-
}
|
|
732
|
-
|
|
733
|
-
val minCapped = if (minArea != null) {
|
|
734
|
-
capped.filter { it.width * it.height >= minArea }
|
|
735
|
-
} else {
|
|
736
|
-
capped
|
|
737
|
-
}
|
|
738
|
-
|
|
739
|
-
val poolForSelection = if (minCapped.isNotEmpty()) minCapped else capped
|
|
740
|
-
|
|
741
|
-
fun aspectDiff(size: Size): Double {
|
|
742
|
-
val w = size.width.toDouble()
|
|
743
|
-
val h = size.height.toDouble()
|
|
744
|
-
val aspect = max(w, h) / min(w, h)
|
|
745
|
-
val target = max(targetAspect, 1.0 / targetAspect)
|
|
746
|
-
return abs(aspect - target)
|
|
747
|
-
}
|
|
748
|
-
|
|
749
|
-
if (preferClosestAspect) {
|
|
750
|
-
// Prefer aspect ratio match first, then pick the highest resolution among matches.
|
|
751
|
-
poolForSelection.forEach { size ->
|
|
752
|
-
val diff = aspectDiff(size)
|
|
753
|
-
Log.d(TAG, "[SIZE_SELECTION] ${size.width}x${size.height} aspect=${size.width.toDouble()/size.height} diff=$diff")
|
|
194
|
+
private fun fallbackToOpenCV(imageProxy: ImageProxy, rotationDegrees: Int) {
|
|
195
|
+
val nv21 = imageProxyToNV21(imageProxy)
|
|
196
|
+
val rectangle = if (nv21 != null) {
|
|
197
|
+
try {
|
|
198
|
+
DocumentDetector.detectRectangleInYUV(
|
|
199
|
+
nv21,
|
|
200
|
+
imageProxy.width,
|
|
201
|
+
imageProxy.height,
|
|
202
|
+
rotationDegrees
|
|
203
|
+
)
|
|
204
|
+
} catch (e: Exception) {
|
|
205
|
+
Log.w(TAG, "[CAMERAX] OpenCV fallback failed", e)
|
|
206
|
+
null
|
|
754
207
|
}
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
val close = poolForSelection.filter { aspectDiff(it) <= bestDiff + 0.001 }
|
|
758
|
-
val selected = close.maxByOrNull { it.width * it.height } ?: poolForSelection.maxByOrNull { it.width * it.height }
|
|
759
|
-
Log.d(TAG, "[SIZE_SELECTION] Best aspect diff: $bestDiff, candidates: ${close.size}, selected: ${selected?.width}x${selected?.height}")
|
|
760
|
-
return selected
|
|
208
|
+
} else {
|
|
209
|
+
null
|
|
761
210
|
}
|
|
762
211
|
|
|
763
|
-
val
|
|
212
|
+
val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.height else imageProxy.width
|
|
213
|
+
val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.width else imageProxy.height
|
|
764
214
|
|
|
765
|
-
|
|
215
|
+
onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
|
|
216
|
+
imageProxy.close()
|
|
766
217
|
}
|
|
767
218
|
|
|
768
|
-
private fun
|
|
769
|
-
|
|
219
|
+
private fun imageProxyToNV21(imageProxy: ImageProxy): ByteArray? {
|
|
220
|
+
return try {
|
|
221
|
+
val yBuffer = imageProxy.planes[0].buffer
|
|
222
|
+
val uBuffer = imageProxy.planes[1].buffer
|
|
223
|
+
val vBuffer = imageProxy.planes[2].buffer
|
|
770
224
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
225
|
+
val ySize = yBuffer.remaining()
|
|
226
|
+
val uSize = uBuffer.remaining()
|
|
227
|
+
val vSize = vBuffer.remaining()
|
|
775
228
|
|
|
776
|
-
|
|
777
|
-
if (rotationDegrees != 0) {
|
|
778
|
-
matrix.postRotate(rotationDegrees.toFloat(), bitmap.width / 2f, bitmap.height / 2f)
|
|
779
|
-
}
|
|
780
|
-
if (mirror) {
|
|
781
|
-
matrix.postScale(-1f, 1f, bitmap.width / 2f, bitmap.height / 2f)
|
|
782
|
-
}
|
|
229
|
+
val nv21 = ByteArray(ySize + uSize + vSize)
|
|
783
230
|
|
|
784
|
-
|
|
785
|
-
|
|
231
|
+
yBuffer.get(nv21, 0, ySize)
|
|
232
|
+
vBuffer.get(nv21, ySize, vSize)
|
|
233
|
+
uBuffer.get(nv21, ySize + vSize, uSize)
|
|
786
234
|
|
|
787
|
-
|
|
788
|
-
return try {
|
|
789
|
-
val exif = ExifInterface(ByteArrayInputStream(bytes))
|
|
790
|
-
when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
|
791
|
-
ExifInterface.ORIENTATION_ROTATE_90 -> 90
|
|
792
|
-
ExifInterface.ORIENTATION_ROTATE_180 -> 180
|
|
793
|
-
ExifInterface.ORIENTATION_ROTATE_270 -> 270
|
|
794
|
-
else -> 0
|
|
795
|
-
}
|
|
235
|
+
nv21
|
|
796
236
|
} catch (e: Exception) {
|
|
797
|
-
Log.
|
|
798
|
-
|
|
237
|
+
Log.e(TAG, "[CAMERAX] Failed to convert ImageProxy to NV21", e)
|
|
238
|
+
null
|
|
799
239
|
}
|
|
800
240
|
}
|
|
801
241
|
|
|
802
242
|
private fun refineWithOpenCv(
|
|
803
243
|
nv21: ByteArray,
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
mlBox: Rect?
|
|
244
|
+
width: Int,
|
|
245
|
+
height: Int,
|
|
246
|
+
rotation: Int,
|
|
247
|
+
mlBox: android.graphics.Rect?
|
|
808
248
|
): Rectangle? {
|
|
809
249
|
return try {
|
|
810
|
-
|
|
811
|
-
val uprightHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight
|
|
812
|
-
val openCvRect = if (mlBox != null) {
|
|
813
|
-
val expanded = expandRect(mlBox, uprightWidth, uprightHeight, 0.25f)
|
|
814
|
-
DocumentDetector.detectRectangleInYUVWithRoi(
|
|
815
|
-
nv21,
|
|
816
|
-
imageWidth,
|
|
817
|
-
imageHeight,
|
|
818
|
-
rotationDegrees,
|
|
819
|
-
expanded
|
|
820
|
-
)
|
|
821
|
-
} else {
|
|
822
|
-
DocumentDetector.detectRectangleInYUV(nv21, imageWidth, imageHeight, rotationDegrees)
|
|
823
|
-
}
|
|
824
|
-
if (openCvRect == null) {
|
|
825
|
-
mlBox?.let { boxToRectangle(insetBox(it, 0.9f)) }
|
|
826
|
-
} else {
|
|
827
|
-
openCvRect
|
|
828
|
-
}
|
|
250
|
+
DocumentDetector.detectRectangleInYUV(nv21, width, height, rotation)
|
|
829
251
|
} catch (e: Exception) {
|
|
830
|
-
Log.w(TAG, "[
|
|
252
|
+
Log.w(TAG, "[CAMERAX] OpenCV detection failed", e)
|
|
831
253
|
null
|
|
832
254
|
}
|
|
833
255
|
}
|
|
834
256
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
257
|
+
fun capturePhoto(
|
|
258
|
+
outputDirectory: File,
|
|
259
|
+
onImageCaptured: (File) -> Unit,
|
|
260
|
+
onError: (Exception) -> Unit
|
|
261
|
+
) {
|
|
262
|
+
val imageCapture = imageCapture ?: run {
|
|
263
|
+
onError(IllegalStateException("ImageCapture not initialized"))
|
|
264
|
+
return
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
pendingCapture = PendingCapture(outputDirectory, onImageCaptured, onError)
|
|
268
|
+
|
|
269
|
+
val photoFile = File(outputDirectory, "doc_scan_${System.currentTimeMillis()}.jpg")
|
|
270
|
+
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
|
271
|
+
|
|
272
|
+
imageCapture.takePicture(
|
|
273
|
+
outputOptions,
|
|
274
|
+
ContextCompat.getMainExecutor(context),
|
|
275
|
+
object : ImageCapture.OnImageSavedCallback {
|
|
276
|
+
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
|
277
|
+
Log.d(TAG, "[CAMERAX] Image saved: ${photoFile.absolutePath}")
|
|
278
|
+
onImageCaptured(photoFile)
|
|
279
|
+
pendingCapture = null
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
override fun onError(exception: ImageCaptureException) {
|
|
283
|
+
Log.e(TAG, "[CAMERAX] Image capture failed", exception)
|
|
284
|
+
onError(exception)
|
|
285
|
+
pendingCapture = null
|
|
286
|
+
}
|
|
287
|
+
}
|
|
841
288
|
)
|
|
842
289
|
}
|
|
843
290
|
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
val padY = (box.height() * ratio).toInt()
|
|
847
|
-
val left = (box.left - padX).coerceAtLeast(0)
|
|
848
|
-
val top = (box.top - padY).coerceAtLeast(0)
|
|
849
|
-
val right = (box.right + padX).coerceAtMost(maxWidth)
|
|
850
|
-
val bottom = (box.bottom + padY).coerceAtMost(maxHeight)
|
|
851
|
-
return Rect(left, top, right, bottom)
|
|
291
|
+
fun setTorchEnabled(enabled: Boolean) {
|
|
292
|
+
camera?.cameraControl?.enableTorch(enabled)
|
|
852
293
|
}
|
|
853
294
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
return Rect(
|
|
859
|
-
box.left + insetX,
|
|
860
|
-
box.top + insetY,
|
|
861
|
-
box.right - insetX,
|
|
862
|
-
box.bottom - insetY
|
|
863
|
-
)
|
|
295
|
+
fun stopCamera() {
|
|
296
|
+
Log.d(TAG, "[CAMERAX] stopCamera called")
|
|
297
|
+
cameraProvider?.unbindAll()
|
|
298
|
+
camera = null
|
|
864
299
|
}
|
|
865
300
|
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
if (last != null && now - lastRectangleTimestamp < 150) {
|
|
871
|
-
return last
|
|
872
|
-
}
|
|
873
|
-
lastRectangle = null
|
|
874
|
-
return null
|
|
875
|
-
}
|
|
876
|
-
|
|
877
|
-
lastRectangle = current
|
|
878
|
-
lastRectangleTimestamp = now
|
|
879
|
-
return current
|
|
301
|
+
fun shutdown() {
|
|
302
|
+
stopCamera()
|
|
303
|
+
objectDetector.close()
|
|
304
|
+
cameraExecutor.shutdown()
|
|
880
305
|
}
|
|
881
306
|
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
val bottom = listOf(rectangle.topLeft.y, rectangle.bottomLeft.y, rectangle.topRight.y, rectangle.bottomRight.y).maxOrNull() ?: 0.0
|
|
887
|
-
return Rect(left.toInt(), top.toInt(), right.toInt(), bottom.toInt())
|
|
307
|
+
fun refreshTransform() {
|
|
308
|
+
// CameraX handles transform automatically via PreviewView
|
|
309
|
+
// No manual matrix calculation needed!
|
|
310
|
+
Log.d(TAG, "[CAMERAX] Transform refresh requested - handled automatically by PreviewView")
|
|
888
311
|
}
|
|
889
312
|
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
val ySize = width * height
|
|
894
|
-
val uvSize = width * height / 2
|
|
895
|
-
val nv21 = ByteArray(ySize + uvSize)
|
|
896
|
-
|
|
897
|
-
val yBuffer = image.planes[0].buffer
|
|
898
|
-
val uBuffer = image.planes[1].buffer
|
|
899
|
-
val vBuffer = image.planes[2].buffer
|
|
900
|
-
|
|
901
|
-
val yRowStride = image.planes[0].rowStride
|
|
902
|
-
val yPixelStride = image.planes[0].pixelStride
|
|
903
|
-
var outputOffset = 0
|
|
904
|
-
for (row in 0 until height) {
|
|
905
|
-
var inputOffset = row * yRowStride
|
|
906
|
-
for (col in 0 until width) {
|
|
907
|
-
nv21[outputOffset++] = yBuffer.get(inputOffset)
|
|
908
|
-
inputOffset += yPixelStride
|
|
909
|
-
}
|
|
910
|
-
}
|
|
313
|
+
// Simplified coordinate mapping - PreviewView handles most of the work
|
|
314
|
+
fun mapRectangleToView(rectangle: Rectangle?, imageWidth: Int, imageHeight: Int): Rectangle? {
|
|
315
|
+
if (rectangle == null || imageWidth <= 0 || imageHeight <= 0) return null
|
|
911
316
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
val
|
|
915
|
-
val
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
317
|
+
// CameraX PreviewView with FILL_CENTER handles scaling and centering
|
|
318
|
+
// We just need to scale the coordinates proportionally
|
|
319
|
+
val viewWidth = previewView.width.toFloat()
|
|
320
|
+
val viewHeight = previewView.height.toFloat()
|
|
321
|
+
|
|
322
|
+
if (viewWidth <= 0 || viewHeight <= 0) return null
|
|
323
|
+
|
|
324
|
+
// Simple proportional scaling
|
|
325
|
+
val scaleX = viewWidth / imageWidth.toFloat()
|
|
326
|
+
val scaleY = viewHeight / imageHeight.toFloat()
|
|
327
|
+
|
|
328
|
+
fun scalePoint(point: org.opencv.core.Point): org.opencv.core.Point {
|
|
329
|
+
return org.opencv.core.Point(
|
|
330
|
+
point.x * scaleX,
|
|
331
|
+
point.y * scaleY
|
|
332
|
+
)
|
|
927
333
|
}
|
|
928
334
|
|
|
929
|
-
return
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
335
|
+
return Rectangle(
|
|
336
|
+
scalePoint(rectangle.topLeft),
|
|
337
|
+
scalePoint(rectangle.topRight),
|
|
338
|
+
scalePoint(rectangle.bottomLeft),
|
|
339
|
+
scalePoint(rectangle.bottomRight)
|
|
340
|
+
)
|
|
933
341
|
}
|
|
934
342
|
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
val characteristics = cameraManager.getCameraCharacteristics(id)
|
|
944
|
-
characteristics.get(CameraCharacteristics.LENS_FACING) == desiredFacing
|
|
945
|
-
} ?: cameraManager.cameraIdList.firstOrNull()
|
|
946
|
-
} catch (e: Exception) {
|
|
947
|
-
Log.e(TAG, "[CAMERA2] Failed to select camera", e)
|
|
948
|
-
null
|
|
949
|
-
}
|
|
343
|
+
fun getPreviewViewport(): android.graphics.RectF? {
|
|
344
|
+
// With CameraX PreviewView, the viewport is simply the view bounds
|
|
345
|
+
val width = previewView.width.toFloat()
|
|
346
|
+
val height = previewView.height.toFloat()
|
|
347
|
+
|
|
348
|
+
if (width <= 0 || height <= 0) return null
|
|
349
|
+
|
|
350
|
+
return android.graphics.RectF(0f, 0f, width, height)
|
|
950
351
|
}
|
|
951
352
|
}
|
package/android/src/camera2/kotlin/com/reactnativerectangledocscanner/DocumentScannerView.kt
CHANGED
|
@@ -9,9 +9,9 @@ import android.graphics.PorterDuff
|
|
|
9
9
|
import android.graphics.PorterDuffXfermode
|
|
10
10
|
import org.opencv.core.Point
|
|
11
11
|
import android.util.Log
|
|
12
|
-
import android.view.TextureView
|
|
13
12
|
import android.view.View
|
|
14
13
|
import android.widget.FrameLayout
|
|
14
|
+
import androidx.camera.view.PreviewView
|
|
15
15
|
import androidx.lifecycle.Lifecycle
|
|
16
16
|
import androidx.lifecycle.LifecycleOwner
|
|
17
17
|
import androidx.lifecycle.LifecycleRegistry
|
|
@@ -28,7 +28,7 @@ import kotlin.math.max
|
|
|
28
28
|
|
|
29
29
|
class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), LifecycleOwner {
|
|
30
30
|
private val themedContext = context
|
|
31
|
-
private val previewView:
|
|
31
|
+
private val previewView: PreviewView
|
|
32
32
|
private val overlayView: OverlayView
|
|
33
33
|
private val useNativeOverlay = false
|
|
34
34
|
private var cameraController: CameraController? = null
|
|
@@ -81,12 +81,14 @@ class DocumentScannerView(context: ThemedReactContext) : FrameLayout(context), L
|
|
|
81
81
|
lifecycleRegistry.currentState = Lifecycle.State.INITIALIZED
|
|
82
82
|
Log.d(TAG, "[INIT] Lifecycle state: ${lifecycleRegistry.currentState}")
|
|
83
83
|
|
|
84
|
-
// Create preview view
|
|
84
|
+
// Create preview view (CameraX PreviewView)
|
|
85
85
|
Log.d(TAG, "[INIT] Creating PreviewView...")
|
|
86
|
-
previewView =
|
|
86
|
+
previewView = PreviewView(context).apply {
|
|
87
87
|
layoutParams = LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)
|
|
88
88
|
visibility = View.VISIBLE
|
|
89
89
|
keepScreenOn = true
|
|
90
|
+
implementationMode = PreviewView.ImplementationMode.COMPATIBLE // Use TextureView internally
|
|
91
|
+
scaleType = PreviewView.ScaleType.FILL_CENTER // Fill and center the preview
|
|
90
92
|
}
|
|
91
93
|
Log.d(TAG, "[INIT] PreviewView created: $previewView")
|
|
92
94
|
Log.d(TAG, "[INIT] PreviewView visibility: ${previewView.visibility}")
|