Files
yola/Pink/docs/技术实现方案.md
T
coco 88c0fbefef a
2026-07-14 09:44:44 +08:00

20 KiB
Raw Blame History

排卵试纸识别技术实现方案

版本: 1.0 | 日期: 2026-07-13


一、总体架构

┌──────────────────────────────────────────────────────────────┐
│  CameraX ImageAnalysis (RGBA_8888, 每帧回调)                  │
│  ↓                                                             │
│  OvImageAnalyzer.analyze(ImageProxy)                            │
│    ├─ ImageProxy → OpenCV Mat (RGBA)                            │
│    ├─ StripLocator.locate() → 试纸定位                          │
│    │    ├─ 找到两个绿色定位块                                     │
│    │    ├─ 旋转矫正                                             │
│    │    ├─ 找到 C线 / T线 / 白块 区域                             │
│    │    └─ 在原图上绘制标记框(无论成功与否都绘制)                  │
│    ├─ 定位成功 → FeatureExtractor.extract() → 60维特征向量        │
│    │    └─ 存入内存数组 (容量: 15)                                │
│    ├─ Mat → Bitmap → 返回 UI 显示                                │
│    └─ 异步处理: 上一帧未处理完 → 跳过当前帧(直接返回原图)          │
│                                                                  │
│  当累积到 15 组特征:                                              │
│    ├─ 每组调用 OvRandomForest.score() → 5分类概率 → argmax        │
│    ├─ 15个结果取众数 (mode) → 最终结果                             │
│    ├─ 停止摄像头采集                                              │
│    └─ UI 显示最终结果                                             │
└──────────────────────────────────────────────────────────────┘

分类结果映射

index 含义 对应 hCG 浓度
0 阴性 0
1 也算阴性 10
2 阳性 25
3 阳性 50
4 强阳 75, 100

二、项目模块划分

app/src/main/java/com/pinkbear/pinkov/
├── opencv/                              # OpenCV 3.0 Java 封装
│   └── org/opencv/
│       ├── core/        (Mat, Scalar, Rect, Point, Size, Core, CvType)
│       ├── imgproc/     (Imgproc)
│       └── android/     (Utils — matToBitmap / bitmapToMat)
├── processing/
│   ├── ImageConverter.kt                # ImageProxy ↔ Mat ↔ Bitmap
│   ├── StripLocator.kt                  # 试纸定位(核心)
│   └── FeatureExtractor.kt              # 60维特征提取
├── classifier/
│   └── OvRandomForest.kt                # 随机森林分类器
├── ui/
│   ├── scan/
│   │   ├── OvImageAnalyzer.kt           # CameraX 分析器
│   │   ├── ScanPage.kt                  # 扫描页面
│   │   └── ScanViewModel.kt             # 扫描状态管理
│   └── ...

三、模块详细设计

3.1 OpenCV 集成

来源: prostate_DataAcq1/openCVLibrary300/src/main/java/org/opencv/

操作: 将 org.opencv.core, org.opencv.imgproc, org.opencv.android.Utils 的 Java 源码复制到 Pink 项目。

不需要的包(Camera部分,由 CameraX 替代):

  • org.opencv.android.CameraBridgeViewBase
  • org.opencv.android.JavaCameraView

不需要 Gradle 依赖,纯 Java 源码集成。


3.2 图像格式转换 (ImageConverter.kt)

object ImageConverter {
    /**
     * CameraX ImageProxy (RGBA_8888) → OpenCV Mat (CV_8UC4)
     */
    fun imageProxyToMat(image: ImageProxy): Mat {
        val buffer = image.planes[0].buffer
        val bytes = ByteArray(buffer.remaining())
        buffer.get(bytes)
        val mat = Mat(image.height, image.width, CvType.CV_8UC4)
        mat.put(0, 0, bytes)
        return mat
    }

    /**
     * OpenCV Mat (RGBA) → Android Bitmap
     */
    fun matToBitmap(mat: Mat): Bitmap {
        val bitmap = Bitmap.createBitmap(mat.width(), mat.height(), Bitmap.Config.ARGB_8888)
        Utils.matToBitmap(mat, bitmap)
        return bitmap
    }
}

3.3 试纸定位器 (StripLocator.kt)

仅支持绿色排卵试纸。基于 ImageLocationOvulation.GetPositions() 移植。

3.3.1 数据结构

