magicCamera — 程序员文档

本文档面向开发爱好者,说明项目的技术架构、算法实现和开发流程。


效果演示

在这里插入图片描述

📋 项目概述

magicCamera 是一个 Android 应用,利用摄像头、OpenCV 图像处理和 CameraX 框架,实现实时卡牌检测和 AR 替换。

核心功能

  • 📷 实时卡牌检测:使用 Canny 边界检测和图像处理算法检测视野中的卡牌
  • 🎴 AR 卡牌替换:将检测到的卡牌实时替换为指定的卡牌图像
  • 🎯 交互式选牌流程:通过屏幕触摸进行两步选牌(先选花色,再选点数)
  • ⚙️ 动态参数调节:长按快门按钮呼出参数面板,实时调整检测算法参数
  • 🔄 双摄像头支持:前后摄像头切换,每个摄像头独立保存参数配置

🏗️ 技术架构

核心库和框架

库/框架 用途
CameraX 相机捕获和预览管理
OpenCV 图像处理和卡牌检测
Kotlin Coroutines 异步任务处理
AndroidX AppCompat Material Design UI 组件
SharedPreferences 参数持久化存储

核心类结构

MainActivity.kt
├── 相机管理
│   ├── ProcessCameraProvider
│   ├── CameraSelector (前/后)
│   └── ImageAnalysis
│
├── 图像处理
│   ├── imageProxyToNv21()          [YUV → NV21 转换]
│   ├── normalizeFrame()             [颜色空间转换和旋转]
│   └── detectObjectContours()       [核心检测逻辑]
│
├── 卡牌替换
│   ├── replaceDetectedCard()        [透视变换]
│   └── loadSelectedCardImage()      [加载卡牌图片]
│
├── UI 交互
│   ├── shutterBtn.setOnClickListener()
│   ├── switchCameraBtn.setOnClickListener()
│   ├── detectionView.setOnTouchListener()
│   └── showSettingsDialog()         [参数调节面板]
│
└── 状态管理
    └── magicState (0=待机, 1=选花色, 2=选点数, 3=激活贴图)

多线程架构

┌─────────────────────────────────────┐
│         Main Thread (UI)             │
│  - UI 渲染                           │
│  - 用户交互处理                      │
└────────────┬────────────────────────┘
             │
    ┌────────┴────────┐
    │                 │
┌───▼──────┐    ┌────▼────────────┐
│ Processing    │ CameraExecutor   │
│ Thread        │                  │
│ (HandlerThread)│ (Executors)     │
│               │                  │
│ - OpenCV 处理 │ - 相机帧捕获    │
│ - Mat 变换    │ - ImageProxy    │
└────────────┘ └─────────────────┘

三层执行模型

  1. CameraExecutor → ImageAnalysis 获取相机帧
  2. ProcessingHandler → 后台线程执行 OpenCV 算法
  3. MainThread → UI 线程更新预览和参数

🔍 图像处理管线

完整流程

ImageProxy (NV21)
    ↓
imageProxyToNv21() ─── YUV420 格式转 NV21 字节数组
    ↓
normalizeFrame()  ─── 颜色空间转换 + 旋转 + 镜像
    ├─ CVTColor: YUV2RGB_NV21
    ├─ Core.rotate(ROTATE_90_CLOCKWISE)
    └─ Core.flip (前置摄像头)
    ↓
detectObjectContours()
    ├─ CVTColor: RGB2GRAY
    ├─ GaussianBlur
    ├─ Canny
    ├─ MorphologyEx (MORPH_CLOSE)
    ├─ FindContours
    └─ 卡牌识别与选择
    ↓
replaceDetectedCard()
    ├─ Perspective Transform
    ├─ WarpPerspective
    └─ Mask Copy
    ↓
Mat → Bitmap → ImageView

关键函数详解

1. imageProxyToNv21(imageProxy: ImageProxy): ByteArray

目的:将 ImageProxy 转换为 NV21 格式字节数组

