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 android.util.Size
24
- import android.view.Surface
25
- import android.view.TextureView
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.exifinterface.media.ExifInterface
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.io.FileOutputStream
34
- import java.io.ByteArrayInputStream
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: androidx.lifecycle.LifecycleOwner,
44
- private val previewView: TextureView
23
+ private val lifecycleOwner: LifecycleOwner,
24
+ private val previewView: PreviewView
45
25
  ) {
46
- private val cameraManager = context.getSystemService(Context.CAMERA_SERVICE) as CameraManager
47
- private var cameraDevice: CameraDevice? = null
48
- private var captureSession: CameraCaptureSession? = null
49
- private var previewRequestBuilder: CaptureRequest.Builder? = null
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 var previewSize: Size? = null
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
- private var torchEnabled = false
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 ANALYSIS_ASPECT_TOLERANCE = 0.15
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
- private val textureListener = object : TextureView.SurfaceTextureListener {
97
- override fun onSurfaceTextureAvailable(surface: SurfaceTexture, width: Int, height: Int) {
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
- fun stopCamera() {
135
- Log.d(TAG, "[CAMERA2] stopCamera called")
136
- previewView.surfaceTextureListener = null
137
- closeSession()
138
- }
62
+ useFrontCamera = useFront
63
+ detectionEnabled = enableDetection
139
64
 
140
- fun refreshTransform() {
141
- configureTransform()
142
- }
65
+ val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
143
66
 
144
- fun capturePhoto(
145
- outputDirectory: File,
146
- onImageCaptured: (File) -> Unit,
147
- onError: (Exception) -> Unit
148
- ) {
149
- val device = cameraDevice
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 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
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
- 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
- private fun setupImageReaders() {
404
- val analysis = analysisSize
405
- val capture = captureSize
406
-
407
- yuvReader?.close()
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
- if (capture != null) {
446
- jpegReader = ImageReader.newInstance(capture.width, capture.height, ImageFormat.JPEG, 2).apply {
447
- setOnImageAvailableListener({ reader ->
448
- val image = reader.acquireNextImage() ?: return@setOnImageAvailableListener
449
- val pending = pendingCapture.getAndSet(null)
450
- if (pending == null) {
451
- image.close()
452
- return@setOnImageAvailableListener
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
- analysisHandler.post { processCapture(image, pending) }
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
- surfaceTexture.setDefaultBufferSize(preview.width, preview.height)
466
- Log.d(TAG, "[CAMERA2] SurfaceTexture defaultBufferSize=${preview.width}x${preview.height}")
467
- val previewSurface = Surface(surfaceTexture)
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
- device.createCaptureSession(targets, object : CameraCaptureSession.StateCallback() {
475
- override fun onConfigured(session: CameraCaptureSession) {
476
- captureSession = session
477
- configureTransform()
478
- startRepeating(previewSurface)
479
- }
480
-
481
- override fun onConfigureFailed(session: CameraCaptureSession) {
482
- Log.e(TAG, "[CAMERA2] Failed to configure capture session")
483
- }
484
- }, cameraHandler)
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
- private fun startRepeating(previewSurface: Surface) {
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, "[CAMERA2] Failed to update torch state", e)
132
+ Log.e(TAG, "[CAMERAX] Use case binding failed", e)
515
133
  }
516
134
  }
517
135
 
518
- private fun analyzeImage(image: Image) {
519
- val rotationDegrees = computeRotationDegrees()
520
- val imageWidth = image.width
521
- val imageHeight = image.height
522
- val nv21 = try {
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 inputImage = try {
542
- InputImage.fromByteArray(
543
- nv21,
544
- imageWidth,
545
- imageHeight,
546
- rotationDegrees,
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
- val frameWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth
565
- val frameHeight = if (rotationDegrees == 90 || rotationDegrees == 270) imageWidth else imageHeight
566
- onFrameAnalyzed?.invoke(smoothRectangle(rectangle), frameWidth, frameHeight)
567
- }
568
- .addOnFailureListener { e ->
569
- Log.e(TAG, "[CAMERA2] ML Kit detection failed", e)
570
- val rectangle = try {
571
- DocumentDetector.detectRectangleInYUV(nv21, imageWidth, imageHeight, rotationDegrees)
572
- } catch (detectError: Exception) {
573
- Log.w(TAG, "[CAMERA2] OpenCV fallback failed", detectError)
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
- private fun processCapture(image: Image, pending: PendingCapture) {
586
- try {
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
- 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 closeSession() {
621
- try {
622
- captureSession?.close()
623
- captureSession = null
624
- cameraDevice?.close()
625
- cameraDevice = null
626
- } catch (e: Exception) {
627
- Log.e(TAG, "[CAMERA2] Error closing camera", e)
628
- } finally {
629
- yuvReader?.close()
630
- jpegReader?.close()
631
- yuvReader = null
632
- jpegReader = null
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
- val bestDiff = poolForSelection.minOf { aspectDiff(it) }
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 matching = poolForSelection.filter { aspectDiff(it) <= ANALYSIS_ASPECT_TOLERANCE }
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
- return matching.firstOrNull() ?: poolForSelection.first()
212
+ onFrameAnalyzed?.invoke(rectangle, frameWidth, frameHeight)
213
+ imageProxy.close()
766
214
  }
767
215
 
768
- private fun rotateAndMirror(bitmap: Bitmap, rotationDegrees: Int, mirror: Boolean): Bitmap {
769
- Log.d(TAG, "[ROTATE_MIRROR] rotationDegrees=$rotationDegrees mirror=$mirror bitmap=${bitmap.width}x${bitmap.height}")
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
- if (rotationDegrees == 0 && !mirror) {
772
- Log.d(TAG, "[ROTATE_MIRROR] No rotation/mirror needed, returning bitmap as-is")
773
- return bitmap
774
- }
222
+ val ySize = yBuffer.remaining()
223
+ val uSize = uBuffer.remaining()
224
+ val vSize = vBuffer.remaining()
775
225
 
776
- val matrix = Matrix()
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
- return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true)
785
- }
228
+ yBuffer.get(nv21, 0, ySize)
229
+ vBuffer.get(nv21, ySize, vSize)
230
+ uBuffer.get(nv21, ySize + vSize, uSize)
786
231
 
787
- private fun readExifRotation(bytes: ByteArray): Int {
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.w(TAG, "[CAMERA2] Failed to read EXIF rotation", e)
798
- 0
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
- imageWidth: Int,
805
- imageHeight: Int,
806
- rotationDegrees: Int,
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
- val uprightWidth = if (rotationDegrees == 90 || rotationDegrees == 270) imageHeight else imageWidth
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, "[CAMERA2] OpenCV refine failed", e)
249
+ Log.w(TAG, "[CAMERAX] OpenCV detection failed", e)
831
250
  null
832
251
  }
833
252
  }
834
253
 
835
- private fun boxToRectangle(box: Rect): Rectangle {
836
- return Rectangle(
837
- Point(box.left.toDouble(), box.top.toDouble()),
838
- Point(box.right.toDouble(), box.top.toDouble()),
839
- Point(box.left.toDouble(), box.bottom.toDouble()),
840
- Point(box.right.toDouble(), box.bottom.toDouble())
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
- private fun expandRect(box: Rect, maxWidth: Int, maxHeight: Int, ratio: Float): Rect {
845
- val padX = (box.width() * ratio).toInt()
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
- private fun insetBox(box: Rect, ratio: Float): Rect {
855
- if (ratio >= 1f) return box
856
- val insetX = ((1f - ratio) * box.width() / 2f).toInt()
857
- val insetY = ((1f - ratio) * box.height() / 2f).toInt()
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
- private fun smoothRectangle(current: Rectangle?): Rectangle? {
867
- val now = System.currentTimeMillis()
868
- val last = lastRectangle
869
- if (current == null) {
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
- private fun rectangleBounds(rectangle: Rectangle): Rect {
883
- val left = listOf(rectangle.topLeft.x, rectangle.bottomLeft.x, rectangle.topRight.x, rectangle.bottomRight.x).minOrNull() ?: 0.0
884
- val right = listOf(rectangle.topLeft.x, rectangle.bottomLeft.x, rectangle.topRight.x, rectangle.bottomRight.x).maxOrNull() ?: 0.0
885
- val top = listOf(rectangle.topLeft.y, rectangle.bottomLeft.y, rectangle.topRight.y, rectangle.bottomRight.y).minOrNull() ?: 0.0
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
- private fun imageToNv21(image: Image): ByteArray {
891
- val width = image.width
892
- val height = image.height
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
- val uvRowStride = image.planes[1].rowStride
913
- val uvPixelStride = image.planes[1].pixelStride
914
- val vRowStride = image.planes[2].rowStride
915
- val vPixelStride = image.planes[2].pixelStride
916
- val uvHeight = height / 2
917
- val uvWidth = width / 2
918
- for (row in 0 until uvHeight) {
919
- var uInputOffset = row * uvRowStride
920
- var vInputOffset = row * vRowStride
921
- for (col in 0 until uvWidth) {
922
- nv21[outputOffset++] = vBuffer.get(vInputOffset)
923
- nv21[outputOffset++] = uBuffer.get(uInputOffset)
924
- uInputOffset += uvPixelStride
925
- vInputOffset += vPixelStride
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 nv21
930
- }
931
- private fun hasCameraPermission(): Boolean {
932
- return ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
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
- private fun selectCameraId(): String? {
936
- return try {
937
- val desiredFacing = if (useFrontCamera) {
938
- CameraCharacteristics.LENS_FACING_FRONT
939
- } else {
940
- CameraCharacteristics.LENS_FACING_BACK
941
- }
942
- cameraManager.cameraIdList.firstOrNull { id ->
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
  }
@@ -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: TextureView
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 = TextureView(context).apply {
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}")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-native-rectangle-doc-scanner",
3
- "version": "7.65.0",
3
+ "version": "8.0.0",
4
4
  "description": "Native-backed document scanner for React Native with customizable overlays.",
5
5
  "license": "MIT",
6
6
  "main": "dist/index.js",