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

Android中使用CameraX无法正确裁剪指定矩形区域图像的问题

Android CameraX 预览框区域裁剪偏移问题

我尝试在Android中使用CameraX捕获预览界面矩形框内的图像,实现思路是计算屏幕与捕获图像的比例,将预览中可见的矩形映射到捕获图像的对应区域,再裁剪Bitmap。但裁剪后的结果图像位置偏下,需要解决这个偏移问题。

布局文件 fragment_scan_doc.xml

<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:id="@+id/clRoot"
    android:layout_width="match_parent"
    android:layout_height="match_parent">

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/gdl_left"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_begin="28dp" />


    <TextView
        android:id="@+id/tvTitle"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin_25dp"
        android:text="@string/app_name"
        android:textColor="@color/black"
        android:textSize="@dimen/text_size_24sp"
        android:textStyle="bold"
        app:layout_constraintEnd_toEndOf="@id/gdl_right"
        app:layout_constraintStart_toStartOf="@id/gdl_left"
        app:layout_constraintTop_toTopOf="parent" />


    <TextView
        android:id="@+id/tvDesc"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginTop="@dimen/margin_16dp"
        android:text="@string/place_doc"
        android:textColor="@color/black"
        app:layout_constraintEnd_toEndOf="@+id/gdl_right"
        app:layout_constraintStart_toStartOf="@+id/gdl_left"
        app:layout_constraintTop_toBottomOf="@+id/tvTitle" />

    <androidx.camera.view.PreviewView
        android:id="@+id/previewView"
        android:layout_width="0dp"
        android:layout_height="0dp"
        android:layout_marginTop="@dimen/dimen_24dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@id/tvDesc" />

    <TextView
        android:id="@+id/tvCamera"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="13dp"
        android:background="@drawable/bg_round_white_solid_yellow_stripe"
        android:gravity="center"
        android:paddingStart="@dimen/dimen_25dp"
        android:paddingTop="@dimen/dimen_10dp"
        android:paddingEnd="@dimen/dimen_25dp"
        android:paddingBottom="@dimen/dimen_10dp"
        android:text="Front"
        android:textColor="@color/black"
        app:layout_constraintEnd_toEndOf="@id/gdl_right"
        app:layout_constraintStart_toStartOf="@id/gdl_left"
        app:layout_constraintTop_toTopOf="@+id/previewView" />

    <View
        android:id="@+id/border_view"
        android:layout_width="325dp"
        android:layout_height="202.5010436dp"
        android:background="@drawable/bg_round_grey_stripe"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="@id/gdl_right"
        app:layout_constraintStart_toStartOf="@id/gdl_left"
        app:layout_constraintTop_toTopOf="parent" />

    <TextView
        android:id="@+id/tvPoweredBy"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginTop="15dp"
        android:text="Powered by"
        android:textColor="@color/white"
        app:layout_constraintEnd_toEndOf="@id/gdl_right"
        app:layout_constraintStart_toStartOf="@id/gdl_left"
        app:layout_constraintTop_toBottomOf="@+id/border_view" />

    <View
        android:id="@+id/vTakePhoto"
        android:layout_width="80dp"
        android:layout_height="80dp"
        android:layout_marginBottom="20dp"
        android:background="@drawable/bg_round_white_solid_stripe"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
         />

    <ImageView
        android:id="@+id/ivFlash"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:layout_marginStart="40dp"
        android:background="@drawable/bg_circle_white_solid"
        android:padding="10dp"
        android:src="@drawable/ic_baseline_flash_off_24"
        app:layout_constraintBottom_toBottomOf="@+id/vTakePhoto"
        app:layout_constraintStart_toEndOf="@+id/vTakePhoto"
        app:layout_constraintTop_toTopOf="@+id/vTakePhoto" />

    <ProgressBar
        android:id="@+id/progressBar"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:elevation="50dp"
        android:visibility="gone"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent" />

    <androidx.constraintlayout.widget.Guideline
        android:id="@+id/gdl_right"
        android:layout_width="wrap_content"
        android:layout_height="wrap_content"
        android:orientation="vertical"
        app:layout_constraintGuide_end="28dp" />


</androidx.constraintlayout.widget.ConstraintLayout>

拍照与图像捕获代码

private fun takePhoto() {
    imageCapture?.takePicture(ContextCompat.getMainExecutor(requireContext()),
        object : ImageCapture.OnImageCapturedCallback() {
            @SuppressLint("UnsafeOptInUsageError")
            override fun onCaptureSuccess(imageProxy: ImageProxy) {
                val image = imageProxy.image?.let { image ->
                    InputImage.fromMediaImage(
                        image,
                        imageProxy.imageInfo.rotationDegrees
                    )
                }
                cropImage(imageProxy)
            }

            override fun onError(exception: ImageCaptureException) {
                exception.printStackTrace()
            }
        })
}