data class LocationResult(
    val success: Boolean,
    val leftImg: Mat?,           // C线区域 (RGB)
    val rightImg: Mat?,          // T线区域 (RGB), 阴性时为 null
    val wbImg: Mat?,             // 白色参考块 (RGB)
    val isNegative: Boolean,     // 是否阴性(只有 C 线)
    val definition: Double,      // 清晰度评分
    val errorCode: Int,          // 0=成功, 非0=失败
    val annotatedMat: Mat        // 绘制了标记框的结果图像
)

3.3.2 定位算法 (共 11 步)

输入: Mat (RGBA, 完整相机帧), 无 ROI 限制

步骤1: 预处理
  RGBA → RGB (丢弃 Alpha 通道)
  中值滤波 (5x5, 核大小 5)

步骤2: 找到两个绿色定位块
  RGB → HSV (COLOR_RGB2HSV)
  提取 S 通道
  OTSU 二值化 (THRESH_BINARY | THRESH_OTSU)
  形态学开运算 (MORPH_OPEN, 核大小 15x15)

步骤3: 验证定位块
  查找轮廓 (RETR_EXTERNAL, CHAIN_APPROX_SIMPLE)
  期望: 恰好 2 个轮廓
  验证面积比: 1.5 ≤ maxArea / secondArea ≤ 4.5
  验证宽高比: 4.25 ≤ whRatio ≤ 13.5
  验证边界: 不超出图像范围

步骤4: 判定试纸颜色 (仅接受绿色)
  在大定位块中心取正方形区域 (边长 = 半高)
  转 HSV, 计算 H 通道均值 meanH 和标准差 stddevH
  条件: stddevH < 22.5 且 meanH ∈ [30.5, 85.0]
  不满足 → 返回失败 (errorCode = -1)

步骤5: 计算旋转角度
  确定大定位块在左还是右
  计算旋转角度 angle
  计算中心点距离 distRadio
  距离比例系数: 1.0 ~ 1.65
    动态调整偏移系数: 1.1 ~ 1.25

步骤6: 旋转矫正
  构建旋转矩形 ROI (覆盖两定位块之间的白色区域)
  getRotationMatrix2D + warpAffine
  提取矫正后 G 通道

步骤7: 找到 C 线和 T 线
  G 通道: 中值滤波(3x3) + 高斯模糊(5x5)
  自适应二值化 (ADAPTIVE_THRESH_MEAN_C, blockSize=37, C=4)
  查找轮廓
  多层筛选:
    RightBottomX ∈ [15%, 95%] 图像宽度
    LeftTopX     ∈ [5%, 90%] 图像宽度
    Rectangularity ∈ [0.30, 1.15]
    Width        ∈ [4.5%, 20%] 图像宽度
    Height       ∈ [65%, 110%] 图像高度
  按 CenterX 排序

步骤8: 处理识别结果
  情况A: 2 个轮廓 → C线 + T线 都存在 (正常有反应)
    leftImg = 左侧轮廓, rightImg = 右侧轮廓
    isNegative = false
  情况B: 1 个轮廓 → 只有 C线 (阴性)
    判断轮廓在左侧还是右侧
    人造另一个 T 线区域 (偏移 C 线位置 4×宽度)
    isNegative = true
  情况C: 0 个轮廓 → C线和T线都不存在 → 失败
  情况D: C线不存在 → 轮廓在错误位置 → 失败

步骤9: 提取三个 ROI 区域
  leftImg:  C 线区域 (控制线)
  rightImg: T 线区域 (测试线)
  wbImg:    C线中心附近取白块区域 (白平衡参考)

步骤10: 清晰度评估
  Scharr 算子 (CV_16S, 1, 0 和 0, 1)
  absdiff + add → 梯度幅值
  逐行分析亮度变化 → 清晰度值

步骤11: 绘制标记框
  在原图上绘制:
    - 绿色 ROI 外框
    - 定位块轮廓 (红色)
    - C线/T线矩形框
  无论成功与否都返回带标记的图像

输出: LocationResult

3.3.3 关键移植表

旧项目代码 (Java) 新项目 (Kotlin)
ImageLocationOvulation.GetPositions() StripLocator.locate()
ContourGf Kotlin data class ContourGf
ContourSelector.CalContoursGf() StripLocator 内部方法
ContourSelector.SelectContour() Kotlin 重写,List.filter
ContourSelector.SortContoursByGf() Kotlin sortedBy
CalImgDefinition() (Scharr) 直接移植
JudgePaperColor() StripLocator 内部方法,仅检查绿色
DrawRect() StripLocator.drawAnnotations()

