在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)
}
})
}