# 排卵试纸识别技术实现方案 > 版本: 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`) ```kotlin 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 数据结构 ```kotlin 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 实现 ```kotlin 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() 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% ```kotlin 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`) ```kotlin class ScanViewModel : ViewModel() { // 处理状态 private val isProcessing = AtomicBoolean(false) private val isScanning = AtomicBoolean(true) // UI 状态 val currentBitmap = mutableStateOf(null) val statusText = mutableStateOf("请将试纸置于扫描框内") val finalResult = mutableStateOf(null) val resultText = mutableStateOf(null) // 特征缓冲区 (线程安全) private val featuresBuffer = Collections.synchronizedList(mutableListOf()) 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 线的情况 |