You need to enable JavaScript to run this app.
最新活动
大模型
产品
解决方案
定价
生态与合作
支持与服务
开发者
了解我们

Android中基于CameraX实现文档扫描的图像透视变换问题

嘿,兄弟,我前阵子刚基于CameraX做过一个文档扫描的Android应用,正好踩过这些坑,给你详细说下怎么实现你要的功能!

一、透视变换核心实现步骤

1. 生成透视变换矩阵

你已经拿到了文档的四个角坐标(假设是PointF[] srcPoints,对应文档左上、右上、右下、左下四个点),接下来需要把这些点映射到一个规整的矩形目标区域(比如你想要的矫正后文档尺寸,PointF[] dstPoints对应新Bitmap的四个角)。

用Android自带的Matrix类就能轻松生成3x3透视变换矩阵,不用手动推导矩阵元素,高效又靠谱:

// 假设矫正后的文档宽高为targetWidth和targetHeight
PointF[] dstPoints = new PointF[]{
    new PointF(0, 0),
    new PointF(targetWidth, 0),
    new PointF(targetWidth, targetHeight),
    new PointF(0, targetHeight)
};

Matrix perspectiveMatrix = new Matrix();
// setPolyToPoly自动计算透视变换矩阵,参数依次是源点、源点偏移、目标点、目标点偏移、点数量(最多4个)
perspectiveMatrix.setPolyToPoly(srcPoints, 0, dstPoints, 0, 4);

要是你非要手动计算3x3矩阵,原理是解线性方程组求8个未知参数(最后一个元素固定为1),但系统API已经帮我们做了优化,强烈推荐直接用上面的方法。

2. 图像透视变换的两种实现方式

方式一:手动像素级矩阵运算(按你的需求)

如果坚持要对每个像素执行矩阵乘法,可以这样做:先把Bitmap转成像素数组,遍历每个目标像素位置,反向查找原始图像中对应的像素(正向映射容易出现图像空洞,反向更可靠),再赋值颜色:

// 原始捕获的Bitmap
Bitmap originalBitmap = ...;
int originalWidth = originalBitmap.getWidth();
int originalHeight = originalBitmap.getHeight();

// 创建矫正后的空白Bitmap
Bitmap correctedBitmap = Bitmap.createBitmap(targetWidth, targetHeight, originalBitmap.getConfig());

// 获取原始图像像素数组
int[] originalPixels = new int[originalWidth * originalHeight];
originalBitmap.getPixels(originalPixels, 0, originalWidth, 0, 0, originalWidth, originalHeight);

// 初始化矫正后图像像素数组
int[] correctedPixels = new int[targetWidth * targetHeight];

// 提取透视矩阵的9个元素
float[] matrixValues = new float[9];
perspectiveMatrix.getValues(matrixValues);

// 遍历矫正后图像的每个像素,反向映射到原始图像
for (int y = 0; y < targetHeight; y++) {
    for (int x = 0; x < targetWidth; x++) {
        // 用齐次坐标计算原始图像中的对应位置
        float srcX = matrixValues[0] * x + matrixValues[1] * y + matrixValues[2];
        float srcY = matrixValues[3] * x + matrixValues[4] * y + matrixValues[5];
        float scale = matrixValues[6] * x + matrixValues[7] * y + matrixValues[8];
        
        // 转换为原始图像的整数坐标
        int originalX = Math.round(srcX / scale);
        int originalY = Math.round(srcY / scale);
        
        // 检查坐标是否在原始图像有效范围内
        if (originalX >= 0 && originalX < originalWidth && originalY >= 0 && originalY < originalHeight) {
            correctedPixels[y * targetWidth + x] = originalPixels[originalY * originalWidth + originalX];
        } else {
            // 超出范围的区域设为白色
            correctedPixels[y * targetWidth + x] = Color.WHITE;
        }
    }
}

// 将像素数组写入矫正后的Bitmap
correctedBitmap.setPixels(correctedPixels, 0, targetWidth, 0, 0, targetWidth, targetHeight);

方式二:系统Canvas绘制(高效推荐)

实际开发中更推荐用Canvas绘制,系统做了底层优化,比手动遍历像素快得多:

Bitmap correctedBitmap = Bitmap.createBitmap(targetWidth, targetHeight, originalBitmap.getConfig());
Canvas canvas = new Canvas(correctedBitmap);
// 直接用变换矩阵绘制原始Bitmap,自动完成透视变换
canvas.drawBitmap(originalBitmap, perspectiveMatrix, null);
二、点击捕获按钮的触发逻辑

核心是保存最新的文档四角坐标,确保捕获时用的是实时检测到的位置:

  1. 先定义变量存储最新角坐标:
private PointF[] latestDocCorners;

// 当四角检测逻辑更新坐标时,同步更新这个变量(注意克隆避免引用修改)
public void updateLatestDocCorners(PointF[] corners) {
    this.latestDocCorners = corners.clone();
}
  1. 设置捕获按钮的点击事件:
captureButton.setOnClickListener(v -> {
    if (latestDocCorners == null || latestDocCorners.length != 4) {
        Toast.makeText(this, "请先对准文档,检测到四角后再捕获", Toast.LENGTH_SHORT).show();
        return;
    }
    
    // 触发CameraX图像捕获
    imageCapture.takePicture(
        ContextCompat.getMainExecutor(this),
        new ImageCapture.OnImageCapturedCallback() {
            @Override
            public void onCaptureSuccess(@NonNull ImageProxy image) {
                // 将ImageProxy转换为Bitmap
                Bitmap originalBitmap = imageProxyToBitmap(image);
                image.close();
                
                // 用最新的角坐标执行透视变换
                Bitmap correctedBitmap = performPerspectiveTransform(originalBitmap, latestDocCorners);
                
                // 后续操作:保存到本地/显示在界面上
                saveCorrectedBitmap(correctedBitmap);
                showCorrectedImage(correctedBitmap);
            }

            @Override
            public void onError(@NonNull ImageCaptureException exception) {
                Toast.makeText(MainActivity.this, "捕获失败:" + exception.getMessage(), Toast.LENGTH_SHORT).show();
            }
        }
    );
});
  1. 补充ImageProxyBitmap的工具方法:
private Bitmap imageProxyToBitmap(ImageProxy image) {
    Image.Plane[] planes = image.getPlanes();
    ByteBuffer yBuffer = planes[0].getBuffer();
    ByteBuffer uBuffer = planes[1].getBuffer();
    ByteBuffer vBuffer = planes[2].getBuffer();

    int ySize = yBuffer.remaining();
    int uSize = uBuffer.remaining();
    int vSize = vBuffer.remaining();

    byte[] nv21 = new byte[ySize + uSize + vSize];
    yBuffer.get(nv21, 0, ySize);
    vBuffer.get(nv21, ySize, vSize);
    uBuffer.get(nv21, ySize + vSize, uSize);

    YuvImage yuvImage = new YuvImage(nv21, ImageFormat.NV21, image.getWidth(), image.getHeight(), null);
    ByteArrayOutputStream out = new ByteArrayOutputStream();
    yuvImage.compressToJpeg(new Rect(0, 0, image.getWidth(), image.getHeight()), 100, out);
    byte[] imageBytes = out.toByteArray();
    return BitmapFactory.decodeByteArray(imageBytes, 0, imageBytes.length);
}

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

火山引擎 最新活动