裁剪函数代码(原实现)

private fun cropImage(imageProxy: ImageProxy) {
    val ori = imageProxy.convertImageProxyToBitmap().rotate(90f)

    val rect = Rect()
    mViewBinding.borderView.getGlobalVisibleRect(rect)

    val screenWidth = mViewBinding.root.width
    val screenHeight = mViewBinding.root.height

    val ratioW = screenWidth.toFloat() / ori.width // 计算屏幕与图像宽度的比例
    val ratioH = screenHeight.toFloat() / ori.height

    val x1 = rect.left / ratioW
    val y1 = rect.top / ratioH
    val x1W = rect.right / ratioW
    val x1H = rect.bottom / ratioH

    val rect1 = Rect(x1.toInt(), y1.toInt(), x1W.toInt(), x1H.toInt())

    val cropedBm = Bitmap.createBitmap(
        ori,
        rect1.left,
        rect1.top,
        rect1.right - rect1.left,
        rect1.bottom - rect1.top
    )
    processNewBitmap(cropedBm) // 显示裁剪后的Bitmap,结果位置偏下
}

问题原因与解决方案

问题根源

  1. 硬编码旋转图像:直接将图像旋转90度,未考虑imageProxy.imageInfo.rotationDegrees的实际值,不同设备传感器方向不同会导致坐标映射错误。
  2. 比例计算错误:用屏幕整体宽高计算比例,未考虑PreviewView按相机比例缩放后可能存在的黑边,导致坐标映射偏差。
  3. 坐标转换错误:getGlobalVisibleRect获取的是屏幕绝对坐标,未转换为相对于PreviewView的相对坐标就进行映射。

修正后的裁剪函数

private fun cropImage(imageProxy: ImageProxy) {
    // 1. 按实际旋转角度处理图像
    val rotationDegrees = imageProxy.imageInfo.rotationDegrees
    val oriBitmap = imageProxy.convertImageProxyToBitmap()
    val rotatedBitmap = when (rotationDegrees) {
        90 -> oriBitmap.rotate(90f)
        180 -> oriBitmap.rotate(180f)
        270 -> oriBitmap.rotate(270f)
        else -> oriBitmap
    }

    // 2. 获取PreviewView在屏幕中的区域
    val previewView = mViewBinding.previewView
    val previewRect = Rect()
    previewView.getGlobalVisibleRect(previewRect)

    // 3. 将边框坐标转换为PreviewView内部的相对坐标
    val borderRect = Rect()
    mViewBinding.borderView.getGlobalVisibleRect(borderRect)
    val relativeLeft = borderRect.left - previewRect.left
    val relativeTop = borderRect.top - previewRect.top
    val relativeRight = borderRect.right - previewRect.left
    val relativeBottom = borderRect.bottom - previewRect.top

    // 4. 计算预览与捕获图像的正确映射比例
    val previewWidth = previewView.width.toFloat()
    val previewHeight = previewView.height.toFloat()
    val imageWidth = rotatedBitmap.width.toFloat()
    val imageHeight = rotatedBitmap.height.toFloat()

    val scaleX = imageWidth / previewWidth
    val scaleY = imageHeight / previewHeight

    // 5. 计算图像中的目标裁剪区域
    val cropLeft = (relativeLeft * scaleX).toInt()
    val cropTop = (relativeTop * scaleY).toInt()
    val cropRight = (relativeRight * scaleX).toInt()
    val cropBottom = (relativeBottom * scaleY).toInt()

    // 确保裁剪区域在图像范围内
    val safeLeft = cropLeft.coerceAtLeast(0)
    val safeTop = cropTop.coerceAtLeast(0)
    val safeRight = cropRight.coerceAtMost(rotatedBitmap.width)
    val safeBottom = cropBottom.coerceAtMost(rotatedBitmap.height)

    // 6. 执行裁剪
    val cropedBm = Bitmap.createBitmap(
        rotatedBitmap,
        safeLeft,
        safeTop,
        safeRight - safeLeft,
        safeBottom - safeTop
    )
    processNewBitmap(cropedBm)

    // 释放资源避免内存泄漏
    imageProxy.close()
    oriBitmap.recycle()
    rotatedBitmap.recycle()
}

额外优化建议

  • 确保convertImageProxyToBitmap方法正确处理YUV到RGB的转换,避免图像失真。
  • 在PreviewView的SurfaceProvider回调中获取预览实际尺寸,进一步提升坐标映射精度。
  • 裁剪后及时回收Bitmap资源。

内容的提问来源于stack exchange,提问作者theanilpaudel

相关产品推荐
方舟 Agent Plan

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

最近更新时间:2026.08.18 02:35:19