private fun imageProxyToNv21(image: ImageProxy): ByteArray {
    val yBuffer = image.planes[0].buffer      // Y plane
    val uBuffer = image.planes[1].buffer      // U plane
    val vBuffer = image.planes[2].buffer      // V plane
    
    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)           // V 先
    uBuffer.get(nv21, ySize + vSize, uSize)   // U 后 (NV21 = NV12 + V/U 交换)
    return nv21
}

关键点

  • NV21 格式:Y plane + (V, U 交错)
  • 必须按照 V, U 顺序放置,不是 U, V
2. normalizeFrame(data: ByteArray): Mat

目的:标准化帧(颜色转换、旋转、镜像)

private fun normalizeFrame(data: ByteArray): Mat {
    val yuv = Mat(previewHeight + previewHeight / 2, previewWidth, CvType.CV_8UC1)
    yuv.put(0, 0, data)
    
    val rgb = Mat()
    Imgproc.cvtColor(yuv, rgb, Imgproc.COLOR_YUV2RGB_NV21)  // NV21 → RGB
    yuv.release()
    
    val rotated = Mat()
    Core.rotate(rgb, rotated, Core.ROTATE_90_CLOCKWISE)     // 旋转 90°
    rgb.release()
    
    if (lensFacing == CameraSelector.LENS_FACING_FRONT) {
        Core.flip(rotated, rotated, 0)                       // 前置镜像
    }
    return rotated
}

关键点

  • CameraX 提供的帧默认是 90° 旋转的
  • 前置摄像头需要镜像(flip axis 0 = 竖直翻转)
3. detectObjectContours(data: ByteArray) — 核心检测逻辑

流程

// 1. 颜色转换
Imgproc.cvtColor(frame, gray, Imgproc.COLOR_RGB2GRAY)

// 2. 高斯模糊(去噪)
Imgproc.GaussianBlur(gray, blurred, Size(blurSize, blurSize), 0.0)

// 3. Canny 边界检测
Imgproc.Canny(blurred, edges, cannyLower, cannyUpper)

// 4. 形态学闭运算(补缝)
val kernel = Imgproc.getStructuringElement(Imgproc.MORPH_RECT, Size(morphKernelSize, morphKernelSize))
Imgproc.morphologyEx(edges, edges, Imgproc.MORPH_CLOSE, kernel)

// 5. 轮廓提取
val contours = mutableListOf<MatOfPoint>()
Imgproc.findContours(edges, contours, hierarchy, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)

// 6. 卡牌识别(选择最佳轮廓)
var bestContour: MatOfPoint? = selectBestContour(contours)

// 7. 角点提取
val (tl, tr, br, bl) = extractCorners(bestContour)

// 8. 替换卡牌(如果状态为 3)
if (magicState == 3) {
    replaceDetectedCard(frame, tl, tr, br, bl)
}

卡牌识别算法

前置摄像头策略(中心优先)
if (lensFacing == CameraSelector.LENS_FACING_FRONT) {
    val dist = hypot(minRect.center.x - frameCenterX, minRect.center.y - frameCenterY)
    if (dist < minCenterDist) {
        minCenterDist = dist
        bestContour = contour
    }
}

原理:前置摄像头通常只能看到一张卡牌,选择离画面中心最近的轮廓。

后置摄像头策略(形状+面积筛选)
if (lensFacing == CameraSelector.LENS_FACING_BACK) {
    val w = minRect.size.width
    val h = minRect.size.height
    
    val ratio = w.coerceAtLeast(h) / w.coerceAtMost(h)        // 宽高比
    val rectArea = w * h
    val extent = area / rectArea                              // 面积填充度
    
    // 条件:宽高比 1.3~1.9(卡牌比例)+ 填充度 > 73%
    if (ratio in 1.3..1.9 && extent > 0.73) {
        if (area > maxArea) {
            maxArea = area
            bestContour = contour
        }
    }
}

