Jetpack Compose CameraX实现扫码识别、OCR文字识别

1,514 阅读3分钟

在Jetpack Compose中使用CameraX,结合Google MLKit实现 扫码识别和OCR文字识别,其中OCR识别中实现了对扫码框的图片裁剪,再进行识别。

实现效果( 源码

配置依赖

// 相机
def camerax_version = '1.2.0-alpha04'
implementation "androidx.camera:camera-camera2:$camerax_version"
implementation "androidx.camera:camera-lifecycle:$camerax_version"
implementation "androidx.camera:camera-view:$camerax_version"
// mlkit
implementation "com.google.mlkit:barcode-scanning:17.0.2"
implementation "com.google.mlkit:text-recognition:16.0.0-beta4"
implementation "com.google.mlkit:text-recognition-chinese:16.0.0-beta4"

//申请权限
def accompanistVersion = "0.23.1"
implementation "com.google.accompanist:accompanist-permissions:$accompanistVersion"

核心代码

申请权限

@Composable
fun CameraViewPermission(
    modifier: Modifier = Modifier,
    preview: Preview,
    imageCapture: ImageCapture? = null,
    imageAnalysis: ImageAnalysis? = null,
    cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA,
    scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER,
    enableTorch: Boolean = false,
    focusOnTap: Boolean = false
) {

    val context = LocalContext.current

    PermissionView(
        permission = Manifest.permission.CAMERA,
        rationale = "请打开相机权限",
        permissionNotAvailableContent = {
            Column(modifier) {
                Text("未能获取相机")
                Spacer(modifier = Modifier.height(8.dp))
                TextButton(
                    onClick = {
                        openSettingsPermission(context)
                    }
                ) {
                    Text("打开应用权限设置")
                }
            }
        }
    ) {

        CameraView(
            modifier,
            preview = preview,
            imageCapture = imageCapture,
            imageAnalysis = imageAnalysis,
            scaleType = scaleType,
            cameraSelector = cameraSelector,
            focusOnTap = focusOnTap,
            enableTorch = enableTorch,
        )


    }


}


@ExperimentalPermissionsApi
@Composable
fun PermissionView(
    permission: String = android.Manifest.permission.CAMERA,
    rationale: String = "该功能需要此权限,请打开该权限。",
    permissionNotAvailableContent: @Composable () -> Unit = { },
    content: @Composable () -> Unit = { }
) {
    val permissionState = rememberPermissionState(permission)
    PermissionRequired(
        permissionState = permissionState,
        permissionNotGrantedContent = {
            Rationale(
                text = rationale,
                onRequestPermission = { permissionState.launchPermissionRequest() }
            )
        },
        permissionNotAvailableContent = permissionNotAvailableContent,
        content = content
    )
}

@Composable
private fun Rationale(
    text: String,
    onRequestPermission: () -> Unit
) {
    AlertDialog(
        onDismissRequest = { /* Don't */ },
        title = {
            Text(text = "请求权限")
        },
        text = {
            Text(text)
        },
        confirmButton = {
            Button(onClick = onRequestPermission) {
                Text("确定")
            }
        }
    )
}

fun openSettingsPermission(context: Context) {
    context.startActivity(
        Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
            data = Uri.fromParts("package", context.packageName, null)
        }
    )
}

CameraView

