Android Jetpack Compose中如何按矩形遮罩精准裁剪图片?
精准裁剪相机拍摄图像至屏幕遮罩边界的解决方案
问题背景
需求为拍摄屏幕上矩形遮罩内的图像,并将其精准裁剪至遮罩边界,但目前裁剪后的图片存在拉伸或包含遮罩外区域的问题。
原相机拍摄裁剪代码
override suspend fun onCapturePhoto( controller: LifecycleCameraController, onResult: (Result<Bitmap>) -> Unit ) { controller.takePicture(ContextCompat.getMainExecutor(application), object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { super.onCaptureSuccess(image) try { val rotationDegrees = image.imageInfo.rotationDegrees val bitmap = imageProxyToBitmap(image) // Rotate the bitmap if necessary val correctedBitmap = rotateBitmap( bitmap, rotationDegrees ) // Calculate cropping rectangle dimensions val width = correctedBitmap.width val height = correctedBitmap.height val centerX = width / 2 val centerY = height / 2 // 20% up and 20% below the center val cropHeight = (height * 0.4).toInt() // Total height (20% above + 20% below) val cropTop = (centerY - cropHeight / 2).coerceAtLeast(0) // Ensure it's not negative val cropBottom = (cropTop + cropHeight).coerceAtMost(height) // Ensure it doesn't exceed the image // Create the cropped bitmap val imageBitmap = Bitmap.createBitmap( correctedBitmap, 0, // Start from the left (full width) cropTop, // Start from the calculated top width, // Full width cropBottom - cropTop // Height of the cropped area ) onResult(Result.success(imageBitmap)) } catch (e: Exception) { onResult(Result.failure(e)) } finally { image.close() // Ensure resources are released } } override fun onError(exception: ImageCaptureException) { super.onError(exception) onResult(Result.failure(exception)) } }) } // Helper: Convert ImageProxy to Bitmap private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap { val buffer = imageProxy.planes[0].buffer val bytes = ByteArray(buffer.capacity()) buffer.get(bytes) return BitmapFactory.decodeByteArray(bytes, 0, bytes.size) } private fun rotateBitmap(bitmap: Bitmap, rotationDegrees: Int): Bitmap { val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) }
原Compose遮罩代码
Box(modifier = Modifier .fillMaxSize() .drawWithContent { val canvasWidth = size.width val canvasHeight = size.height val width = canvasWidth * .97f val height = width * 3 / 4.5f drawContent() drawRect(Color(0x99000000)) // Semi-transparent overlay val newHeight = canvasHeight * 0.35f val newTopOffset = (canvasHeight - newHeight) / 2 // Transparent rectangle drawRoundRect( topLeft = Offset( (canvasWidth - width) / 2, newTopOffset ), size = Size(width, height), color = Color.Transparent, cornerRadius = CornerRadius(24.dp.toPx()), blendMode = BlendMode.SrcIn ) // White border drawRoundRect( topLeft = Offset( (canvasWidth - width) / 2, newTopOffset ), color = Color.White, size = Size(width, height), cornerRadius = CornerRadius(24.dp.toPx()), style = Stroke(width = 2.dp.toPx()), blendMode = BlendMode.Src ) } .background(Color.Black)) { AndroidView(modifier = Modifier.fillMaxSize(), factory = { PreviewView(it).apply { this.controller = controller controller.bindToLifecycle(lifecycleOwner) } }) }
核心问题
- 如何准确裁剪图片以匹配矩形遮罩的边界?
- 当前方案裁剪出的图片要么拉伸,要么包含遮罩外部分。
解决方案
1. 统一遮罩与裁剪的尺寸逻辑
先修正Compose遮罩代码的变量混乱问题,统一使用遮罩的宽高比(4.5:3),明确计算遮罩的位置和大小:
Box(modifier = Modifier .fillMaxSize() .drawWithContent { val canvasWidth = size.width val canvasHeight = size.height // 定义遮罩宽高比:4.5:3(和原代码逻辑一致) val maskAspectRatio = 4.5f / 3f // 遮罩宽度占屏幕宽度97% val maskWidth = canvasWidth * 0.97f // 根据比例计算遮罩高度 val maskHeight = maskWidth / maskAspectRatio // 计算遮罩的居中位置 val maskTop = (canvasHeight - maskHeight) / 2f val maskLeft = (canvasWidth - maskWidth) / 2f drawContent() // 绘制半透明背景遮罩 drawRect(Color(0x99000000)) // 绘制透明裁剪区域(挖空效果) drawRoundRect( topLeft = Offset(maskLeft, maskTop), size = Size(maskWidth, maskHeight), color = Color.Transparent, cornerRadius = CornerRadius(24.dp.toPx()), blendMode = BlendMode.SrcIn ) // 绘制白色边框 drawRoundRect( topLeft = Offset(maskLeft, maskTop), size = Size(maskWidth, maskHeight), color = Color.White, cornerRadius = CornerRadius(24.dp.toPx()), style = Stroke(width = 2.dp.toPx()), blendMode = BlendMode.Src ) } .background(Color.Black)) { AndroidView(modifier = Modifier.fillMaxSize(), factory = { PreviewView(it).apply { this.controller = controller controller.bindToLifecycle(lifecycleOwner) } }) }
2. 优化图像裁剪逻辑(适配相机分辨率与遮罩比例)
相机拍摄的图像分辨率和预览屏幕的分辨率比例可能不一致,需根据遮罩的宽高比,在拍摄图像中找到对应裁剪区域,同时修正imageProxyToBitmap方法以正确处理YUV格式图像:
override suspend fun onCapturePhoto( controller: LifecycleCameraController, onResult: (Result<Bitmap>) -> Unit ) { controller.takePicture(ContextCompat.getMainExecutor(application), object : ImageCapture.OnImageCapturedCallback() { override fun onCaptureSuccess(image: ImageProxy) { super.onCaptureSuccess(image) try { // 1. 正确转换ImageProxy为Bitmap(处理YUV_420_888格式) val bitmap = imageProxyToBitmap(image) // 2. 处理图像旋转,确保方向和屏幕一致 val rotationDegrees = image.imageInfo.rotationDegrees val correctedBitmap = rotateBitmap(bitmap, rotationDegrees) // 3. 定义遮罩的宽高比(和Compose中一致:4.5:3) val maskAspectRatio = 4.5f / 3f val imageWidth = correctedBitmap.width val imageHeight = correctedBitmap.height val imageAspectRatio = imageWidth.toFloat() / imageHeight.toFloat() // 4. 计算裁剪区域:让裁剪后的图像比例和遮罩一致 var cropWidth: Int var cropHeight: Int var cropX: Int var cropY: Int if (imageAspectRatio > maskAspectRatio) { // 图像比遮罩宽,裁剪左右两侧 cropHeight = imageHeight cropWidth = (imageHeight * maskAspectRatio).toInt() cropX = (imageWidth - cropWidth) / 2 cropY = 0 } else { // 图像比遮罩高,裁剪上下两侧 cropWidth = imageWidth cropHeight = (imageWidth / maskAspectRatio).toInt() cropX = 0 cropY = (imageHeight - cropHeight) / 2 } // 5. 确保裁剪区域不超出图像边界 cropX = cropX.coerceAtLeast(0) cropY = cropY.coerceAtLeast(0) cropWidth = cropWidth.coerceAtMost(imageWidth - cropX) cropHeight = cropHeight.coerceAtMost(imageHeight - cropY) // 6. 执行裁剪 val croppedBitmap = Bitmap.createBitmap( correctedBitmap, cropX, cropY, cropWidth, cropHeight ) onResult(Result.success(croppedBitmap)) } catch (e: Exception) { onResult(Result.failure(e)) } finally { image.close() } } override fun onError(exception: ImageCaptureException) { super.onError(exception) onResult(Result.failure(exception)) } }) } // 修正ImageProxy转Bitmap方法,支持YUV_420_888格式 private fun imageProxyToBitmap(imageProxy: ImageProxy): Bitmap { val image = imageProxy.image ?: throw IllegalArgumentException("ImageProxy has no image") val yBuffer = image.planes[0].buffer val uBuffer = image.planes[1].buffer val vBuffer = image.planes[2].buffer val ySize = yBuffer.remaining() val uSize = uBuffer.remaining() val vSize = vBuffer.remaining() val nv21 = ByteArray(ySize + uSize + vSize) yBuffer.get(nv21, 0, ySize) vBuffer.get(nv21, ySize, vSize) uBuffer.get(nv21, ySize + vSize, uSize) val yuvImage = YuvImage(nv21, ImageFormat.NV21, image.width, image.height, null) val outputStream = ByteArrayOutputStream() yuvImage.compressToJpeg(Rect(0, 0, image.width, image.height), 100, outputStream) val jpegBytes = outputStream.toByteArray() return BitmapFactory.decodeByteArray(jpegBytes, 0, jpegBytes.size) } private fun rotateBitmap(bitmap: Bitmap, rotationDegrees: Int): Bitmap { if (rotationDegrees == 0) return bitmap val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } return Bitmap.createBitmap(bitmap, 0, 0, bitmap.width, bitmap.height, matrix, true) }
关键优化点说明
- 统一比例:遮罩和裁剪逻辑使用相同的宽高比,避免比例不匹配导致的拉伸。
- 适配图像比例:根据拍摄图像的实际比例,动态计算裁剪区域,确保裁剪后的图像和遮罩比例一致。
- 修复图像转换:原方法只读取YUV图像的一个平面,导致图像颜色或尺寸异常,修正后完整处理YUV格式。
- 居中裁剪:无论图像比例是宽还是高,都从中心区域裁剪,确保和屏幕遮罩的可视区域对应。
内容的提问来源于stack exchange,提问作者Rabindra Acharya
相关产品推荐
相关产品推荐