原理:后置摄像头可能看到多张卡牌,需要形状和面积双重筛选。

角点提取算法

前置摄像头(使用和与差)
var tl = points[0]; var tr = points[0]; var bl = points[0]; var br = points[0]
var minSum = Double.MAX_VALUE; var maxDiff = -Double.MAX_VALUE; var minDiff = Double.MAX_VALUE

for (p in points) {
    val sum = p.x + p.y      // x + y:左上最小,右下最大
    val diff = p.x - p.y     // x - y:右上最大,左下最小
    
    if (sum < minSum) { minSum = sum; tl = p }      // 左上
    if (diff > maxDiff) { maxDiff = diff; tr = p }  // 右上
    if (diff < minDiff) { minDiff = diff; bl = p }  // 左下
}
br = Point(tr.x + bl.x - tl.x, tr.y + bl.y - tl.y) // 由对角线推导

原理

  • tltltl: x+yx + yx+y 最小
  • trtrtr: x−yx - yxy 最大
  • blblbl: x−yx - yxy 最小
  • brbrbr: 平行四边形性质推导
后置摄像头(标准四点排序)
var tl = points[0]; var tr = points[0]; var br = points[0]; var bl = points[0]
var minSum = Double.MAX_VALUE; var maxSum = -Double.MAX_VALUE
var maxDiff = -Double.MAX_VALUE; var minDiff = Double.MAX_VALUE

for (p in points) {
    val sum = p.x + p.y
    val diff = p.x - p.y
    
    if (sum < minSum) { minSum = sum; tl = p }      // 左上
    if (sum > maxSum) { maxSum = sum; br = p }      // 右下
    if (diff > maxDiff) { maxDiff = diff; tr = p }  // 右上
    if (diff < minDiff) { minDiff = diff; bl = p }  // 左下
}

透视变换和替换

private fun replaceDetectedCard(frame: Mat, tl: Point, tr: Point, br: Point, bl: Point) {
    val srcMat = replacementCardMat!!
    val w = srcMat.cols().toDouble()
    val h = srcMat.rows().toDouble()
    
    // 源图片的四个角
    val srcPts = MatOfPoint2f(
        Point(0.0, 0.0), Point(w, 0.0), Point(w, h), Point(0.0, h)
    )
    
    // 目标图片的四个角(检测到的卡牌位置)
    val dstPts = MatOfPoint2f(tl, tr, br, bl)
    
    // 计算透视变换矩阵
    val transformMatrix = Imgproc.getPerspectiveTransform(srcPts, dstPts)
    
    // 应用变换
    val warpedCard = Mat()
    Imgproc.warpPerspective(srcMat, warpedCard, transformMatrix, frame.size())
    
    // 创建遮罩(只显示卡牌区域)
    val mask = Mat.zeros(frame.size(), CvType.CV_8UC1)
    val maskPolygon = listOf(MatOfPoint(tl, tr, br, bl))
    Imgproc.fillPoly(mask, maskPolygon, Scalar(255.0))
    
    // 合成到原图
    warpedCard.copyTo(frame, mask)
    
    // 清理
    warpedCard.release()
    mask.release()
    transformMatrix.release()
}

🎮 魔术状态机

状态转移图

      ┌──────────┐
      │   状态0   │ ◄──────┐
      │   待机     │        │
      │  不贴图    │        │
      └────┬─────┘        │
           │ 短按快门      │
           ▼              │
      ┌──────────┐        │
      │   状态1   │        │
      │  选花色   │        │
      │ 2×2网格   │        │
      └────┬─────┘        │
           │ 触摸选花色    │
           ▼              │
      ┌──────────┐        │
      │   状态2   │        │
      │  选点数   │        │
      │ 3×5网格   │        │
      └────┬─────┘        │
           │ 触摸选点数    │
           ▼              │
      ┌──────────┐        │
      │   状态3   │        │
      │ 激活贴图  │        │
      │ 实时替换  │        │
      └────┬─────┘        │
           │              │
           └──────────────┘