3.4 特征提取器 (FeatureExtractor.kt)

3.4.1 特征说明

根据 ov_alg.txt仅保留 HSV 和灰度特征,删除所有 RGB 和 Lab 特征。

每个区域提取:

类别 通道 统计量 数量
HSV H, S, V mean (均值) 3
HSV H, S, V stddev (标准差) 3
HSV H, S, V hist (直方图峰值) 3
HSV H, S, V max (最大值) 3
HSV H, S, V min (最小值) 3
HSV 小计 15
Gray - mean (均值) 1
Gray - stddev (标准差) 1
Gray - hist (直方图峰值) 1
Gray - max (最大值) 1
Gray - min (最小值) 1
Gray 小计 5

总计: 3 个区域 × (15 HSV + 5 Gray) = 60 维特征

3.4.2 精确特征顺序 (必须与训练数据列顺序一致)

索引   特征名
[0]    left_block_H
[1]    left_block_S
[2]    left_block_V
[3]    left_block_H_stddev
[4]    left_block_S_stddev
[5]    left_block_V_stddev
[6]    left_block_H_hist
[7]    left_block_S_hist
[8]    left_block_V_hist
[9]    left_block_H_max
[10]   left_block_S_max
[11]   left_block_V_max
[12]   left_block_H_min
[13]   left_block_S_min
[14]   left_block_V_min

[15]   right_block_H
[16]   right_block_S
[17]   right_block_V
[18]   right_block_H_stddev
[19]   right_block_S_stddev
[20]   right_block_V_stddev
[21]   right_block_H_hist
[22]   right_block_S_hist
[23]   right_block_V_hist
[24]   right_block_H_max
[25]   right_block_S_max
[26]   right_block_V_max
[27]   right_block_H_min
[28]   right_block_S_min
[29]   right_block_V_min

[30]   whiteBlock_H
[31]   whiteBlock_S
[32]   whiteBlock_V
[33]   whiteBlock_H_stddev
[34]   whiteBlock_S_stddev
[35]   whiteBlock_V_stddev
[36]   whiteBlock_H_hist
[37]   whiteBlock_S_hist
[38]   whiteBlock_V_hist
[39]   whiteBlock_H_max
[40]   whiteBlock_S_max
[41]   whiteBlock_V_max
[42]   whiteBlock_H_min
[43]   whiteBlock_S_min
[44]   whiteBlock_V_min

[45]   left_grayValue
[46]   left_grayStddevValue
[47]   left_grayHist
[48]   left_grayMax
[49]   left_grayMin

[50]   right_grayValue
[51]   right_grayStddevValue
[52]   right_grayHist
[53]   right_grayMax
[54]   right_grayMin

[55]   white_grayValue
[56]   white_grayStddevValue
[57]   white_grayHist
[58]   white_grayMax
[59]   white_grayMin

3.4.3 实现

class FeatureExtractor {
    fun extract(leftImg: Mat, rightImg: Mat, wbImg: Mat): FloatArray {
        val features = FloatArray(60)

        // left block HSV: [0-14]
        extractBlockHsv(leftImg, features, 0)
        // right block HSV: [15-29]
        extractBlockHsv(rightImg, features, 15)
        // white block HSV: [30-44]
        extractBlockHsv(wbImg, features, 30)

        // left block gray: [45-49]
        extractBlockGray(leftImg, features, 45)
        // right block gray: [50-54]
        extractBlockGray(rightImg, features, 50)
        // white block gray: [55-59]
        extractBlockGray(wbImg, features, 55)

        return features
    }

    private fun extractBlockHsv(mat: Mat, features: FloatArray, offset: Int) {
        val hsv = Mat()
        Imgproc.cvtColor(mat, hsv, Imgproc.COLOR_RGB2HSV_FULL)

        // mean + stddev
        val mean = MatOfDouble()
        val stddev = MatOfDouble()
        Core.meanStdDev(hsv, mean, stddev)
        features[offset + 0] = mean.toArray()[0].toFloat()   // H mean
        features[offset + 1] = mean.toArray()[1].toFloat()   // S mean
        features[offset + 2] = mean.toArray()[2].toFloat()   // V mean
        features[offset + 3] = stddev.toArray()[0].toFloat() // H stddev
        features[offset + 4] = stddev.toArray()[1].toFloat() // S stddev
        features[offset + 5] = stddev.toArray()[2].toFloat() // V stddev

        // histogram peaks
        for (ch in 0..2) {
            val hist = Mat()
            Imgproc.calcHist(
                listOf(hsv), MatOfInt(ch), Mat(),
                hist, MatOfInt(255), MatOfFloat(0f, 255f)
            )
            val mm = Core.minMaxLoc(hist)
            features[offset + 6 + ch] = mm.maxLoc.y.toFloat()
            hist.release()
        }

        // minMaxLoc
        val channels = ArrayList<Mat>()
        Core.split(hsv, channels)
        for (ch in 0..2) {
            val mm = Core.minMaxLoc(channels[ch])
            features[offset + 9 + ch] = mm.maxVal.toFloat()  // max
            features[offset + 12 + ch] = mm.minVal.toFloat() // min
            channels[ch].release()
        }
        hsv.release()
    }