// https://stackoverflow.com/a/70302763
@Composable
fun CameraView(
    modifier: Modifier = Modifier,
    preview: Preview,
    imageCapture: ImageCapture? = null,
    imageAnalysis: ImageAnalysis? = null,
    cameraSelector: CameraSelector = CameraSelector.DEFAULT_BACK_CAMERA,
    scaleType: PreviewView.ScaleType = PreviewView.ScaleType.FILL_CENTER,
    enableTorch: Boolean = false,
    focusOnTap: Boolean = false
) {

    val context = LocalContext.current

    //1
    val previewView = remember { PreviewView(context) }
    val lifecycleOwner = LocalLifecycleOwner.current

    val cameraProvider by produceState<ProcessCameraProvider?>(initialValue = null) {
        value = context.getCameraProvider()
    }

    val camera = remember(cameraProvider) {
        cameraProvider?.let {
            it.unbindAll()
            it.bindToLifecycle(
                lifecycleOwner,
                cameraSelector,
                *listOfNotNull(preview, imageAnalysis, imageCapture).toTypedArray()
            )
        }
    }


    // 2
    LaunchedEffect(true) {
        preview.setSurfaceProvider(previewView.surfaceProvider)
        previewView.scaleType = scaleType
    }


    LaunchedEffect(camera, enableTorch) {
        // 控制闪光灯
        camera?.let {
            if (it.cameraInfo.hasFlashUnit()) {
                it.cameraControl.enableTorch(context, enableTorch)
            }
        }
    }

    DisposableEffect(Unit) {
        onDispose {
            cameraProvider?.unbindAll()
        }
    }

    // 3
    AndroidView(
        { previewView },
        modifier = modifier
            .fillMaxSize()
            .pointerInput(camera, focusOnTap) {
                if (!focusOnTap) return@pointerInput

                detectTapGestures {
                    val meteringPointFactory = SurfaceOrientedMeteringPointFactory(
                        size.width.toFloat(),
                        size.height.toFloat()
                    )
                    
                    // 点击屏幕聚焦
                    val meteringAction = FocusMeteringAction
                        .Builder(
                            meteringPointFactory.createPoint(it.x, it.y),
                            FocusMeteringAction.FLAG_AF
                        )
                        .disableAutoCancel()
                        .build()

                    camera?.cameraControl?.startFocusAndMetering(meteringAction)
                }
            },
    )
}



private suspend fun Context.getCameraProvider(): ProcessCameraProvider =
    suspendCoroutine { continuation ->
        ProcessCameraProvider.getInstance(this).also { cameraProvider ->
            cameraProvider.addListener({
                continuation.resume(cameraProvider.get())
            }, ContextCompat.getMainExecutor(this))
        }
    }

private suspend fun CameraControl.enableTorch(context: Context, torch: Boolean): Unit =
    suspendCoroutine {
        enableTorch(torch).addListener(
            {},
            ContextCompat.getMainExecutor(context)
        )
    }

绘制扫描框

// We only need to analyze the part of the image that has text, so we set crop percentages
// to avoid analyze the entire image from the live camera feed.
// 裁剪区域 比例
val cropTopLeftScale: Offset = Offset(x = 0.025f, y = 0.3f)
val cropSizeScale: Size = Size(width = 0.95f, height = 0.1f)



/**
 * 扫描框:按屏幕比例
 *
 * 当 sizeScale.height == 0f 或 sizeScale.width == 0f 为方形
 *
 */
