You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.15 16:42:06