    private fun extractBlockGray(mat: Mat, features: FloatArray, offset: Int) {
        val gray = Mat()
        Imgproc.cvtColor(mat, gray, Imgproc.COLOR_RGB2GRAY)

        // mean + stddev
        val mean = MatOfDouble()
        val stddev = MatOfDouble()
        Core.meanStdDev(gray, mean, stddev)
        features[offset + 0] = mean.toArray()[0].toFloat()
        features[offset + 1] = stddev.toArray()[0].toFloat()

        // histogram peak
        val hist = Mat()
        Imgproc.calcHist(
            listOf(gray), MatOfInt(0), Mat(),
            hist, MatOfInt(255), MatOfFloat(0f, 255f)
        )
        val histMm = Core.minMaxLoc(hist)
        features[offset + 2] = histMm.maxLoc.y.toFloat()
        hist.release()

        // minMaxLoc
        val mm = Core.minMaxLoc(gray)
        features[offset + 3] = mm.maxVal.toFloat()
        features[offset + 4] = mm.minVal.toFloat()
        gray.release()
    }
}

3.5 随机森林分类器 (OvRandomForest.kt)

来源: C:\Users\hukou\Documents\pailuan\master\clf\ov_rtree50_f20_20260708.java

算法: Random Forest, 50 棵树, min_samples_leaf=20

输入: 60 维特征向量 (DoubleArray(60))

输出: 5 分类概率分布 (DoubleArray(5))

准确率: ~99.3%

object OvRandomForest {
    /**
     * 对输入特征进行评分
     * @param input 60 维特征向量
     * @return 5 分类概率分布 [class0, class1, class2, class3, class4]
     */
    fun score(input: DoubleArray): DoubleArray {
        // 直接从 ov_rtree50_f20_20260708.java 的 Model.score() 移植
        // 50 棵决策树的 if-else 逻辑
    }

    /**
     * 预测分类
     * @return 0-4 的类别 index
     */
    fun predict(features: FloatArray): Int {
        val input = DoubleArray(60) { features[it].toDouble() }
        val probs = score(input)
        return probs.indices.maxByOrNull { probs[it] } ?: 0
    }
}

移植方式: 将 Java 的 Model.score() 方法体直接复制到 Kotlin 的 score() 方法中。模型是纯 if-else 树结构,无外部依赖。


3.6 扫描状态管理 (ScanViewModel.kt)

class ScanViewModel : ViewModel() {
    // 处理状态
    private val isProcessing = AtomicBoolean(false)
    private val isScanning = AtomicBoolean(true)

    // UI 状态
    val currentBitmap = mutableStateOf<Bitmap?>(null)
    val statusText = mutableStateOf("请将试纸置于扫描框内")
    val finalResult = mutableStateOf<Int?>(null)
    val resultText = mutableStateOf<String?>(null)

    // 特征缓冲区 (线程安全)
    private val featuresBuffer = Collections.synchronizedList(mutableListOf<FloatArray>())
    private val MAX_SAMPLES = 15

    private val stripLocator = StripLocator()
    private val featureExtractor = FeatureExtractor()

    /**
     * 处理一帧图像
     * - 如果上一帧还在处理 → 跳过,返回原图
     * - 如果扫描已完成 → 跳过,返回原图
     * - 异步执行定位和特征提取
     */
    fun processFrame(mat: Mat): Bitmap {
        if (isProcessing.get() || !isScanning.get()) {
            return ImageConverter.matToBitmap(mat)
        }

        isProcessing.set(true)

        viewModelScope.launch(Dispatchers.Default) {
            try {
                val result = stripLocator.locate(mat)

                // 更新显示的图像 (带标记框)
                withContext(Dispatchers.Main) {
                    currentBitmap.value = ImageConverter.matToBitmap(result.annotatedMat)
                }

                if (result.success) {
                    val features = featureExtractor.extract(
                        result.leftImg!!, result.rightImg!!, result.wbImg!!
                    )
                    featuresBuffer.add(features)

                    withContext(Dispatchers.Main) {
                        statusText.value = "已识别: ${featuresBuffer.size}/$MAX_SAMPLES"
                    }

                    if (featuresBuffer.size >= MAX_SAMPLES) {
                        finalizeResult()
                    }
                }
            } finally {
                isProcessing.set(false)
            }
        }

        return ImageConverter.matToBitmap(mat)
    }