@Composable
fun DrawCropScan(
    topLeftScale: Offset = cropTopLeftScale,
    sizeScale: Size = cropSizeScale,
    color: Color = MaterialTheme.colorScheme.primary,
) {

    var lineBottomY by remember { mutableStateOf(0f) }
    
    var isAnimated by remember { mutableStateOf(true) }
    
    val lineYAnimation by animateFloatAsState(
        targetValue = if (isAnimated) 0f else lineBottomY,
        animationSpec = infiniteRepeatable(animation = TweenSpec(durationMillis = 1500))
    )

    LaunchedEffect(true) {
        isAnimated = !isAnimated
    }

    Canvas(modifier = Modifier
        .fillMaxSize()
        .onGloballyPositioned {
        }
    ) {

        val paint = Paint().asFrameworkPaint()
        paint.apply {
            isAntiAlias = true
            textSize = 24.sp.toPx()
            typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD)
        }
        
        // 绘制背景
        drawRect(Color.Transparent.copy(alpha = 0.1f))

        // 扫描框 高度、宽度
        var height = size.height * sizeScale.height
        var with = size.width * sizeScale.width

        // square 方形
        if(sizeScale.height == 0f ){
            height = with
        }
        if(sizeScale.width == 0f ){
            with = height
        }


        val topLeft = Offset(x = size.width * topLeftScale.x, y = size.height * topLeftScale.y)


        // 扫描框 矩形
        val rectF = Rect(offset = topLeft, size = Size(with, height))

//        Log.d("rectF", " width-height: ${rectF.width} * ${rectF.height}")
//        Log.d("rectF", "$rectF")
//        Log.d("size", "${size.toRect()}")


        drawRoundRect(
            color = Color.Transparent,
            topLeft = rectF.topLeft, size = rectF.size,
            blendMode = BlendMode.Clear
        )

        // 扫描线 可到达的最大位置
        lineBottomY = height - 5.dp.toPx()

        val padding = 10.dp.toPx()
        // 扫描线
        val rectLine = Rect(
            offset = topLeft.plus(Offset(x = padding, y = lineYAnimation)),
            size = Size(with - 2 * padding, 3.dp.toPx())
        )

        // 画扫描线
        drawOval(color, rectLine.topLeft, rectLine.size)

        // 边框
        val lineWith = 3.dp.toPx()
        val lineLength = 12.dp.toPx()

        val lSizeH = Size(lineLength, lineWith)
        val lSizeV = Size(lineWith, lineLength)

        val path = Path()
        // 左上角
        path.addRect(Rect(offset = rectF.topLeft, lSizeH))
        path.addRect(Rect(offset = rectF.topLeft, lSizeV))

        // 左下角
        path.addRect(Rect(offset = rectF.bottomLeft.minus(Offset(x = 0f, y = lineWith)), lSizeH))
        path.addRect(Rect(offset = rectF.bottomLeft.minus(Offset(x = 0f, y = lineLength)), lSizeV))
        // 右上角
        path.addRect(Rect(offset = rectF.topRight.minus(Offset(x = lineLength, y = 0f)), lSizeH))
        path.addRect(Rect(offset = rectF.topRight.minus(Offset(x = lineWith, y = 0f)), lSizeV))
        // 右下角
        path.addRect(
            Rect(offset = rectF.bottomRight.minus(Offset(x = lineLength, y = lineWith)), lSizeH)
        )
        path.addRect(
            Rect(offset = rectF.bottomRight.minus(Offset(x = lineWith, y = lineLength)), lSizeV)
        )

        drawPath(path = path, color = Color.White)
    }
}

MLKit 图像识别

配置识别器

// 文字识别
private val textRecognizer: TextRecognizer = TextRecognition.getClient(
    ChineseTextRecognizerOptions.Builder().build()
)
// 条码、二维码识别
private val barcodeScanner: BarcodeScanner = BarcodeScanning.getClient(
    BarcodeScannerOptions.Builder()
   .setBarcodeFormats(Barcode.FORMAT_CODE_128, Barcode.FORMAT_QR_CODE).build()
)

图像分析

imageAnalysis.setAnalyzer(Executors.newSingleThreadExecutor()) { image ->
    if (image.image == null) {
        image.close()
        return@setAnalyzer
    }

    val mediaImage = image.image!!

    val inputImage = InputImage.fromMediaImage(mediaImage, image.imageInfo.rotationDegrees)

    val task = if (useOCR) {

        // OCR 文字识别, 截取扫描框图片
        val bitmap = cropTextImage(image) ?: return@setAnalyzer

        val inputImageCrop = InputImage.fromBitmap(bitmap, 0)

        textRecognizer.process(inputImageCrop)
            .addOnSuccessListener {
                val text = it.text

                Log.d("zzz", "textRecognizer onSuccess")
                Log.d("zzzzzz OCR result", "ocr result: $text")
                bitmapR.value = bitmap
                scanText.value = text

            }.addOnFailureListener {
                Log.d("zzz", "onFailure")
                bitmapR.value = bitmap
                scanText.value = "onFailure"
            }
    } else {
           barcodeScanner.process(inputImage)
            .addOnSuccessListener {
                Log.d("zzz", "barcodeScanner onSuccess")
                it.forEach { code ->
                    val text = code.displayValue ?: ""
                    text.isNotEmpty().apply {
                        scanBarcode.value = text
                    }
                }

            }.addOnFailureListener {
                Log.d("zzz", "onFailure")
                scanBarcode.value = "onFailure"

            }
    }


    task.addOnCompleteListener {
        // 关闭
        image.close()
    }

}

裁剪扫描框的图片

// 裁剪区域 比例
val cropTopLeftScale: Offset = Offset(x = 0.025f, y = 0.3f)
val cropSizeScale: Size = Size(width = 0.95f, height = 0.1f)


