react-native-rectangle-doc-scanner 7.65.0 → 8.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,294 @@ 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
|
-
jpegReader?.close()
|
|
409
|
-
|
|
410
|
-
if (analysis != null) {
|
|
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
|
+
preview = Preview.Builder()
|
|
92
|
+
.build()
|
|
93
|
+
.also {
|
|
94
|
+
it.setSurfaceProvider(previewView.surfaceProvider)
|
|
442
95
|
}
|
|
443
|
-
}
|
|
444
96
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
97
|
+
// ImageAnalysis UseCase for document detection
|
|
98
|
+
imageAnalyzer = ImageAnalysis.Builder()
|
|
99
|
+
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
|
100
|
+
.build()
|
|
101
|
+
.also {
|
|
102
|
+
it.setAnalyzer(cameraExecutor) { imageProxy ->
|
|
103
|
+
if (detectionEnabled) {
|
|
104
|
+
analyzeImage(imageProxy)
|
|
105
|
+
} else {
|
|
106
|
+
imageProxy.close()
|
|
453
107
|
}
|
|
454
|
-
|
|
455
|
-
}, cameraHandler)
|
|
108
|
+
}
|
|
456
109
|
}
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
private fun createCaptureSession() {
|
|
461
|
-
val device = cameraDevice ?: return
|
|
462
|
-
val surfaceTexture = previewView.surfaceTexture ?: return
|
|
463
|
-
val preview = previewSize ?: return
|
|
464
110
|
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
val targets = mutableListOf(previewSurface)
|
|
470
|
-
yuvReader?.surface?.let { targets.add(it) }
|
|
471
|
-
jpegReader?.surface?.let { targets.add(it) }
|
|
111
|
+
// ImageCapture UseCase
|
|
112
|
+
imageCapture = ImageCapture.Builder()
|
|
113
|
+
.setCaptureMode(ImageCapture.CAPTURE_MODE_MAXIMIZE_QUALITY)
|
|
114
|
+
.build()
|
|
472
115
|
|
|
473
116
|
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
|
-
}
|
|
117
|
+
// Unbind all use cases before rebinding
|
|
118
|
+
cameraProvider.unbindAll()
|
|
119
|
+
|
|
120
|
+
// Bind use cases to camera
|
|
121
|
+
camera = cameraProvider.bindToLifecycle(
|
|
122
|
+
lifecycleOwner,
|
|
123
|
+
cameraSelector,
|
|
124
|
+
preview,
|
|
125
|
+
imageAnalyzer,
|
|
126
|
+
imageCapture
|
|
127
|
+
)
|
|
489
128
|
|
|
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
|
-
}
|
|
129
|
+
Log.d(TAG, "[CAMERAX] Camera bound successfully")
|
|
507
130
|
|
|
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
131
|
} catch (e: Exception) {
|
|
514
|
-
Log.e(TAG, "[
|
|
132
|
+
Log.e(TAG, "[CAMERAX] Use case binding failed", e)
|
|
515
133
|
}
|
|
516
134
|
}
|
|
517
135
|
|
|
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)
|
|
136
|
+
@androidx.annotation.OptIn(ExperimentalGetImage::class)
|
|
137
|
+
private fun analyzeImage(imageProxy: ImageProxy) {
|
|
138
|
+
val mediaImage = imageProxy.image
|
|
139
|
+
if (mediaImage == null) {
|
|
140
|
+
imageProxy.close()
|
|
532
141
|
return
|
|
533
|
-
} finally {
|
|
534
|
-
try {
|
|
535
|
-
image.close()
|
|
536
|
-
} catch (e: Exception) {
|
|
537
|
-
Log.w(TAG, "[CAMERA2] Failed to close image", e)
|
|
538
|
-
}
|
|
539
142
|
}
|
|
540
143
|
|
|
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
|
-
}
|
|
144
|
+
val rotationDegrees = imageProxy.imageInfo.rotationDegrees
|
|
145
|
+
val imageWidth = imageProxy.width
|
|
146
|
+
val imageHeight = imageProxy.height
|
|
147
|
+
|
|
148
|
+
// Try ML Kit first
|
|
149
|
+
val inputImage = InputImage.fromMediaImage(mediaImage, rotationDegrees)
|
|
554
150
|
|
|
555
151
|
objectDetector.process(inputImage)
|
|
556
152
|
.addOnSuccessListener { objects ->
|
|
153
|
+
if (objects.isEmpty()) {
|
|
154
|
+
// No objects detected, fallback to OpenCV
|
|
155
|
+
fallbackToOpenCV(imageProxy, rotationDegrees)
|
|
156
|
+
return@addOnSuccessListener
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Find largest object
|
|
557
160
|
val best = objects.maxByOrNull { obj ->
|
|
558
161
|
val box = obj.boundingBox
|
|
559
162
|
box.width() * box.height()
|
|
560
163
|
}
|
|
561
164
|
val mlBox = best?.boundingBox
|
|
562
|
-
val rectangle = refineWithOpenCv(nv21, imageWidth, imageHeight, rotationDegrees, mlBox)
|
|
563
165
|
|
|
564
|
-
|
|
565
|
-
val
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
166
|
+
// Refine with OpenCV
|
|
167
|
+
val nv21 = imageProxyToNV21(imageProxy)
|
|
168
|
+
val rectangle = if (nv21 != null) {
|
|
169
|
+
try {
|
|
170
|
+
refineWithOpenCv(nv21, imageWidth, imageHeight, rotationDegrees, mlBox)
|
|
171
|
+
} catch (e: Exception) {
|
|
172
|
+
Log.w(TAG, "[CAMERAX] OpenCV refinement failed", e)
|
|
173
|
+
null
|
|
174
|
+
}
|
|
175
|
+
} else {
|
|
574
176
|
null
|
|
575
177
|
}
|
|
178
|
+
|
|
576
179
|
val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth
|
|
577
180
|
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
181
|
|
|
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)
|
|
182
|
+
onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
|
|
183
|
+
imageProxy.close()
|
|
605
184
|
}
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
185
|
+
.addOnFailureListener { e ->
|
|
186
|
+
Log.w(TAG, "[CAMERAX] ML Kit detection failed, using OpenCV", e)
|
|
187
|
+
fallbackToOpenCV(imageProxy, rotationDegrees)
|
|
609
188
|
}
|
|
610
|
-
bitmap.recycle()
|
|
611
|
-
|
|
612
|
-
pending.onImageCaptured(photoFile)
|
|
613
|
-
} catch (e: Exception) {
|
|
614
|
-
pending.onError(e)
|
|
615
|
-
} finally {
|
|
616
|
-
image.close()
|
|
617
|
-
}
|
|
618
189
|
}
|
|
619
190
|
|
|
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")
|
|
191
|
+
private fun fallbackToOpenCV(imageProxy: ImageProxy, rotationDegrees: Int) {
|
|
192
|
+
val nv21 = imageProxyToNV21(imageProxy)
|
|
193
|
+
val rectangle = if (nv21 != null) {
|
|
194
|
+
try {
|
|
195
|
+
DocumentDetector.detectRectangleInYUV(
|
|
196
|
+
nv21,
|
|
197
|
+
imageProxy.width,
|
|
198
|
+
imageProxy.height,
|
|
199
|
+
rotationDegrees
|
|
200
|
+
)
|
|
201
|
+
} catch (e: Exception) {
|
|
202
|
+
Log.w(TAG, "[CAMERAX] OpenCV fallback failed", e)
|
|
203
|
+
null
|
|
754
204
|
}
|
|
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
|
|
205
|
+
} else {
|
|
206
|
+
null
|
|
761
207
|
}
|
|
762
208
|
|
|
763
|
-
val
|
|
209
|
+
val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.height else imageProxy.width
|
|
210
|
+
val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageProxy.width else imageProxy.height
|
|
764
211
|
|
|
765
|
-
|
|
212
|
+
onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
|
|
213
|
+
imageProxy.close()
|
|
766
214
|
}
|
|
767
215
|
|
|
768
|
-
private fun
|
|
769
|
-
|
|
216
|
+
private fun imageProxyToNV21(imageProxy: ImageProxy): ByteArray? {
|
|
217
|
+
return try {
|
|
218
|
+
val yBuffer = imageProxy.planes[0].buffer
|
|
219
|
+
val uBuffer = imageProxy.planes[1].buffer
|
|
220
|
+
val vBuffer = imageProxy.planes[2].buffer
|
|
770
221
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
}
|
|
222
|
+
val ySize = yBuffer.remaining()
|
|
223
|
+
val uSize = uBuffer.remaining()
|
|
224
|
+
val vSize = vBuffer.remaining()
|
|
775
225
|
|
|
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
|
-
}
|
|
226
|
+
val nv21 = ByteArray(ySize + uSize + vSize)
|
|
783
227
|
|
|
784
|
-
|
|
785
|
-
|
|
228
|
+
yBuffer.get(nv21, 0, ySize)
|
|
229
|
+
vBuffer.get(nv21, ySize, vSize)
|
|
230
|
+
uBuffer.get(nv21, ySize + vSize, uSize)
|
|
786
231
|
|
|
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
|
-
}
|
|
232
|
+
nv21
|
|
796
233
|
} catch (e: Exception) {
|
|
797
|
-
Log.
|
|
798
|
-
|
|
234
|
+
Log.e(TAG, "[CAMERAX] Failed to convert ImageProxy to NV21", e)
|
|
235
|
+
null
|
|
799
236
|
}
|
|
800
237
|
}
|
|
801
238
|
|
|
802
239
|
private fun refineWithOpenCv(
|
|
803
240
|
nv21: ByteArray,
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
mlBox: Rect?
|
|
241
|
+
width: Int,
|
|
242
|
+
height: Int,
|
|
243
|
+
rotation: Int,
|
|
244
|
+
mlBox: android.graphics.Rect?
|
|
808
245
|
): Rectangle? {
|
|
809
246
|
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
|
-
}
|
|
247
|
+
DocumentDetector.detectRectangleInYUV(nv21, width, height, rotation)
|
|
829
248
|
} catch (e: Exception) {
|
|
830
|
-
Log.w(TAG, "[
|
|
249
|
+
Log.w(TAG, "[CAMERAX] OpenCV detection failed", e)
|
|
831
250
|
null
|
|
832
251
|
}
|
|
833
252
|
}
|
|
834
253
|
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
254
|
+
fun capturePhoto(
|
|
255
|
+
outputDirectory: File,
|
|
256
|
+
onImageCaptured: (File) -> Unit,
|
|
257
|
+
onError: (Exception) -> Unit
|
|
258
|
+
) {
|
|
259
|
+
val imageCapture = imageCapture ?: run {
|
|
260
|
+
onError(IllegalStateException("ImageCapture not initialized"))
|
|
261
|
+
return
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
pendingCapture = PendingCapture(outputDirectory, onImageCaptured, onError)
|
|
265
|
+
|
|
266
|
+
val photoFile = File(outputDirectory, "doc_scan_${System.currentTimeMillis()}.jpg")
|
|
267
|
+
val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()
|
|
268
|
+
|
|
269
|
+
imageCapture.takePicture(
|
|
270
|
+
outputOptions,
|
|
271
|
+
ContextCompat.getMainExecutor(context),
|
|
272
|
+
object : ImageCapture.OnImageSavedCallback {
|
|
273
|
+
override fun onImageSaved(output: ImageCapture.OutputFileResults) {
|
|
274
|
+
Log.d(TAG, "[CAMERAX] Image saved: ${photoFile.absolutePath}")
|
|
275
|
+
onImageCaptured(photoFile)
|
|
276
|
+
pendingCapture = null
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
override fun onError(exception: ImageCaptureException) {
|
|
280
|
+
Log.e(TAG, "[CAMERAX] Image capture failed", exception)
|
|
281
|
+
onError(exception)
|
|
282
|
+
pendingCapture = null
|
|
283
|
+
}
|
|
284
|
+
}
|
|
841
285
|
)
|
|
842
286
|
}
|
|
843
287
|
|
|
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)
|
|
288
|
+
fun setTorchEnabled(enabled: Boolean) {
|
|
289
|
+
camera?.cameraControl?.enableTorch(enabled)
|
|
852
290
|
}
|
|
853
291
|
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
return Rect(
|
|
859
|
-
box.left + insetX,
|
|
860
|
-
box.top + insetY,
|
|
861
|
-
box.right - insetX,
|
|
862
|
-
box.bottom - insetY
|
|
863
|
-
)
|
|
292
|
+
fun stopCamera() {
|
|
293
|
+
Log.d(TAG, "[CAMERAX] stopCamera called")
|
|
294
|
+
cameraProvider?.unbindAll()
|
|
295
|
+
camera = null
|
|
864
296
|
}
|
|
865
297
|
|
|
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
|
|
298
|
+
fun shutdown() {
|
|
299
|
+
stopCamera()
|
|
300
|
+
objectDetector.close()
|
|
301
|
+
cameraExecutor.shutdown()
|
|
880
302
|
}
|
|
881
303
|
|
|
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())
|
|
304
|
+
fun refreshTransform() {
|
|
305
|
+
// CameraX handles transform automatically via PreviewView
|
|
306
|
+
// No manual matrix calculation needed!
|
|
307
|
+
Log.d(TAG, "[CAMERAX] Transform refresh requested - handled automatically by PreviewView")
|
|
888
308
|
}
|
|
889
309
|
|
|
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
|
-
}
|
|
310
|
+
// Simplified coordinate mapping - PreviewView handles most of the work
|
|
311
|
+
fun mapRectangleToView(rectangle: Rectangle?, imageWidth: Int, imageHeight: Int): Rectangle? {
|
|
312
|
+
if (rectangle == null || imageWidth <= 0 || imageHeight <= 0) return null
|
|
911
313
|
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
val
|
|
915
|
-
val
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
314
|
+
// CameraX PreviewView with FILL_CENTER handles scaling and centering
|
|
315
|
+
// We just need to scale the coordinates proportionally
|
|
316
|
+
val viewWidth = previewView.width.toFloat()
|
|
317
|
+
val viewHeight = previewView.height.toFloat()
|
|
318
|
+
|
|
319
|
+
if (viewWidth <= 0 || viewHeight <= 0) return null
|
|
320
|
+
|
|
321
|
+
// Simple proportional scaling
|
|
322
|
+
val scaleX = viewWidth / imageWidth.toFloat()
|
|
323
|
+
val scaleY = viewHeight / imageHeight.toFloat()
|
|
324
|
+
|
|
325
|
+
fun scalePoint(point: org.opencv.core.Point): org.opencv.core.Point {
|
|
326
|
+
return org.opencv.core.Point(
|
|
327
|
+
point.x * scaleX,
|
|
328
|
+
point.y * scaleY
|
|
329
|
+
)
|
|
927
330
|
}
|
|
928
331
|
|
|
929
|
-
return
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
332
|
+
return Rectangle(
|
|
333
|
+
scalePoint(rectangle.topLeft),
|
|
334
|
+
scalePoint(rectangle.topRight),
|
|
335
|
+
scalePoint(rectangle.bottomLeft),
|
|
336
|
+
scalePoint(rectangle.bottomRight)
|
|
337
|
+
)
|
|
933
338
|
}
|
|
934
339
|
|
|
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
|
-
}
|
|
340
|
+
fun getPreviewViewport(): android.graphics.RectF? {
|
|
341
|
+
// With CameraX PreviewView, the viewport is simply the view bounds
|
|
342
|
+
val width = previewView.width.toFloat()
|
|
343
|
+
val height = previewView.height.toFloat()
|
|
344
|
+
|
|
345
|
+
if (width <= 0 || height <= 0) return null
|
|
346
|
+
|
|
347
|
+
return android.graphics.RectF(0f, 0f, width, height)
|
|
950
348
|
}
|
|
951
349
|
}
|
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}")
|