a
This commit is contained in:
@@ -0,0 +1,636 @@
|
||||
# 排卵试纸识别技术实现方案
|
||||
|
||||
> 版本: 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<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%
|
||||
|
||||
```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<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 线的情况 |
|
||||
Reference in New Issue
Block a user