@SuppressLint("UnsafeOptInUsageError")
fun cropTextImage(imageProxy: ImageProxy): Bitmap? {
    val mediaImage = imageProxy.image ?: return null

    val rotationDegrees = imageProxy.imageInfo.rotationDegrees


    val imageHeight = mediaImage.height
    val imageWidth = mediaImage.width


    // 根据图片角度,计算裁剪框 
    val cropRect = when (rotationDegrees) {
        90, 270 -> getCropRect90(imageHeight.toFloat(), imageWidth.toFloat()).toAndroidRect()
        else -> getCropRect(imageHeight.toFloat(), imageWidth.toFloat()).toAndroidRect()
    }


    val convertImageToBitmap = ImageUtils.convertYuv420888ImageToBitmap(mediaImage)
    // 旋转并裁剪
    val croppedBitmap =
        ImageUtils.rotateAndCrop(convertImageToBitmap, rotationDegrees, cropRect)

//        Log.d("===", "====================================")
//        Log.d("mediaImage", "$rotationDegrees width-height: $imageWidth * $imageHeight")
//        Log.d("cropRect", "$rotationDegrees width-height: ${cropRect.width()} * ${cropRect.height()}")
//        Log.d("cropRect", "$rotationDegrees ltrb: $cropRect")
//
//        Log.d("convertImageToBitmap", "width-height: ${convertImageToBitmap.width} * ${convertImageToBitmap.height}")
//        Log.d("croppedBitmap", "width-height: ${croppedBitmap.width} * ${croppedBitmap.height}")


    return croppedBitmap

}


fun getCropRect(
    surfaceHeight: Float,
    surfaceWidth: Float,
    topLeftScale: Offset = cropTopLeftScale,
    sizeScale: Size = cropSizeScale,
): Rect {

    val height = surfaceHeight * sizeScale.height
    val with = surfaceWidth * sizeScale.width
    val topLeft = Offset(x = surfaceWidth * topLeftScale.x, y = surfaceHeight * topLeftScale.y)

    return Rect(offset = topLeft, size = Size(with, height))

}

fun getCropRect90(
    surfaceHeight: Float,
    surfaceWidth: Float,
    topLeftScale: Offset = Offset(x = cropTopLeftScale.y, y = cropTopLeftScale.x),
    sizeScale: Size = Size(width = cropSizeScale.height, height = cropSizeScale.width),
): Rect {

    val height = surfaceHeight * sizeScale.height
    val with = surfaceWidth * sizeScale.width
    val topLeft = Offset(x = surfaceWidth * topLeftScale.x, y = surfaceHeight * topLeftScale.y)

    return Rect(offset = topLeft, size = Size(with, height))

}



// 显示裁剪后,得到的图片
@Composable
fun ShowAfterCropImageToAnalysis(bitmap: Bitmap) {

    Image(bitmap = bitmap.asImageBitmap(), contentDescription = null,
        contentScale = ContentScale.FillWidth,
        modifier = Modifier
            .padding(top = 60.dp)
            .fillMaxWidth()

            .drawWithContent {
                drawContent()
                drawRect(
                    Color.Red,
                    Offset.Zero,
                    Size(height = size.height, width = size.width),
                    style = Stroke(width = 2.dp.toPx())
                )
            }
    )
}

拍照


fun ImageCapture.takePhoto(
    filenameFormat: String = "yyyy-MM-dd-HH-mm-ss-SSS",
    outputDirectory: File,
    executor: Executor = Executors.newSingleThreadExecutor(),
    onImageCaptured: (Uri) -> Unit,
    onError: (ImageCaptureException) -> Unit
) {

    val photoFile = File(
        outputDirectory,
        SimpleDateFormat(filenameFormat, Locale.US).format(System.currentTimeMillis()) + ".jpg"
    )

    val outputOptions = ImageCapture.OutputFileOptions.Builder(photoFile).build()

    takePicture(outputOptions, executor, object : ImageCapture.OnImageSavedCallback {
        override fun onError(exception: ImageCaptureException) {
            Log.e("kilo", "Take photo error:", exception)
            onError(exception)
        }

        override fun onImageSaved(outputFileResults: ImageCapture.OutputFileResults) {
            val savedUri = Uri.fromFile(photoFile)
            onImageCaptured(savedUri)
        }
    })
}