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);
二、点击捕获按钮的触发逻辑
核心是保存最新的文档四角坐标,确保捕获时用的是实时检测到的位置:
- 先定义变量存储最新角坐标:
private PointF[] latestDocCorners; // 当四角检测逻辑更新坐标时,同步更新这个变量(注意克隆避免引用修改) public void updateLatestDocCorners(PointF[] corners) { this.latestDocCorners = corners.clone(); }
- 设置捕获按钮的点击事件:
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(); } } ); });
- 补充
ImageProxy转Bitmap的工具方法:
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