状态定义

private var magicState = 0
// 0 = 待机 (不贴图)
// 1 = 选花色中
// 2 = 选点数中
// 3 = 激活贴图

private var selectedSuit = -1    // 0=黑桃, 1=红心, 2=梅花, 3=方块
private var selectedRank = -1    // 1~12 = A~12, 13 = K

卡牌 ID 计算

finalCardId = selectedSuit * 13 + selectedRank

范围:0~51(52 张牌)
黑桃:0~12    (A~K)
红心:13~25   (A~K)
梅花:26~38   (A~K)
方块:39~51   (A~K)

💾 参数持久化

SharedPreferences 设计

private fun getPrefPrefix(): String = 
    if (lensFacing == CameraSelector.LENS_FACING_FRONT) "front_" else "back_"

private fun loadParamsForCurrentCamera() {
    val prefix = getPrefPrefix()
    blurSize = sharedPrefs.getFloat(prefix + "blurSize", 13.0f).toDouble()
    cannyLower = sharedPrefs.getFloat(prefix + "cannyLower", 0.0f).toDouble()
    cannyUpper = sharedPrefs.getFloat(prefix + "cannyUpper", 150.0f).toDouble()
    morphKernelSize = sharedPrefs.getFloat(prefix + "morphKernelSize", 30.0f).toDouble()
}

private fun saveParamsToLocal() {
    val prefix = getPrefPrefix()
    sharedPrefs.edit().apply {
        putFloat(prefix + "blurSize", blurSize.toFloat())
        putFloat(prefix + "cannyLower", cannyLower.toFloat())
        putFloat(prefix + "cannyUpper", cannyUpper.toFloat())
        putFloat(prefix + "morphKernelSize", morphKernelSize.toFloat())
        apply()
    }
}

存储键

  • front_blurSize, front_cannyLower, front_cannyUpper, front_morphKernelSize
  • back_blurSize, back_cannyLower, back_cannyUpper, back_morphKernelSize

🔧 防闪烁机制

缓存机制

private var lastTl: Point? = null
private var lastTr: Point? = null
private var lastBl: Point? = null
private var lastBr: Point? = null
private var missedFrames = 1
private val MAX_MISSED_FRAMES = 3

// 检测失败时的处理
if (bestContour != null) {
    lastTl = tl; lastTr = tr; lastBl = bl; lastBr = br
    missedFrames = 0
} else {
    if (lastTl != null && missedFrames < MAX_MISSED_FRAMES) {
        missedFrames++
        if (magicState == 3) replaceDetectedCard(frame, lastTl!!, lastTr!!, lastBr!!, lastBl!!)
    } else {
        clearCache()
    }
}

原理

  • 连续检测失败最多 3 帧
  • 使用最后一次成功的卡牌位置继续替换
  • 保证 AR 贴图的连贯性

📦 项目结构

magicCamera/
├── app/
│   ├── src/main/
│   │   ├── java/com/yuer/magicCamera/
│   │   │   └── MainActivity.kt              # 主应用文件(完整实现)
│   │   ├── res/
│   │   │   ├── drawable/
│   │   │   │   ├── card_0.png ~ card_51.png # 52 张卡牌图片
│   │   │   │   └── ...
│   │   │   ├── layout/
│   │   │   │   └── activity_main.xml       # UI 布局
│   │   │   └── values/
│   │   │       └── strings.xml             # 字符串资源
│   │   └── AndroidManifest.xml
│   ├── build.gradle.kts                    # App 模块配置
│   └── proguard-rules.pro
├── gradle/
│   └── libs.versions.toml                  # 依赖版本管理
├── build.gradle.kts                        # 根项目配置
├── settings.gradle.kts
├── gradlew / gradlew.bat
├── gradle.properties
├── README.md                               # 普通用户文档
├── DEVELOPER.md                            # 本文件
└── local.properties
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