    /**
     * 完成识别: 15 组特征分别预测 → 取众数
     */
    private fun finalizeResult() {
        isScanning.set(false)

        val predictions = featuresBuffer.map { OvRandomForest.predict(it) }
        val mode = predictions.groupBy { it }.maxByOrNull { it.value.size }?.key ?: 0

        val resultMap = mapOf(
            0 to "阴性",
            1 to "阴性 (10)",
            2 to "阳性 (25)",
            3 to "阳性 (50)",
            4 to "强阳 (75/100)"
        )

        viewModelScope.launch(Dispatchers.Main) {
            finalResult.value = mode
            resultText.value = resultMap[mode] ?: "未知"
            statusText.value = "识别完成"
        }
    }

    fun reset() {
        featuresBuffer.clear()
        isScanning.set(true)
        finalResult.value = null
        resultText.value = null
        statusText.value = "请将试纸置于扫描框内"
    }
}

3.7 异步帧处理策略

时间线:
Frame 1 → 开始处理 (定位 ~100-200ms)
Frame 2 → 跳过 (Frame 1 还在处理) → 返回原图
Frame 3 → 跳过 → 返回原图
Frame 4 → Frame 1 完成,开始处理 Frame 4
Frame 5 → 跳过
...
Frame N → 定位成功 → 提取特征 → 存入缓冲区
...
当缓冲区满 15 → 分类器投票 → 结果 → 停止扫描

关键实现:

  • AtomicBoolean isProcessing: 保证同一时间只有一帧在处理
  • 跳过时直接返回原图 Mat → Bitmap,不阻塞 UI
  • 识别过程在 Dispatchers.Default 协程中执行

3.8 阴性处理逻辑

定位结果分析:
  2 个轮廓:
    ├─ 正常情况: leftImg=C线, rightImg=T线
    └─ isNegative = false

  1 个轮廓:
    ├─ 判断轮廓位置 (在图像中线左侧还是右侧)
    ├─ 人造另一个区域 (偏移 4×宽度)
    ├─ 如果是 C 线位置正确 → isNegative = true
    └─ 如果是 T 线位置 (C 线缺失) → success = false

  0 个轮廓:
    └─ C 线和 T 线都不存在 → success = false

四、实施步骤

阶段 内容 预估时间
Phase 1 OpenCV 集成: 复制 openCVLibrary300 源码,验证基础功能 1 天
Phase 2 图像转换: ImageProxy → Mat, Mat → Bitmap 0.5 天
Phase 3 试纸定位: 移植 StripLocator (11步定位算法) 3-4 天
Phase 4 特征提取: 60 维特征提取,确保顺序正确 1-2 天
Phase 5 分类器: 移植 Java 随机森林模型到 Kotlin 1 天
Phase 6 状态管理: ScanViewModel 帧处理 + 15帧缓冲 + 众数投票 1-2 天
Phase 7 UI 集成: 改造 ScanPage 显示实时标记图像和结果 1-2 天
Phase 8 测试调优: 定位准确率、性能、内存管理 2-3 天

总计: 约 11-15 天


五、关键技术决策

决策 选择 理由
OpenCV 版本 openCVLibrary300 (纯 Java) 与旧项目一致,无需 JNI,直接复用
试纸类型 仅绿色排卵试纸 用户明确要求,代码中无类型判断
特征集 60维 (HSV + 灰度) 与 ov_alg.txt 和训练模型一致
特征顺序 严格按训练数据列顺序 确保模型输入正确
分类器 随机森林 50 树 m2cgen 导出的纯 Java 代码
帧处理 异步 + AtomicBoolean 跳过 避免处理积压,保证 UI 流畅
结果决策 15 帧投票众数 提高单帧识别的稳定性
数据存储 识别完成后直接出结果
ROI 限制 无 (全图定位) 用户要求去掉 10%-25% 限制
阴性处理 保留人造 T 线逻辑 兼容只有 C 线的情况