react-native-rectangle-doc-scanner 7.64.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,866 +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)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
session.capture(requestBuilder.build(), object : CameraCaptureSession.CaptureCallback() {}, cameraHandler)
|
|
178
|
-
} catch (e: Exception) {
|
|
179
|
-
pendingCapture.getAndSet(null)?.onError?.invoke(e)
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
|
-
fun setTorchEnabled(enabled: Boolean) {
|
|
184
|
-
torchEnabled = enabled
|
|
185
|
-
updateRepeatingRequest()
|
|
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
|
|
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)
|
|
229
73
|
}
|
|
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
|
-
)
|
|
74
|
+
}, ContextCompat.getMainExecutor(context))
|
|
278
75
|
}
|
|
279
76
|
|
|
280
|
-
fun
|
|
281
|
-
val
|
|
282
|
-
|
|
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
82
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
381
|
-
cameraManager.openCamera(cameraId, object : CameraDevice.StateCallback() {
|
|
382
|
-
override fun onOpened(camera: CameraDevice) {
|
|
383
|
-
cameraDevice = camera
|
|
384
|
-
createCaptureSession()
|
|
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
|
-
|
|
465
|
-
surfaceTexture.setDefaultBufferSize(preview.width, preview.height)
|
|
466
|
-
Log.d(TAG, "[CAMERA2] SurfaceTexture defaultBufferSize=${preview.width}x${preview.height}")
|
|
467
|
-
val previewSurface = Surface(surfaceTexture)
|
|
468
110
|
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
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
|
|
182
|
+
onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
|
|
183
|
+
imageProxy.close()
|
|
599
184
|
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
FileOutputStream(photoFile).use { out ->
|
|
604
|
-
rotated.compress(Bitmap.CompressFormat.JPEG, 95, out)
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
if (rotated != bitmap) {
|
|
608
|
-
rotated.recycle()
|
|
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
|
-
val appliedRotation = rotationDegrees
|
|
673
|
-
val swap = appliedRotation == 90 || appliedRotation == 270
|
|
674
|
-
val bufferWidth = if (swap) preview.height.toFloat() else preview.width.toFloat()
|
|
675
|
-
val bufferHeight = if (swap) preview.width.toFloat() else preview.height.toFloat()
|
|
676
|
-
val bufferRect = RectF(0f, 0f, bufferWidth, bufferHeight)
|
|
677
|
-
bufferRect.offset(centerX - bufferRect.centerX(), centerY - bufferRect.centerY())
|
|
678
|
-
|
|
679
|
-
val matrix = Matrix()
|
|
680
|
-
matrix.setRectToRect(viewRect, bufferRect, Matrix.ScaleToFit.FILL)
|
|
681
|
-
val scale = max(viewWidth / bufferRect.width(), viewHeight / bufferRect.height())
|
|
682
|
-
matrix.postScale(scale, scale, centerX, centerY)
|
|
683
|
-
if (appliedRotation != 0) {
|
|
684
|
-
matrix.postRotate(appliedRotation.toFloat(), centerX, centerY)
|
|
685
|
-
}
|
|
686
|
-
|
|
687
|
-
val pts = floatArrayOf(
|
|
688
|
-
0f, 0f,
|
|
689
|
-
bufferWidth, 0f,
|
|
690
|
-
0f, bufferHeight,
|
|
691
|
-
bufferWidth, bufferHeight
|
|
692
|
-
)
|
|
693
|
-
matrix.mapPoints(pts)
|
|
694
|
-
|
|
695
|
-
Log.d(
|
|
696
|
-
TAG,
|
|
697
|
-
"[TRANSFORM] rotations sensor=$sensorOrientation display=$displayRotation computed=$rotationDegrees applied=$appliedRotation " +
|
|
698
|
-
"view=${viewWidth}x${viewHeight} preview=${preview.width}x${preview.height}"
|
|
699
|
-
)
|
|
700
|
-
|
|
701
|
-
previewView.setTransform(matrix)
|
|
702
|
-
latestTransform = Matrix(matrix)
|
|
703
|
-
latestBufferWidth = preview.width
|
|
704
|
-
latestBufferHeight = preview.height
|
|
705
|
-
latestTransformRotation = appliedRotation
|
|
706
|
-
|
|
707
|
-
Log.d(
|
|
708
|
-
TAG,
|
|
709
|
-
"[TRANSFORM] viewClass=${previewView.javaClass.name} isTextureView=${previewView is TextureView} " +
|
|
710
|
-
"buffer=${bufferWidth}x${bufferHeight} scale=$scale center=${centerX}x${centerY} matrix=$matrix " +
|
|
711
|
-
"pts=[${pts[0]},${pts[1]} ${pts[2]},${pts[3]} ${pts[4]},${pts[5]} ${pts[6]},${pts[7]}]"
|
|
712
|
-
)
|
|
713
|
-
val recomputed = computeRotationDegrees()
|
|
714
|
-
if (rotationDegrees != recomputed) {
|
|
715
|
-
Log.e(TAG, "[TRANSFORM] rotation mismatch computed=$rotationDegrees recomputed=$recomputed")
|
|
716
|
-
}
|
|
717
|
-
Log.d(TAG, "[TRANSFORM] Matrix applied successfully")
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
private fun chooseBestSize(
|
|
721
|
-
sizes: Array<Size>?,
|
|
722
|
-
targetAspect: Double,
|
|
723
|
-
maxArea: Int?,
|
|
724
|
-
minArea: Int? = null,
|
|
725
|
-
preferClosestAspect: Boolean = false
|
|
726
|
-
): Size? {
|
|
727
|
-
if (sizes == null || sizes.isEmpty()) return null
|
|
728
|
-
val sorted = sizes.sortedByDescending { it.width * it.height }
|
|
729
|
-
|
|
730
|
-
val capped = if (maxArea != null) {
|
|
731
|
-
sorted.filter { it.width * it.height <= maxArea }
|
|
732
|
-
} else {
|
|
733
|
-
sorted
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
if (capped.isEmpty()) {
|
|
737
|
-
return sorted.first()
|
|
738
|
-
}
|
|
739
|
-
|
|
740
|
-
val minCapped = if (minArea != null) {
|
|
741
|
-
capped.filter { it.width * it.height >= minArea }
|
|
742
|
-
} else {
|
|
743
|
-
capped
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
val poolForSelection = if (minCapped.isNotEmpty()) minCapped else capped
|
|
747
|
-
|
|
748
|
-
fun aspectDiff(size: Size): Double {
|
|
749
|
-
val w = size.width.toDouble()
|
|
750
|
-
val h = size.height.toDouble()
|
|
751
|
-
val aspect = max(w, h) / min(w, h)
|
|
752
|
-
val target = max(targetAspect, 1.0 / targetAspect)
|
|
753
|
-
return abs(aspect - target)
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
if (preferClosestAspect) {
|
|
757
|
-
// Prefer aspect ratio match first, then pick the highest resolution among matches.
|
|
758
|
-
poolForSelection.forEach { size ->
|
|
759
|
-
val diff = aspectDiff(size)
|
|
760
|
-
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
|
|
761
204
|
}
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
val close = poolForSelection.filter { aspectDiff(it) <= bestDiff + 0.001 }
|
|
765
|
-
val selected = close.maxByOrNull { it.width * it.height } ?: poolForSelection.maxByOrNull { it.width * it.height }
|
|
766
|
-
Log.d(TAG, "[SIZE_SELECTION] Best aspect diff: $bestDiff, candidates: ${close.size}, selected: ${selected?.width}x${selected?.height}")
|
|
767
|
-
return selected
|
|
205
|
+
} else {
|
|
206
|
+
null
|
|
768
207
|
}
|
|
769
208
|
|
|
770
|
-
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
|
|
771
211
|
|
|
772
|
-
|
|
212
|
+
onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
|
|
213
|
+
imageProxy.close()
|
|
773
214
|
}
|
|
774
215
|
|
|
775
|
-
private fun
|
|
776
|
-
|
|
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
|
|
777
221
|
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
}
|
|
222
|
+
val ySize = yBuffer.remaining()
|
|
223
|
+
val uSize = uBuffer.remaining()
|
|
224
|
+
val vSize = vBuffer.remaining()
|
|
782
225
|
|
|
783
|
-
|
|
784
|
-
if (rotationDegrees != 0) {
|
|
785
|
-
matrix.postRotate(rotationDegrees.toFloat(), bitmap.width / 2f, bitmap.height / 2f)
|
|
786
|
-
}
|
|
787
|
-
if (mirror) {
|
|
788
|
-
matrix.postScale(-1f, 1f, bitmap.width / 2f, bitmap.height / 2f)
|
|
789
|
-
}
|
|
226
|
+
val nv21 = ByteArray(ySize + uSize + vSize)
|
|
790
227
|
|
|
791
|
-
|
|
792
|
-
|
|
228
|
+
yBuffer.get(nv21, 0, ySize)
|
|
229
|
+
vBuffer.get(nv21, ySize, vSize)
|
|
230
|
+
uBuffer.get(nv21, ySize + vSize, uSize)
|
|
793
231
|
|
|
794
|
-
|
|
795
|
-
return try {
|
|
796
|
-
val exif = ExifInterface(ByteArrayInputStream(bytes))
|
|
797
|
-
when (exif.getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)) {
|
|
798
|
-
ExifInterface.ORIENTATION_ROTATE_90 -> 90
|
|
799
|
-
ExifInterface.ORIENTATION_ROTATE_180 -> 180
|
|
800
|
-
ExifInterface.ORIENTATION_ROTATE_270 -> 270
|
|
801
|
-
else -> 0
|
|
802
|
-
}
|
|
232
|
+
nv21
|
|
803
233
|
} catch (e: Exception) {
|
|
804
|
-
Log.
|
|
805
|
-
|
|
234
|
+
Log.e(TAG, "[CAMERAX] Failed to convert ImageProxy to NV21", e)
|
|
235
|
+
null
|
|
806
236
|
}
|
|
807
237
|
}
|
|
808
238
|
|
|
809
239
|
private fun refineWithOpenCv(
|
|
810
240
|
nv21: ByteArray,
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
mlBox: Rect?
|
|
241
|
+
width: Int,
|
|
242
|
+
height: Int,
|
|
243
|
+
rotation: Int,
|
|
244
|
+
mlBox: android.graphics.Rect?
|
|
815
245
|
): Rectangle? {
|
|
816
246
|
return try {
|
|
817
|
-
|
|
818
|
-
val uprightHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight
|
|
819
|
-
val openCvRect = if (mlBox != null) {
|
|
820
|
-
val expanded = expandRect(mlBox, uprightWidth, uprightHeight, 0.25f)
|
|
821
|
-
DocumentDetector.detectRectangleInYUVWithRoi(
|
|
822
|
-
nv21,
|
|
823
|
-
imageWidth,
|
|
824
|
-
imageHeight,
|
|
825
|
-
rotationDegrees,
|
|
826
|
-
expanded
|
|
827
|
-
)
|
|
828
|
-
} else {
|
|
829
|
-
DocumentDetector.detectRectangleInYUV(nv21, imageWidth, imageHeight, rotationDegrees)
|
|
830
|
-
}
|
|
831
|
-
if (openCvRect == null) {
|
|
832
|
-
mlBox?.let { boxToRectangle(insetBox(it, 0.9f)) }
|
|
833
|
-
} else {
|
|
834
|
-
openCvRect
|
|
835
|
-
}
|
|
247
|
+
DocumentDetector.detectRectangleInYUV(nv21, width, height, rotation)
|
|
836
248
|
} catch (e: Exception) {
|
|
837
|
-
Log.w(TAG, "[
|
|
249
|
+
Log.w(TAG, "[CAMERAX] OpenCV detection failed", e)
|
|
838
250
|
null
|
|
839
251
|
}
|
|
840
252
|
}
|
|
841
253
|
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
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
|
+
}
|
|
848
285
|
)
|
|
849
286
|
}
|
|
850
287
|
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
val padY = (box.height() * ratio).toInt()
|
|
854
|
-
val left = (box.left - padX).coerceAtLeast(0)
|
|
855
|
-
val top = (box.top - padY).coerceAtLeast(0)
|
|
856
|
-
val right = (box.right + padX).coerceAtMost(maxWidth)
|
|
857
|
-
val bottom = (box.bottom + padY).coerceAtMost(maxHeight)
|
|
858
|
-
return Rect(left, top, right, bottom)
|
|
288
|
+
fun setTorchEnabled(enabled: Boolean) {
|
|
289
|
+
camera?.cameraControl?.enableTorch(enabled)
|
|
859
290
|
}
|
|
860
291
|
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
return Rect(
|
|
866
|
-
box.left + insetX,
|
|
867
|
-
box.top + insetY,
|
|
868
|
-
box.right - insetX,
|
|
869
|
-
box.bottom - insetY
|
|
870
|
-
)
|
|
292
|
+
fun stopCamera() {
|
|
293
|
+
Log.d(TAG, "[CAMERAX] stopCamera called")
|
|
294
|
+
cameraProvider?.unbindAll()
|
|
295
|
+
camera = null
|
|
871
296
|
}
|
|
872
297
|
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
if (last != null && now - lastRectangleTimestamp < 150) {
|
|
878
|
-
return last
|
|
879
|
-
}
|
|
880
|
-
lastRectangle = null
|
|
881
|
-
return null
|
|
882
|
-
}
|
|
883
|
-
|
|
884
|
-
lastRectangle = current
|
|
885
|
-
lastRectangleTimestamp = now
|
|
886
|
-
return current
|
|
298
|
+
fun shutdown() {
|
|
299
|
+
stopCamera()
|
|
300
|
+
objectDetector.close()
|
|
301
|
+
cameraExecutor.shutdown()
|
|
887
302
|
}
|
|
888
303
|
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
val bottom = listOf(rectangle.topLeft.y, rectangle.bottomLeft.y, rectangle.topRight.y, rectangle.bottomRight.y).maxOrNull() ?: 0.0
|
|
894
|
-
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")
|
|
895
308
|
}
|
|
896
309
|
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
val ySize = width * height
|
|
901
|
-
val uvSize = width * height / 2
|
|
902
|
-
val nv21 = ByteArray(ySize + uvSize)
|
|
903
|
-
|
|
904
|
-
val yBuffer = image.planes[0].buffer
|
|
905
|
-
val uBuffer = image.planes[1].buffer
|
|
906
|
-
val vBuffer = image.planes[2].buffer
|
|
907
|
-
|
|
908
|
-
val yRowStride = image.planes[0].rowStride
|
|
909
|
-
val yPixelStride = image.planes[0].pixelStride
|
|
910
|
-
var outputOffset = 0
|
|
911
|
-
for (row in 0 until height) {
|
|
912
|
-
var inputOffset = row * yRowStride
|
|
913
|
-
for (col in 0 until width) {
|
|
914
|
-
nv21[outputOffset++] = yBuffer.get(inputOffset)
|
|
915
|
-
inputOffset += yPixelStride
|
|
916
|
-
}
|
|
917
|
-
}
|
|
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
|
|
918
313
|
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
val
|
|
922
|
-
val
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
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
|
+
)
|
|
934
330
|
}
|
|
935
331
|
|
|
936
|
-
return
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
332
|
+
return Rectangle(
|
|
333
|
+
scalePoint(rectangle.topLeft),
|
|
334
|
+
scalePoint(rectangle.topRight),
|
|
335
|
+
scalePoint(rectangle.bottomLeft),
|
|
336
|
+
scalePoint(rectangle.bottomRight)
|
|
337
|
+
)
|
|
940
338
|
}
|
|
941
339
|
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
val characteristics = cameraManager.getCameraCharacteristics(id)
|
|
951
|
-
characteristics.get(CameraCharacteristics.LENS_FACING) == desiredFacing
|
|
952
|
-
} ?: cameraManager.cameraIdList.firstOrNull()
|
|
953
|
-
} catch (e: Exception) {
|
|
954
|
-
Log.e(TAG, "[CAMERA2] Failed to select camera", e)
|
|
955
|
-
null
|
|
956
|
-
}
|
|
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)
|
|
957
348
|
}
|
|
958
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}")
|