This commit is contained in:
coco
2026-07-14 09:44:44 +08:00
parent fe2bb81260
commit 88c0fbefef
583 changed files with 115036 additions and 0 deletions
+15
View File
@@ -0,0 +1,15 @@
*.iml
.gradle
/local.properties
/.idea/caches
/.idea/libraries
/.idea/modules.xml
/.idea/workspace.xml
/.idea/navEditor.xml
/.idea/assetWizardSettings.xml
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties
+3
View File
@@ -0,0 +1,3 @@
# Default ignored files
/shelf/
/workspace.xml
+1
View File
@@ -0,0 +1 @@
PinkOV
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AndroidProjectSystem">
<option name="providerId" value="com.android.tools.idea.GradleProjectSystem" />
</component>
</project>
+26
View File
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AppInsightsSettings">
<option name="tabSettings">
<map>
<entry key="Firebase Crashlytics">
<value>
<InsightsFilterSettings>
<option name="connection">
<ConnectionSetting>
<option name="appId" value="PLACEHOLDER" />
<option name="mobileSdkAppId" value="" />
<option name="projectId" value="" />
<option name="projectNumber" value="" />
</ConnectionSetting>
</option>
<option name="signal" value="SIGNAL_UNSPECIFIED" />
<option name="timeIntervalDays" value="THIRTY_DAYS" />
<option name="visibilityType" value="ALL" />
</InsightsFilterSettings>
</value>
</entry>
</map>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="CompilerConfiguration">
<bytecodeTargetLevel target="21" />
</component>
</project>
+11
View File
@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="deploymentTargetSelector">
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DialogSelection />
</SelectionState>
</selectionStates>
</component>
</project>
+19
View File
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="GradleMigrationSettings" migrationVersion="1" />
<component name="GradleSettings">
<option name="linkedExternalProjectsSettings">
<GradleProjectSettings>
<option name="testRunner" value="CHOOSE_PER_TEST" />
<option name="externalProjectPath" value="$PROJECT_DIR$" />
<option name="modules">
<set>
<option value="$PROJECT_DIR$" />
<option value="$PROJECT_DIR$/app" />
<option value="$PROJECT_DIR$/openCVLibrary300" />
</set>
</option>
</GradleProjectSettings>
</option>
</component>
</project>
+61
View File
@@ -0,0 +1,61 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="ComposePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="ComposePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewDimensionRespectsLimit" enabled="true" level="WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewMustBeTopLevelFunction" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNeedsComposableAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="GlancePreviewNotSupportedInUnitTestFiles" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewAnnotationInFunctionWithParameters" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewApiLevelMustBeValid" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewDeviceShouldUseNewSpec" enabled="true" level="WEAK WARNING" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewFontScaleMustBeGreaterThanZero" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewMultipleParameterProviders" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewParameterProviderOnFirstParameter" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
<inspection_tool class="PreviewPickerAnnotation" enabled="true" level="ERROR" enabled_by_default="true">
<option name="composableFile" value="true" />
<option name="previewFile" value="true" />
</inspection_tool>
</profile>
</component>
+9
View File
@@ -0,0 +1,9 @@
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="ProjectRootManager" version="2" languageLevel="JDK_21" default="true" project-jdk-name="21" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/build/classes" />
</component>
<component name="ProjectType">
<option name="id" value="Android" />
</component>
</project>
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="RunConfigurationProducerService">
<option name="ignoredProducers">
<set>
<option value="com.intellij.execution.junit.AbstractAllInDirectoryConfigurationProducer" />
<option value="com.intellij.execution.junit.AllInPackageConfigurationProducer" />
<option value="com.intellij.execution.junit.PatternConfigurationProducer" />
<option value="com.intellij.execution.junit.TestInClassConfigurationProducer" />
<option value="com.intellij.execution.junit.UniqueIdConfigurationProducer" />
<option value="com.intellij.execution.junit.testDiscovery.JUnitTestDiscoveryConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinJUnitRunConfigurationProducer" />
<option value="org.jetbrains.kotlin.idea.junit.KotlinPatternConfigurationProducer" />
</set>
</option>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$/.." vcs="Git" />
</component>
</project>
+47
View File
@@ -0,0 +1,47 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project overview
**PinkOV** — an Android application using Kotlin, Jetpack Compose, and Material 3 with adaptive navigation. Package: `com.pinkbear.pinkov`.
## Build and test commands
```bash
# Build the project
./gradlew assembleDebug
# Run unit tests (JVM, in app/src/test/)
./gradlew test
# Run a single unit test class
./gradlew test --tests "com.pinkbear.pinkov.ExampleUnitTest"
# Run a single unit test method
./gradlew test --tests "com.pinkbear.pinkov.ExampleUnitTest.addition_isCorrect"
# Run instrumented tests (requires connected device or emulator)
./gradlew connectedAndroidTest
# Run lint checks
./gradlew lint
# Clean build outputs
./gradlew clean
```
## Architecture
- **Single-activity app**: `MainActivity` uses `setContent` with `PinkOVTheme` wrapping the root composable `PinkOVApp()`.
- **Navigation**: `NavigationSuiteScaffold` (Material 3 adaptive) with bottom tabs defined by the `AppDestinations` enum. Destinations are Home and Mine — each backed by a drawable icon resource. Full-screen pages (e.g., `ScanPage`) are toggled via a `showScanPage` boolean state in `PinkOVApp`.
- **Theme layer** (`ui/theme/`): Supports dynamic color on Android 12+ with dark/light fallback color schemes. Uses `MaterialTheme` with custom color and typography definitions.
- **Camera**: Uses CameraX (`camera-core`, `camera-camera2`, `camera-lifecycle`) with `ImageAnalysis` for frame processing. `OvImageAnalyzer` (in `ui/scan/`) implements `ImageAnalysis.Analyzer`, crops the top 50% of each frame, and exposes the result via `MutableState<Bitmap>`. Permissions are handled via Accompanist (`accompanist-permissions`).
- **Dependency management**: Gradle version catalog in `gradle/libs.versions.toml` — add new dependencies there and reference via `libs.*` in `app/build.gradle.kts` rather than hardcoding coordinates.
- **Target**: compileSdk 36, minSdk 24, Kotlin 2.2.10, AGP 9.2.1, Java 11 bytecode target.
## Key conventions
- Compose previews use `@PreviewScreenSizes` (for adaptive layouts) and `@Preview(showBackground = true)` (for component-level previews).
- Navigation icons are `painterResource`-based drawable vectors stored in `app/src/main/res/drawable/`.
- The Compose BOM (`androidx-compose-bom`) manages Compose library versions — individual Compose dependency versions should not be pinned.
+1
View File
@@ -0,0 +1 @@
/build
+66
View File
@@ -0,0 +1,66 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
}
android {
namespace = "com.pinkbear.pinkov"
compileSdk {
version = release(36) {
minorApiLevel = 1
}
}
defaultConfig {
applicationId = "com.pinkbear.pinkov"
minSdk = 24
targetSdk = 36
versionCode = 1
versionName = "1.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
buildTypes {
release {
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
buildFeatures {
compose = true
}
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.activity.compose)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive.navigation.suite)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.graphics)
implementation(libs.androidx.compose.ui.tooling.preview)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.lifecycle.runtime.ktx)
implementation(libs.androidx.lifecycle.runtime.compose)
implementation(libs.androidx.lifecycle.viewmodel.compose)
implementation(libs.androidx.camera.core)
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.accompanist.permissions)
implementation(project(":openCVLibrary300"))
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.compose.ui.test.junit4)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.junit)
debugImplementation(libs.androidx.compose.ui.test.manifest)
debugImplementation(libs.androidx.compose.ui.tooling)
}
+21
View File
@@ -0,0 +1,21 @@
# Add project specific ProGuard rules here.
# You can control the set of applied configuration files using the
# proguardFiles setting in build.gradle.
#
# For more details, see
# http://developer.android.com/guide/developing/tools/proguard.html
# If your project uses WebView with JS, uncomment the following
# and specify the fully qualified class name to the JavaScript interface
# class:
#-keepclassmembers class fqcn.of.javascript.interface.for.webview {
# public *;
#}
# Uncomment this to preserve the line number information for
# debugging stack traces.
#-keepattributes SourceFile,LineNumberTable
# If you keep the line number information, uncomment this to
# hide the original source file name.
#-renamesourcefileattribute SourceFile
@@ -0,0 +1,24 @@
package com.pinkbear.pinkov
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Test
import org.junit.runner.RunWith
import org.junit.Assert.*
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class ExampleInstrumentedTest {
@Test
fun useAppContext() {
// Context of the app under test.
val appContext = InstrumentationRegistry.getInstrumentation().targetContext
assertEquals("com.pinkbear.pinkov", appContext.packageName)
}
}
@@ -0,0 +1,184 @@
package com.pinkbear.pinkov
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.util.Log
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.BeforeClass
import org.junit.Test
import org.junit.runner.RunWith
import org.opencv.android.Utils
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.Rect
import org.opencv.imgproc.Imgproc
import com.pinkbear.pinkov.ui.scan.*
import java.io.File
/**
* Instrumented test that processes ovulation test strip images from /sdcard/papers/
* and reports the classification results.
*
* Usage:
* 1. adb push D:\gitPlanet\yola\Pink\papers /sdcard/papers/
* 2. ./gradlew connectedAndroidTest --tests "com.pinkbear.pinkov.PaperTest"
* 3. adb logcat -s PaperTest:*
*/
@RunWith(AndroidJUnit4::class)
class PaperTest {
companion object {
private const val TAG = "PaperTest"
private const val PAPERS_DIR = "/data/local/tmp/papers"
@BeforeClass
@JvmStatic
fun setUp() {
System.loadLibrary("opencv_java3")
}
}
@Test
fun processAllPapers() {
val dir = File(PAPERS_DIR)
if (!dir.exists() || !dir.isDirectory) {
Log.e(TAG, "Papers directory not found: $PAPERS_DIR")
Log.e(TAG, "Please run: adb push papers /sdcard/papers/")
return
}
val imageFiles = dir.listFiles { f: File -> f.extension.equals("jpg", ignoreCase = true) }
?.sortedBy { it.name } ?: emptyList()
Log.i(TAG, "========================================")
Log.i(TAG, "Found ${imageFiles.size} images in $PAPERS_DIR")
Log.i(TAG, "========================================")
var success = 0
var failed = 0
val results = mutableListOf<String>()
for (file in imageFiles) {
try {
val bitmap = BitmapFactory.decodeFile(file.absolutePath)
if (bitmap == null) {
Log.w(TAG, "${file.name}: FAILED to decode")
failed++
continue
}
val w = bitmap.width
val h = bitmap.height
val isCropped = h < 200 // Cropped strips are very thin (40-100px)
Log.d(TAG, "${file.name}: ${w}x${h} cropped=$isCropped")
val result = if (isCropped) {
processCroppedImage(bitmap, file.name)
} else {
processFullImage(bitmap, file.name)
}
if (result.errorCode == 0) {
// Extract features and classify
val features = FeatureExtractor.extract(
result.leftImg!!, result.rightImg!!, result.wbImg!!
)
if (features != null) {
val classification = OvClassifier.classify(features)
val line = "${file.name}: OK -> ${classification.name} (value=${classification.value}, label=${classification.label})"
Log.i(TAG, line)
results.add(line)
success++
} else {
Log.w(TAG, "${file.name}: feature extraction failed")
results.add("${file.name}: FEATURE_FAIL")
failed++
}
// Cleanup
result.leftImg?.release()
result.rightImg?.release()
result.wbImg?.release()
} else {
val line = "${file.name}: LOCATE_FAIL (errorCode=${result.errorCode}, isNegative=${result.isNegative})"
Log.w(TAG, line)
results.add(line)
failed++
}
bitmap.recycle()
} catch (e: Exception) {
Log.e(TAG, "${file.name}: EXCEPTION: ${e.message}", e)
results.add("${file.name}: EXCEPTION ${e.message}")
failed++
}
}
// Summary
Log.i(TAG, "========================================")
Log.i(TAG, "SUMMARY: success=$success, failed=$failed, total=${imageFiles.size}")
Log.i(TAG, "========================================")
for (r in results) {
Log.i(TAG, " $r")
}
Log.i(TAG, "========================================")
}
private fun processCroppedImage(bitmap: Bitmap, name: String): StripResult {
// Convert Bitmap to RGB Mat
val rgbMat = Mat()
Utils.bitmapToMat(bitmap, rgbMat)
// Convert to BGR (OpenCV default) -> RGB for our algorithm
val rgbCorrect = Mat()
Imgproc.cvtColor(rgbMat, rgbCorrect, Imgproc.COLOR_RGBA2RGB)
rgbMat.release()
val result = StripLocator.locateCroppedStrip(rgbCorrect)
rgbCorrect.release()
return result
}
private fun processFullImage(bitmap: Bitmap, name: String): StripResult {
// Convert Bitmap to RGBA Mat
val rgbaMat = Mat()
Utils.bitmapToMat(bitmap, rgbaMat)
var result = StripLocator.locate(rgbaMat, Rect(0, 0, rgbaMat.width(), rgbaMat.height()), false)
// Fallback 1: try locateCroppedStrip on the full image
if (result.errorCode != 0) {
Log.d(TAG, "$name: locate() failed (errorCode=${result.errorCode}), trying locateCroppedStrip fallback")
result.leftImg?.release(); result.rightImg?.release(); result.wbImg?.release()
val rgbCorrect = Mat()
Imgproc.cvtColor(rgbaMat, rgbCorrect, Imgproc.COLOR_RGBA2RGB)
result = StripLocator.locateCroppedStrip(rgbCorrect)
rgbCorrect.release()
}
// Fallback 2: crop the center 30% and try locateCroppedStrip
if (result.errorCode != 0) {
Log.d(TAG, "$name: locateCroppedStrip full failed (errorCode=${result.errorCode}), trying center crop")
result.leftImg?.release(); result.rightImg?.release(); result.wbImg?.release()
val w = rgbaMat.width()
val h = rgbaMat.height()
// Try multiple crop sizes: 30%, 20%, 15%
val cropSizes = listOf(0.3, 0.2, 0.15)
var croppedResult: StripResult? = null
for (cropSize in cropSizes) {
val margin = (1.0 - cropSize) / 2.0
val cropRect = Rect((w * margin).toInt(), (h * margin).toInt(), (w * cropSize).toInt(), (h * cropSize).toInt())
val cropped = rgbaMat.submat(cropRect)
val rgbCorrect = Mat()
Imgproc.cvtColor(cropped, rgbCorrect, Imgproc.COLOR_RGBA2RGB)
croppedResult = StripLocator.locateCroppedStrip(rgbCorrect)
rgbCorrect.release()
cropped.release()
if (croppedResult.errorCode == 0) break
croppedResult.leftImg?.release(); croppedResult.rightImg?.release(); croppedResult.wbImg?.release()
}
result = croppedResult!!
}
rgbaMat.release()
return result
}
}
+29
View File
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.CAMERA" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.PinkOV">
<activity
android:name=".MainActivity"
android:exported="true"
android:label="@string/app_name"
android:theme="@style/Theme.PinkOV">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>
@@ -0,0 +1,70 @@
package com.pinkbear.pinkov
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.material3.Icon
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.tooling.preview.PreviewScreenSizes
import com.pinkbear.pinkov.navigation.AppDestinations
import com.pinkbear.pinkov.ui.page.HomePage
import com.pinkbear.pinkov.ui.page.MinePage
import com.pinkbear.pinkov.ui.page.ScanPage
import com.pinkbear.pinkov.ui.theme.PinkOVTheme
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
System.loadLibrary("opencv_java3")
enableEdgeToEdge()
setContent {
PinkOVTheme {
PinkOVApp()
}
}
}
}
@PreviewScreenSizes
@Composable
fun PinkOVApp() {
var currentDestination by rememberSaveable { mutableStateOf(AppDestinations.HOME) }
var showScanPage by rememberSaveable { mutableStateOf(false) }
if (showScanPage) {
ScanPage(onBack = { showScanPage = false })
} else {
NavigationSuiteScaffold(
navigationSuiteItems = {
AppDestinations.entries.forEach {
item(
icon = {
Icon(
painterResource(it.icon),
contentDescription = it.label
)
},
label = { Text(it.label) },
selected = it == currentDestination,
onClick = { currentDestination = it }
)
}
}
) {
when (currentDestination) {
AppDestinations.HOME -> HomePage(
onScanClick = { showScanPage = true }
)
AppDestinations.MINE -> MinePage()
}
}
}
}
@@ -0,0 +1,14 @@
package com.pinkbear.pinkov.navigation
import com.pinkbear.pinkov.R
/**
* Navigation destinations for the app's bottom navigation bar.
*/
enum class AppDestinations(
val label: String,
val icon: Int,
) {
HOME("Home", R.drawable.ic_home),
MINE("Mine", R.drawable.ic_mine),
}
@@ -0,0 +1,95 @@
package com.pinkbear.pinkov.ui.component
import androidx.compose.foundation.Canvas
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.drawscope.Stroke
/**
* Draws a green scanning frame overlay on top of the camera image.
*
* The frame is positioned relative to the canvas size and serves as a visual
* guide for the user to align the test strip.
*/
@Composable
fun ScanOverlay(
modifier: Modifier = Modifier,
frameColor: Color = Color.Green,
strokeWidth: Float = 3f,
) {
Canvas(modifier = modifier) {
val canvasWidth = size.width
val canvasHeight = size.height
// Frame dimensions: 90% of width, 60% of the displayed area height
val frameWidth = canvasWidth * 0.90f
val frameHeight = canvasHeight * 0.60f
val topLeft = Offset(
x = (canvasWidth - frameWidth) / 2f,
y = (canvasHeight - frameHeight) / 2f
)
val cornerLength = 30f
// Draw four corners of the scan frame
// Top-left corner
drawLine(
color = frameColor,
start = topLeft,
end = Offset(topLeft.x + cornerLength, topLeft.y),
strokeWidth = strokeWidth
)
drawLine(
color = frameColor,
start = topLeft,
end = Offset(topLeft.x, topLeft.y + cornerLength),
strokeWidth = strokeWidth
)
// Top-right corner
drawLine(
color = frameColor,
start = Offset(topLeft.x + frameWidth - cornerLength, topLeft.y),
end = Offset(topLeft.x + frameWidth, topLeft.y),
strokeWidth = strokeWidth
)
drawLine(
color = frameColor,
start = Offset(topLeft.x + frameWidth, topLeft.y),
end = Offset(topLeft.x + frameWidth, topLeft.y + cornerLength),
strokeWidth = strokeWidth
)
// Bottom-left corner
drawLine(
color = frameColor,
start = Offset(topLeft.x, topLeft.y + frameHeight - cornerLength),
end = Offset(topLeft.x, topLeft.y + frameHeight),
strokeWidth = strokeWidth
)
drawLine(
color = frameColor,
start = Offset(topLeft.x, topLeft.y + frameHeight),
end = Offset(topLeft.x + cornerLength, topLeft.y + frameHeight),
strokeWidth = strokeWidth
)
// Bottom-right corner
drawLine(
color = frameColor,
start = Offset(topLeft.x + frameWidth - cornerLength, topLeft.y + frameHeight),
end = Offset(topLeft.x + frameWidth, topLeft.y + frameHeight),
strokeWidth = strokeWidth
)
drawLine(
color = frameColor,
start = Offset(topLeft.x + frameWidth, topLeft.y + frameHeight - cornerLength),
end = Offset(topLeft.x + frameWidth, topLeft.y + frameHeight),
strokeWidth = strokeWidth
)
}
}
@@ -0,0 +1,47 @@
package com.pinkbear.pinkov.ui.page
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import com.pinkbear.pinkov.ui.theme.PinkOVTheme
@Composable
fun HomePage(
modifier: Modifier = Modifier,
onScanClick: () -> Unit = {},
) {
Scaffold(modifier = modifier) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
horizontalAlignment = Alignment.CenterHorizontally
) {
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = onScanClick) {
Text("扫描")
}
Spacer(modifier = Modifier.weight(1f))
Text(text = "Home")
Spacer(modifier = Modifier.weight(1f))
}
}
}
@Preview(showBackground = true)
@Composable
fun HomePagePreview() {
PinkOVTheme {
HomePage()
}
}
@@ -0,0 +1,34 @@
package com.pinkbear.pinkov.ui.page
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.pinkbear.pinkov.ui.theme.PinkOVTheme
@Composable
fun MinePage(modifier: Modifier = Modifier) {
Scaffold(modifier = modifier) { innerPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(innerPadding),
contentAlignment = Alignment.Center
) {
Text(text = "Mine")
}
}
}
@Preview(showBackground = true)
@Composable
fun MinePagePreview() {
PinkOVTheme {
MinePage()
}
}
@@ -0,0 +1,246 @@
package com.pinkbear.pinkov.ui.page
import android.Manifest
import android.content.Intent
import android.net.Uri
import android.provider.Settings
import androidx.camera.core.CameraSelector
import androidx.camera.core.ImageAnalysis
import androidx.camera.lifecycle.ProcessCameraProvider
import androidx.camera.core.resolutionselector.ResolutionSelector
import androidx.camera.core.resolutionselector.ResolutionStrategy
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.LocalLifecycleOwner
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.lifecycle.viewmodel.compose.viewModel
import androidx.compose.ui.unit.dp
import androidx.core.content.ContextCompat
import com.google.accompanist.permissions.ExperimentalPermissionsApi
import com.google.accompanist.permissions.rememberMultiplePermissionsState
import com.pinkbear.pinkov.ui.component.ScanOverlay
import com.pinkbear.pinkov.ui.scan.OvImageAnalyzer
import com.pinkbear.pinkov.ui.scan.ScanViewModel
import java.util.concurrent.Executors
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
@OptIn(ExperimentalPermissionsApi::class)
@Composable
fun ScanPage(onBack: () -> Unit) {
val context = LocalContext.current
val lifecycleOwner = LocalLifecycleOwner.current
val viewModel: ScanViewModel = viewModel()
val scanState by viewModel.state.collectAsStateWithLifecycle()
val cameraExecutor = remember { Executors.newSingleThreadExecutor() }
val analyzer = remember { OvImageAnalyzer(viewModel) }
// Track whether camera should be bound
var cameraBound by remember { mutableStateOf(false) }
var rescanTrigger by remember { mutableStateOf(0) }
// Camera permission
val permissionState = rememberMultiplePermissionsState(
permissions = listOf(Manifest.permission.CAMERA)
)
LaunchedEffect(Unit) {
permissionState.launchMultiplePermissionRequest()
}
// Set up image analysis
val imageAnalysis = remember {
ImageAnalysis.Builder()
.setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_RGBA_8888)
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
.setResolutionSelector(
ResolutionSelector.Builder()
.setResolutionStrategy(
ResolutionStrategy(
android.util.Size(1280, 720),
ResolutionStrategy.FALLBACK_RULE_CLOSEST_HIGHER_THEN_LOWER
)
)
.build()
)
.build()
.also { it.setAnalyzer(cameraExecutor, analyzer) }
}
// Bind camera when permission granted and not already bound
LaunchedEffect(permissionState.allPermissionsGranted, rescanTrigger) {
if (!permissionState.allPermissionsGranted) return@LaunchedEffect
val cameraProvider = context.getCameraProvider()
cameraProvider.unbindAll()
cameraProvider.bindToLifecycle(
lifecycleOwner,
CameraSelector.DEFAULT_BACK_CAMERA,
imageAnalysis
)
cameraBound = true
}
// Stop camera when final result is obtained
LaunchedEffect(scanState.shouldStopCamera) {
if (scanState.shouldStopCamera) {
val cameraProvider = context.getCameraProvider()
cameraProvider.unbindAll()
cameraBound = false
}
}
if (permissionState.allPermissionsGranted) {
Column(modifier = Modifier.fillMaxSize()) {
// Upper 50%: camera image with scan frame overlay
Box(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight(0.5f)
.background(Color.Black),
contentAlignment = Alignment.Center
) {
if (scanState.previewBitmap != null) {
androidx.compose.foundation.Image(
bitmap = scanState.previewBitmap!!.asImageBitmap(),
contentDescription = "Camera preview",
modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop
)
}
ScanOverlay(modifier = Modifier.fillMaxSize())
}
// Lower 50%: controls and info
Column(
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
.background(Color(0xFF1A1A1A)),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
// Result display
if (scanState.result != null) {
val result = scanState.result!!
Text(
text = "结果: ${result.name}",
color = when (result.label) {
0, 1 -> Color(0xFF4CAF50) // Green for negative
2, 3 -> Color(0xFFFF9800) // Orange for positive
4 -> Color(0xFFF44336) // Red for strong positive
else -> Color.White
},
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "LH值: ${result.value}",
color = Color.White,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 24.dp)
)
} else if (scanState.frameCount > 0) {
// Scanning progress
Text(
text = "采集中... ${scanState.frameCount}/${scanState.totalFrames}",
color = Color.White,
modifier = Modifier.padding(bottom = 8.dp)
)
// Progress bar
Box(
modifier = Modifier
.fillMaxWidth(0.7f)
.height(4.dp)
.background(Color.DarkGray)
) {
Box(
modifier = Modifier
.fillMaxWidth(scanState.frameCount.toFloat() / scanState.totalFrames)
.height(4.dp)
.background(Color(0xFF4CAF50))
)
}
Spacer(modifier = Modifier.height(24.dp))
} else {
Text(
text = "请将排卵试纸置于扫描框内",
color = Color.White,
modifier = Modifier.padding(bottom = 24.dp)
)
}
// Error message
if (scanState.errorMessage != null) {
Text(
text = scanState.errorMessage!!,
color = Color(0xFFF44336),
modifier = Modifier.padding(bottom = 8.dp)
)
}
Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
// Rescan button (shown after result)
if (scanState.result != null) {
Button(onClick = {
viewModel.reset()
rescanTrigger++
}) {
Text("重新扫描")
}
}
Button(onClick = onBack) {
Text("返回")
}
}
}
}
} else {
// Permission denied
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("需要相机权限才能扫描", color = Color.White)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
if (permissionState.shouldShowRationale) {
permissionState.launchMultiplePermissionRequest()
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:${context.packageName}")
context.startActivity(this)
}
}
}) {
Text("授予权限")
}
}
}
}
}
/**
* Suspend function to obtain the [ProcessCameraProvider] instance.
*/
suspend fun android.content.Context.getCameraProvider(): ProcessCameraProvider =
suspendCoroutine { continuation ->
ProcessCameraProvider.getInstance(this).also { cameraProvider ->
cameraProvider.addListener({
continuation.resume(cameraProvider.get())
}, ContextCompat.getMainExecutor(this))
}
}
@@ -0,0 +1,55 @@
package com.pinkbear.pinkov.ui.scan
import org.opencv.core.Point
import org.opencv.core.RotatedRect
import org.opencv.core.Size
/**
* Geometric feature flags for contour sorting and filtering.
* Mirrors the original [ContourGf.GF_FLAG] enum.
*/
enum class GfFlag {
Perimeter,
Area,
CenterX,
CenterY,
LeftTopX,
LeftTopY,
RightBottomX,
RightBottomY,
AspectRatio,
Rectangularity,
GrayValue,
Width,
Height,
RedPercentage,
GreenPercentage,
BluePercentage
}
/**
* Geometric feature data class for a contour.
* Ported from the original ContourGf.java.
*/
class ContourGf {
var contourPerimeter: Int = 0
var contourArea: Int = 0
var contourCenter: Point = Point(0.0, 0.0)
var leftTop: Point = Point(0.0, 0.0)
var rightBottom: Point = Point(0.0, 0.0)
var contourAspectRatio: Double = 0.0
var contourRectangularity: Double = 0.0
var contourGrayValue: Int = 0
var rotRect: RotatedRect = RotatedRect()
var contourIndex: Int = 0
var size: Size = Size()
val colorPercentage: DoubleArray = DoubleArray(3)
fun setColorPercentage(v1: Double, v2: Double, v3: Double) {
colorPercentage[0] = v1
colorPercentage[1] = v2
colorPercentage[2] = v3
}
fun getColorPercentage(vIndex: Int): Double = colorPercentage[vIndex]
}
@@ -0,0 +1,129 @@
package com.pinkbear.pinkov.ui.scan
import org.opencv.core.Core
import org.opencv.core.Mat
import org.opencv.core.MatOfPoint
import org.opencv.core.MatOfPoint2f
import org.opencv.core.Point
import org.opencv.core.Rect
import org.opencv.core.Scalar
import org.opencv.imgproc.Imgproc
/**
* Contour filtering and sorting utilities.
* Ported from the original ContourSelector.java.
*/
object ContourSelector {
private const val MIN_VALUE = 5
/**
* Calculate geometric features for all contours.
*/
fun calContoursGf(
contours: List<MatOfPoint>,
gfsList: MutableList<Pair<MatOfPoint, ContourGf>>,
src: Mat
) {
for (i in contours.indices) {
val contourGf = ContourGf()
val pNextContour = contours[i]
val rect = Imgproc.boundingRect(pNextContour)
val roteRect = Imgproc.minAreaRect(MatOfPoint2f(*pNextContour.toArray()))
contourGf.contourArea = Imgproc.contourArea(pNextContour).toInt()
val mf = MatOfPoint2f(*pNextContour.toArray())
contourGf.contourPerimeter = Imgproc.arcLength(mf, true).toInt()
mf.release()
contourGf.rotRect = roteRect
contourGf.contourIndex = i
val width = Math.max(roteRect.size.height.toInt(), roteRect.size.width.toInt())
val height = Math.min(roteRect.size.height.toInt(), roteRect.size.width.toInt())
contourGf.size = org.opencv.core.Size(rect.width.toDouble(), rect.height.toDouble())
contourGf.contourAspectRatio = height.toDouble() / width // aspect ratio
contourGf.contourRectangularity = contourGf.contourArea.toDouble() / (width * height) // rectangularity
contourGf.contourCenter = Point(roteRect.center.x, roteRect.center.y)
contourGf.leftTop = Point(rect.x.toDouble(), rect.y.toDouble())
contourGf.rightBottom = Point((rect.x + rect.width).toDouble(), (rect.y + rect.height).toDouble())
val roi = Rect(roteRect.center.x.toInt(), roteRect.center.y.toInt(), MIN_VALUE, MIN_VALUE)
if ((roi.x + MIN_VALUE) > (src.width() - 1)
|| (roi.y + MIN_VALUE) > (src.height() - 1)
|| width < MIN_VALUE
|| height < MIN_VALUE
) {
continue
}
val roiSrc = src.submat(roi)
val avg = Core.mean(roiSrc)
val avgSum = avg.`val`[0] + avg.`val`[1] + avg.`val`[2]
val rp = avg.`val`[0] / avgSum
val gp = avg.`val`[1] / avgSum
val bp = avg.`val`[2] / avgSum
contourGf.setColorPercentage(rp, gp, bp)
contourGf.contourGrayValue = (avgSum / 3).toInt()
roiSrc.release()
gfsList.add(Pair(pNextContour, contourGf))
}
}
/**
* Sort contours by a geometric feature flag.
*/
fun sortContoursByGf(
gfsList: MutableList<Pair<MatOfPoint, ContourGf>>,
gf: GfFlag,
bDesc: Boolean
) {
val comparator: Comparator<Pair<MatOfPoint, ContourGf>> = if (bDesc) {
compareByDescending { getGfValue(it.second, gf) }
} else {
compareBy { getGfValue(it.second, gf) }
}
// Use stable sort to handle duplicate keys (original code added 0.00000001 to key)
gfsList.sortWith(comparator)
}
/**
* Select contours within a feature value range.
*/
fun selectContour(
gfsList1: List<Pair<MatOfPoint, ContourGf>>,
gfsList2: MutableList<Pair<MatOfPoint, ContourGf>>,
minValue: Double,
maxValue: Double,
gf: GfFlag
) {
for (pcf in gfsList1) {
val gfValue = getGfValue(pcf.second, gf)
if (gfValue in minValue..maxValue) {
gfsList2.add(pcf)
}
}
}
private fun getGfValue(cf: ContourGf, gf: GfFlag): Double = when (gf) {
GfFlag.Perimeter -> cf.contourPerimeter.toDouble()
GfFlag.Area -> cf.contourArea.toDouble()
GfFlag.CenterX -> cf.contourCenter.x
GfFlag.CenterY -> cf.contourCenter.y
GfFlag.LeftTopX -> cf.leftTop.x
GfFlag.LeftTopY -> cf.leftTop.y
GfFlag.RightBottomX -> cf.rightBottom.x
GfFlag.RightBottomY -> cf.rightBottom.y
GfFlag.AspectRatio -> cf.contourAspectRatio
GfFlag.Rectangularity -> cf.contourRectangularity
GfFlag.GrayValue -> cf.contourGrayValue.toDouble()
GfFlag.Width -> cf.size.width
GfFlag.Height -> cf.size.height
GfFlag.RedPercentage -> cf.getColorPercentage(0)
GfFlag.GreenPercentage -> cf.getColorPercentage(1)
GfFlag.BluePercentage -> cf.getColorPercentage(2)
}
}
@@ -0,0 +1,144 @@
package com.pinkbear.pinkov.ui.scan
import org.opencv.core.Core
import org.opencv.core.Mat
import org.opencv.core.MatOfDouble
import org.opencv.core.MatOfFloat
import org.opencv.core.MatOfInt
import org.opencv.imgproc.Imgproc
/**
* Extracts 60-dimensional feature vector from ovulation test strip ROIs.
*
* Feature order (indices 0-59):
* 0-14: left_block HSV (H, S, V, H_stddev, S_stddev, V_stddev, H_hist, S_hist, V_hist, H_max, S_max, V_max, H_min, S_min, V_min)
* 15-29: right_block HSV (same order)
* 30-44: whiteBlock HSV (same order)
* 45-49: left_gray (grayValue, grayStddev, grayHist, grayMax, grayMin)
* 50-54: right_gray (same order)
* 55-59: white_gray (same order)
*
* Uses OpenCV COLOR_RGB2HSV_FULL (H range 0-255) to match the original training data.
*/
object FeatureExtractor {
private const val HIST_SIZE = 255
private const val HIST_RANGE_START = 0f
private const val HIST_RANGE_END = 255f
/**
* Extract the 60-dimensional feature vector from the three block ROIs.
*
* @param leftImg Left block ROI (T line region, RGB)
* @param rightImg Right block ROI (C line region, RGB)
* @param wbImg White block ROI (between C and T lines, RGB)
* @return DoubleArray of 60 features, or null if any ROI is invalid
*/
fun extract(leftImg: Mat, rightImg: Mat, wbImg: Mat): DoubleArray? {
if (leftImg.empty() || rightImg.empty() || wbImg.empty()) return null
val features = DoubleArray(60)
// Extract HSV features for each block
extractHsvBlock(leftImg, features, 0) // indices 0-14
extractHsvBlock(rightImg, features, 15) // indices 15-29
extractHsvBlock(wbImg, features, 30) // indices 30-44
// Extract gray features for each block
extractGrayBlock(leftImg, features, 45) // indices 45-49
extractGrayBlock(rightImg, features, 50) // indices 50-54
extractGrayBlock(wbImg, features, 55) // indices 55-59
return features
}
/**
* Extract 15 HSV features from a block ROI.
* Features: H, S, V, H_stddev, S_stddev, V_stddev, H_hist, S_hist, V_hist, H_max, S_max, V_max, H_min, S_min, V_min
*/
private fun extractHsvBlock(rgbMat: Mat, features: DoubleArray, offset: Int) {
val hsvMat = Mat()
Imgproc.cvtColor(rgbMat, hsvMat, Imgproc.COLOR_RGB2HSV_FULL)
// Mean and stddev
val mean = MatOfDouble()
val stddev = MatOfDouble()
Core.meanStdDev(hsvMat, mean, stddev)
val meanArr = mean.toArray()
val stddevArr = stddev.toArray()
features[offset + 0] = meanArr[0] // H mean
features[offset + 1] = meanArr[1] // S mean
features[offset + 2] = meanArr[2] // V mean
features[offset + 3] = stddevArr[0] // H stddev
features[offset + 4] = stddevArr[1] // S stddev
features[offset + 5] = stddevArr[2] // V stddev
// Histogram peaks
val histSize = MatOfInt(HIST_SIZE)
val histRange = MatOfFloat(HIST_RANGE_START, HIST_RANGE_END)
val histMat = Mat()
val maskMat = Mat()
val channels = arrayOf(MatOfInt(0), MatOfInt(1), MatOfInt(2))
for (ch in 0..2) {
Imgproc.calcHist(listOf(hsvMat), channels[ch], maskMat, histMat, histSize, histRange)
val result = Core.minMaxLoc(histMat)
features[offset + 6 + ch] = result.maxLoc.y // H/S/V hist peak
}
// Min and max
val planes = mutableListOf<Mat>()
Core.split(hsvMat, planes)
for (ch in 0..2) {
val result = Core.minMaxLoc(planes[ch])
features[offset + 9 + ch] = result.maxVal // H/S/V max
features[offset + 12 + ch] = result.minVal // H/S/V min
}
// Cleanup
hsvMat.release()
mean.release(); stddev.release()
histSize.release(); histRange.release()
histMat.release(); maskMat.release()
channels.forEach { it.release() }
planes.forEach { it.release() }
}
/**
* Extract 5 gray features from a block ROI.
* Features: grayValue, grayStddev, grayHist, grayMax, grayMin
*/
private fun extractGrayBlock(rgbMat: Mat, features: DoubleArray, offset: Int) {
val grayMat = Mat()
Imgproc.cvtColor(rgbMat, grayMat, Imgproc.COLOR_RGB2GRAY)
// Mean and stddev
val mean = MatOfDouble()
val stddev = MatOfDouble()
Core.meanStdDev(grayMat, mean, stddev)
features[offset + 0] = mean.toArray()[0] // gray value
features[offset + 1] = stddev.toArray()[0] // gray stddev
// Histogram peak
val histSize = MatOfInt(HIST_SIZE)
val histRange = MatOfFloat(HIST_RANGE_START, HIST_RANGE_END)
val histMat = Mat()
val maskMat = Mat()
val channel0 = MatOfInt(0)
Imgproc.calcHist(listOf(grayMat), channel0, maskMat, histMat, histSize, histRange)
val histResult = Core.minMaxLoc(histMat)
features[offset + 2] = histResult.maxLoc.y // gray hist peak
// Min and max
val mmResult = Core.minMaxLoc(grayMat)
features[offset + 3] = mmResult.maxVal // gray max
features[offset + 4] = mmResult.minVal // gray min
// Cleanup
grayMat.release()
mean.release(); stddev.release()
histSize.release(); histRange.release()
histMat.release(); maskMat.release()
channel0.release()
}
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,77 @@
package com.pinkbear.pinkov.ui.scan
/**
* Classification result for ovulation test strip.
*
* @param label Class index (0-4)
* @param name Chinese display name
* @param value Numeric value (LH level)
* @param probabilities Probability distribution across all 5 classes
*/
data class OvResult(
val label: Int,
val name: String,
val value: String,
val probabilities: DoubleArray
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is OvResult) return false
return label == other.label && name == other.name && value == other.value
}
override fun hashCode(): Int = label
companion object {
/** Result mapping: 0=阴性, 1=阴性(10), 2=阳性(25), 3=阳性(50), 4=强阳(75/100) */
private val MAPPING = mapOf(
0 to OvResult(0, "阴性", "0", doubleArrayOf()),
1 to OvResult(1, "阴性", "10", doubleArrayOf()),
2 to OvResult(2, "阳性", "25", doubleArrayOf()),
3 to OvResult(3, "阳性", "50", doubleArrayOf()),
4 to OvResult(4, "强阳", "75/100", doubleArrayOf())
)
fun fromLabel(label: Int, probabilities: DoubleArray): OvResult {
val base = MAPPING[label] ?: OvResult(-1, "未知", "0", probabilities)
return base.copy(probabilities = probabilities)
}
}
}
/**
* Wrapper around the m2cgen-exported random forest model.
*
* The model was trained with 50 trees and min_samples_leaf=20,
* achieving ~99.3% accuracy on the training set.
*
* Input: 60-dimensional feature vector (HSV + gray)
* Output: 5-class probability distribution
*/
object OvClassifier {
/**
* Classify a 60-dimensional feature vector.
*
* @param features 60 HSV+gray features in the exact order expected by the model
* @return [OvResult] with the predicted class and probabilities
*/
fun classify(features: DoubleArray): OvResult {
val probabilities = Model.score(features)
val label = probabilities.indices.maxByOrNull { probabilities[it] } ?: 0
return OvResult.fromLabel(label, probabilities)
}
/**
* Take the mode (众数) of multiple classification results.
*
* @param results List of classification results from multiple frames
* @return The most common result, or null if the list is empty
*/
fun vote(results: List<OvResult>): OvResult? {
if (results.isEmpty()) return null
return results.groupBy { it.label }
.maxByOrNull { it.value.size }
?.value?.first()
}
}
@@ -0,0 +1,47 @@
package com.pinkbear.pinkov.ui.scan
import android.util.Log
import androidx.camera.core.ImageAnalysis
import androidx.camera.core.ImageProxy
import java.nio.ByteBuffer
/**
* CameraX ImageAnalyzer for ovulation test strip scanning.
*
* Captures every camera frame, converts it to an RGBA byte array, and delegates
* to [ScanViewModel] for async strip positioning, feature extraction, and
* classification. The frame-skip guard (AtomicBoolean) in ScanViewModel ensures
* only one frame is processed at a time.
*
* The preview bitmap is updated via [ScanViewModel.state] for Compose UI display.
*/
class OvImageAnalyzer(
private val viewModel: ScanViewModel
) : ImageAnalysis.Analyzer {
private var imageRotationDegrees: Int = 0
override fun analyze(image: ImageProxy) {
imageRotationDegrees = image.imageInfo.rotationDegrees
val width = image.width
val height = image.height
try {
// Extract RGBA bytes from the image plane
val buffer: ByteBuffer = image.planes[0].buffer
val rgbaData = ByteArray(buffer.remaining())
buffer.get(rgbaData)
// Delegate to ViewModel for async processing
viewModel.processFrame(rgbaData, width, height, imageRotationDegrees)
} catch (e: Exception) {
Log.e(TAG, "analyze error: ${e.message}", e)
} finally {
image.close()
}
}
companion object {
private const val TAG = "OvImageAnalyzer"
}
}
@@ -0,0 +1,307 @@
package com.pinkbear.pinkov.ui.scan
import android.graphics.Bitmap
import android.graphics.Matrix
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import android.util.Log
import org.opencv.android.Utils
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.Rect
import java.util.concurrent.atomic.AtomicBoolean
/**
* Scan state for the UI to observe.
*/
data class ScanState(
/** Whether the camera is actively scanning */
val isScanning: Boolean = false,
/** Current frame bitmap for display */
val previewBitmap: Bitmap? = null,
/** Number of successful frames collected */
val frameCount: Int = 0,
/** Total frames needed (15) */
val totalFrames: Int = 15,
/** Final classification result */
val result: OvResult? = null,
/** Error message to display */
val errorMessage: String? = null,
/** Whether to stop the camera (final result obtained) */
val shouldStopCamera: Boolean = false,
/** Monotonically increasing frame version — forces StateFlow emission */
val frameVersion: Long = 0
)
/**
* ViewModel for ovulation test strip scanning.
*
* Manages async frame processing with the following strategy:
* - Every camera frame is passed to the analyzer
* - If a previous frame is still being processed, skip the current frame (AtomicBoolean guard)
* - Process frames in background via coroutines (Dispatchers.Default)
* - Collect 15 successful frames, classify each with random forest
* - Take the mode (众数) of all 15 results as the final answer
* - Stop the camera once the final result is obtained
*/
class ScanViewModel : ViewModel() {
private val _state = MutableStateFlow(ScanState())
val state: StateFlow<ScanState> = _state.asStateFlow()
/** Frame skip guard — true when a frame is being processed */
private val isProcessing = AtomicBoolean(false)
/** Collected results from successful frames */
private val results = mutableListOf<OvResult>()
/** Cached bitmap buffer for reuse */
private var bitmapBuffer: Bitmap? = null
/** ROI rectangle relative to the source image */
private var roiRect: Rect? = null
/** Monotonically increasing frame counter to force StateFlow emissions */
private var frameVersion: Long = 0
/**
* Process a camera frame (RGBA byte array).
* Called from the CameraX analyzer thread.
*
* @param rgbaData RGBA_8888 pixel data
* @param width Image width
* @param height Image height
* @param rotationDegrees Image rotation from CameraX
* @return true if the frame was accepted for processing, false if skipped
*/
fun processFrame(
rgbaData: ByteArray,
width: Int,
height: Int,
rotationDegrees: Int
): Boolean {
// Skip if already done or currently processing
if (_state.value.shouldStopCamera) return false
if (!isProcessing.compareAndSet(false, true)) {
Log.d(TAG, "Frame skipped - already processing")
return false
}
Log.d(TAG, "Frame accepted for processing")
// Process in background via viewModelScope
val data = rgbaData.copyOf()
viewModelScope.launch(Dispatchers.Default) {
try {
processFrameInternal(data, width, height, rotationDegrees)
} catch (e: Exception) {
Log.e(TAG, "processFrame error: ${e.message}", e)
} finally {
isProcessing.set(false)
}
}
return true
}
private suspend fun processFrameInternal(
rgbaData: ByteArray,
width: Int,
height: Int,
rotationDegrees: Int
) = withContext(Dispatchers.Default) {
try {
Log.d(TAG, "Processing frame ${width}x${height} rotation=$rotationDegrees")
// Convert byte array to OpenCV Mat
val srcRaw = Mat(height, width, CvType.CV_8UC4)
srcRaw.put(0, 0, rgbaData)
// Rotate image to upright orientation for the algorithm
// Core.rotate() not available in OpenCV 3.0; use transpose+flip
val srcRgba = when (rotationDegrees) {
90 -> {
val rotated = Mat()
Core.transpose(srcRaw, rotated)
Core.flip(rotated, rotated, 1) // flip horizontally
srcRaw.release()
rotated
}
180 -> {
val rotated = Mat()
Core.flip(srcRaw, rotated, -1) // flip both axes
srcRaw.release()
rotated
}
270 -> {
val rotated = Mat()
Core.transpose(srcRaw, rotated)
Core.flip(rotated, rotated, 0) // flip vertically
srcRaw.release()
rotated
}
else -> srcRaw
}
val procW = srcRgba.width()
val procH = srcRgba.height()
// Define ROI: use the full frame (the user will align the strip)
if (roiRect == null) {
roiRect = Rect(0, 0, procW, procH)
}
// Step 1: Locate the strip
val stripResult = StripLocator.locate(srcRgba, roiRect!!, drawAnnotations = true)
if (stripResult.errorCode != 0) {
// Update preview bitmap even on failure (shows the source image)
Log.d(TAG, "Strip locate failed, errorCode=${stripResult.errorCode}")
val rotated = updatePreview(srcRgba, 0)
frameVersion++
_state.value = _state.value.copy(
previewBitmap = rotated,
frameVersion = frameVersion
)
srcRgba.release()
return@withContext
}
// Step 2: Extract features
val features = FeatureExtractor.extract(
stripResult.leftImg!!,
stripResult.rightImg!!,
stripResult.wbImg!!
)
if (features == null) {
val rotated = updatePreview(srcRgba, 0)
frameVersion++
_state.value = _state.value.copy(
previewBitmap = rotated,
frameVersion = frameVersion
)
srcRgba.release()
stripResult.leftImg?.release()
stripResult.rightImg?.release()
stripResult.wbImg?.release()
return@withContext
}
// Step 3: Classify
val result = OvClassifier.classify(features)
synchronized(results) {
results.add(result)
val count = results.size
Log.d(TAG, "Frame $count/15 classified: ${result.name}")
// Update preview
val rotated = updatePreview(srcRgba, 0)
frameVersion++
_state.value = _state.value.copy(
frameCount = count,
previewBitmap = rotated,
frameVersion = frameVersion
)
// Step 4: Check if we have enough frames
if (count >= 15) {
val finalResult = OvClassifier.vote(results)
_state.value = _state.value.copy(
result = finalResult,
shouldStopCamera = true,
isScanning = false
)
}
}
// Cleanup
srcRgba.release()
stripResult.leftImg?.release()
stripResult.rightImg?.release()
stripResult.wbImg?.release()
} catch (e: Exception) {
Log.e(TAG, "processFrameInternal error: ${e.message}", e)
_state.value = _state.value.copy(
errorMessage = "处理出错: ${e.message}"
)
}
}
/**
* Update the preview bitmap with rotation applied.
*
* @param srcRgba Source Mat in sensor-native orientation
* @param rotationDegrees Rotation from CameraX (typically 90 or 270 for portrait)
* @return The rotated bitmap, or null on failure
*/
private fun updatePreview(srcRgba: Mat, rotationDegrees: Int): Bitmap? {
try {
val w = srcRgba.width()
val h = srcRgba.height()
// Create a temporary bitmap in sensor orientation (don't recycle old — UI may still render it)
if (bitmapBuffer == null
|| bitmapBuffer?.width != w
|| bitmapBuffer?.height != h
) {
bitmapBuffer = Bitmap.createBitmap(w, h, Bitmap.Config.ARGB_8888)
}
if (bitmapBuffer == null) {
Log.e(TAG, "updatePreview: failed to create bitmap")
return null
}
Utils.matToBitmap(srcRgba, bitmapBuffer!!)
// Apply rotation to match display orientation
if (rotationDegrees == 0 || rotationDegrees == 180) {
// No size swap needed, just copy
val copy = bitmapBuffer!!.copy(Bitmap.Config.ARGB_8888, false)
if (rotationDegrees == 180) {
val matrix = Matrix().apply { postRotate(180f) }
return Bitmap.createBitmap(copy, 0, 0, copy.width, copy.height, matrix, true)
}
return copy
} else {
// 90 or 270: swap width and height
val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
return Bitmap.createBitmap(
bitmapBuffer!!, 0, 0, w, h, matrix, true
)
}
} catch (e: Exception) {
Log.e(TAG, "updatePreview failed: ${e.message}", e)
return null
}
}
companion object {
private const val TAG = "ScanViewModel"
}
/** Reset the scan state for a new scan. */
fun reset() {
synchronized(results) {
results.clear()
}
isProcessing.set(false)
_state.value = ScanState()
}
override fun onCleared() {
super.onCleared()
bitmapBuffer?.recycle()
bitmapBuffer = null
}
}
@@ -0,0 +1,855 @@
package com.pinkbear.pinkov.ui.scan
import android.util.Log
import org.opencv.core.Core
import org.opencv.core.CvType
import org.opencv.core.Mat
import org.opencv.core.MatOfDouble
import org.opencv.core.MatOfPoint
import org.opencv.core.MatOfPoint2f
import org.opencv.core.Point
import org.opencv.core.Rect
import org.opencv.core.RotatedRect
import org.opencv.core.Scalar
import org.opencv.core.Size
import org.opencv.imgproc.Imgproc
/**
* Paper color enum for test strip type identification.
*/
enum class PaperColor {
Unknown, Green, Blue, Yellow
}
/**
* Result of strip positioning analysis.
*/
data class StripResult(
/** Error code: 0 = success, negative = failure */
val errorCode: Int = -1,
/** Whether the strip is negative (only C line detected) */
val isNegative: Boolean = false,
/** Left block ROI (T line region) */
val leftImg: Mat? = null,
/** Right block ROI (C line region) */
val rightImg: Mat? = null,
/** White block ROI (between C and T lines) */
val wbImg: Mat? = null,
/** Detected paper color */
val paperColor: PaperColor = PaperColor.Unknown,
/** Image sharpness / definition value */
val definitionValue: Double = 0.0,
/** Contours for drawing bounding boxes (in ROI coordinates) */
val boundContours: List<MatOfPoint> = emptyList(),
/** ROI rectangle in the source image */
val roiRect: Rect? = null,
/** Center point of the strip in source image coordinates */
val stripCenter: Point? = null
)
/**
* Ovulation test strip positioning algorithm.
*
* Ported from the original ImageLocationOvulation.java. Performs the 11-step
* pipeline to locate green ovulation test strips, identify C/T lines, and
* extract the three ROI blocks (left, right, white) for feature extraction.
*
* Only supports green paper strips.
*/
object StripLocator {
// --- Constants ---
private const val H_GREEN_MIN = 30.5
private const val H_GREEN_MAX = 85.0
private const val MIN_PIX_VAL = 4
// Morphology kernels (initialized in init block)
private val kSize1 = Size(15.0, 15.0)
private val kSize2 = Size(15.0, 15.0)
private val anchorPt = Point(-1.0, -1.0)
private val m1: Mat by lazy { Imgproc.getStructuringElement(Imgproc.MORPH_RECT, kSize1) }
private val m2: Mat by lazy { Imgproc.getStructuringElement(Imgproc.MORPH_RECT, kSize2) }
private val white = Scalar(255.0, 255.0, 255.0)
private val black = Scalar(0.0, 0.0, 0.0)
private val red = Scalar(255.0, 0.0, 0.0, 0.0)
private val green = Scalar(0.0, 255.0, 0.0, 0.0)
private val mean = MatOfDouble()
private val dev = MatOfDouble()
// --- Public API ---
/**
* Locate the ovulation test strip in the given image.
*
* @param srcRgba Full RGBA source image from the camera
* @param roiRect Region of interest within the source image to analyze
* @param drawAnnotations Whether to draw bounding boxes on the source image
* @return [StripResult] with extracted ROIs and metadata
*/
fun locate(srcRgba: Mat, roiRect: Rect, drawAnnotations: Boolean = true): StripResult {
var bRot = false
var bNegative = false
var paperColor = PaperColor.Unknown
var errorCode = -1
var definitionValue = 0.0
val bcs = mutableListOf<MatOfPoint>()
val srcWid = roiRect.width
val srcHei = roiRect.height
// Extract ROI
val roiSrc = srcRgba.submat(roiRect)
if (drawAnnotations) {
Imgproc.rectangle(srcRgba,
Point(roiRect.x.toDouble(), roiRect.y.toDouble()),
Point((roiRect.x + roiRect.width).toDouble(), (roiRect.y + roiRect.height).toDouble()),
green, 3
)
}
// --- Step 1: Convert to grayscale and OTSU threshold ---
val s = Mat()
Imgproc.cvtColor(roiSrc, s, Imgproc.COLOR_RGBA2GRAY)
val sCpy = Mat()
s.copyTo(sCpy)
val sThresoldImg = Mat()
Imgproc.threshold(s, sThresoldImg, 128.0, 255.0,
Imgproc.THRESH_BINARY or Imgproc.THRESH_OTSU)
// --- Step 2: Morphological open ---
Imgproc.morphologyEx(sThresoldImg, sThresoldImg, Imgproc.MORPH_OPEN, m2)
// --- Step 3: Find external contours ---
val contours0 = mutableListOf<MatOfPoint>()
Imgproc.findContours(sThresoldImg, contours0, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
if (contours0.isEmpty()) {
Log.d(TAG, "Step 3 fail: no contours found after OTSU+morph")
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -9, paperColor = PaperColor.Unknown)
}
// --- Step 4: Calculate geometric features ---
val gfsList0 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.calContoursGf(contours0, gfsList0, s)
if (gfsList0.size != 2) {
Log.d(TAG, "Step 4 fail: expected 2 contours, got ${gfsList0.size}")
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -9, paperColor = PaperColor.Unknown)
}
// --- Step 5: Sort by area, validate area ratio [1.5, 4.5] ---
ContourSelector.sortContoursByGf(gfsList0, GfFlag.Area, true)
val maxArea = gfsList0[0].second.contourArea
val secondArea = gfsList0[1].second.contourArea
if (maxArea.toDouble() / secondArea < 1.2 || maxArea.toDouble() / secondArea > 12.0) {
Log.d(TAG, "Step 5 fail: area ratio ${maxArea.toDouble() / secondArea} not in [1.2, 12.0]")
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -8, paperColor = PaperColor.Unknown)
}
// --- Step 6: Validate aspect ratio and position ---
val gfp0 = gfsList0[0]
val leftX = gfp0.second.leftTop.x.toInt()
val topY = gfp0.second.leftTop.y.toInt()
val rightX = gfp0.second.rightBottom.x.toInt()
val bottomY = gfp0.second.rightBottom.y.toInt()
val rotRect0 = gfp0.second.rotRect
val angle = rotRect0.angle
val rotRcW = rotRect0.size.width.toInt()
val rotRcH = rotRect0.size.height.toInt()
val realHalfWBig = (Math.max(rotRcW, rotRcH) * 0.5).toInt()
val realHalfHBig = (Math.min(rotRcW, rotRcH) * 0.5).toInt()
val whRatio = realHalfWBig.toDouble() / realHalfHBig
if (whRatio < 4.25 || whRatio > 13.5
|| realHalfHBig < MIN_PIX_VAL * 2
|| leftX - MIN_PIX_VAL <= 0
|| rightX + MIN_PIX_VAL >= srcWid
|| topY - MIN_PIX_VAL <= 0
|| bottomY + MIN_PIX_VAL >= srcHei
) {
Log.d(TAG, "Step 6 fail: whRatio=$whRatio, halfH=$realHalfHBig, bounds=($leftX,$topY)-($rightX,$bottomY) src=${srcWid}x${srcHei}")
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -10, paperColor = PaperColor.Unknown)
}
// --- Step 7: Judge paper color ---
val rcColor1 = Rect(
(rotRect0.center.x - realHalfHBig / 2).toInt(),
(rotRect0.center.y - realHalfHBig / 2).toInt(),
realHalfHBig, realHalfHBig
)
// Edge region for sharpness check
val rcEdge = Rect(rcColor1.x - realHalfHBig, rcColor1.y - realHalfHBig, rcColor1.width, rcColor1.height)
if (rcEdge.x <= 0 || rcEdge.x + rcEdge.width >= srcWid
|| rcEdge.y <= 0 || rcEdge.y + rcEdge.height >= srcHei
|| rcColor1.x <= 0 || rcColor1.x + rcColor1.width >= srcWid
|| rcColor1.y <= 0 || rcColor1.y + rcColor1.height >= srcHei
) {
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -11, paperColor = PaperColor.Unknown)
}
// --- Step 8: Evaluate image sharpness with Scharr operator ---
val matEdge = sCpy.submat(rcEdge)
val diffRef = doubleArrayOf(0.0)
val lowRef = intArrayOf(0)
val topRef = intArrayOf(0)
definitionValue = calImgDefinition(matEdge, diffRef, lowRef, topRef)
matEdge.release()
if (definitionValue < 2 || diffRef[0] < 100 || lowRef[0] > 12 || topRef[0] < 1) {
errorCode = -1
// Continue anyway — original code warned but didn't always return
}
// --- Step 9: Judge paper color of the larger block ---
val mc1 = roiSrc.submat(rcColor1)
paperColor = judgePaperColor(mc1)
mc1.release()
if (paperColor == PaperColor.Unknown || paperColor != PaperColor.Green) {
Log.d(TAG, "Step 9 fail: paperColor=$paperColor")
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -1, paperColor = paperColor)
}
// --- Step 10: Compute rotation correction ---
val bWgtH: Boolean
val realAngle: Double
if (rotRcH < rotRcW) {
bWgtH = true
realAngle = -angle
} else if (rotRcH > rotRcW) {
bWgtH = false
realAngle = -angle - 90
} else {
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -1, paperColor = paperColor)
}
// Verify second block is also green
val gfp1 = gfsList0[1]
val rotRect1 = gfp1.second.rotRect
val rotRcWSmall = rotRect1.size.width.toInt()
val rotRcHSmall = rotRect1.size.height.toInt()
val realHalfWSmall = (Math.max(rotRcWSmall, rotRcHSmall) * 0.5).toInt()
val realHalfHSmall = (Math.min(rotRcWSmall, rotRcHSmall) * 0.5).toInt()
val rcColor2 = Rect(
(rotRect1.center.x - realHalfHSmall / 2).toInt(),
(rotRect1.center.y - realHalfHSmall / 2).toInt(),
realHalfHSmall, realHalfHSmall
)
val mc2 = roiSrc.submat(rcColor2)
val color2 = judgePaperColor(mc2)
mc2.release()
if (paperColor != color2) {
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -1, paperColor = paperColor)
}
// Validate center distance ratio
val centerPointDist = Math.sqrt(
(rotRect0.center.x - rotRect1.center.x) * (rotRect0.center.x - rotRect1.center.x)
+ (rotRect0.center.y - rotRect1.center.y) * (rotRect0.center.y - rotRect1.center.y)
)
val distRadio = centerPointDist / (realHalfWBig * 2.0)
if (distRadio < 1 || distRadio > 1.65) {
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
return StripResult(errorCode = -1, paperColor = paperColor)
}
// --- Step 11: Compute rotated ROI for the strip area ---
var x0: Double
var y0: Double
var x1: Double
var y1: Double
val phi = realAngle * Math.PI / 180
val sin = Math.sin(phi)
val cos = Math.cos(phi)
var shiftBig = 1.1
var shiftSmall = 1.65
when {
distRadio > 1.4 -> shiftBig = 1.25
distRadio > 1.3 -> shiftBig = 1.2
distRadio > 1.2 -> shiftBig = 1.15
}
if (rotRect0.center.x > rotRect1.center.x) {
bRot = false
x0 = rotRect1.center.x + (realHalfWSmall * shiftSmall) * cos
y0 = rotRect1.center.y - (realHalfWSmall * shiftSmall) * sin
x1 = rotRect0.center.x - cos * (realHalfWBig * shiftBig)
y1 = rotRect0.center.y + sin * (realHalfWBig * shiftBig)
} else {
bRot = true
x0 = rotRect0.center.x + (realHalfWBig * shiftBig) * cos
y0 = rotRect0.center.y - (realHalfWBig * shiftBig) * sin
x1 = rotRect1.center.x - cos * (realHalfWSmall * shiftSmall)
y1 = rotRect1.center.y + sin * (realHalfWSmall * shiftSmall)
}
val len1 = Math.sqrt((x0 - x1) * (x0 - x1) + (y0 - y1) * (y0 - y1)).toInt()
val len2 = (realHalfHBig * 1.5).toInt()
val centerX = ((x0 + x1) * 0.5).toInt()
val centerY = ((y0 + y1) * 0.5).toInt()
val rotRect1C = Point(centerX.toDouble(), centerY.toDouble())
val rotRc1Size = if (bWgtH) Size(len1.toDouble(), len2.toDouble())
else Size(len2.toDouble(), len1.toDouble())
// Build rotated rect and mask
val rotRect2 = RotatedRect(rotRect1C, rotRc1Size, angle)
val vPoints = arrayOfNulls<Point>(4)
rotRect2.points(vPoints)
val buildContour = MatOfPoint(*vPoints.requireNoNulls())
val buildContours = listOf(buildContour)
val mask = Mat(roiRect.height, roiRect.width, CvType.CV_8UC3)
mask.setTo(black)
Imgproc.drawContours(mask, buildContours, 0, white, -1)
val maskSrc = Mat()
Core.bitwise_and(roiSrc, mask, maskSrc)
val autoRoi = Imgproc.boundingRect(buildContour)
if (autoRoi.x <= 0 || autoRoi.y <= 0
|| (autoRoi.x + autoRoi.width) >= (maskSrc.width() - 1)
|| (autoRoi.y + autoRoi.height) >= (maskSrc.height() - 1)
) {
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
mask.release(); maskSrc.release()
return StripResult(errorCode = -11, paperColor = paperColor)
}
val autoRoiSrc = maskSrc.submat(autoRoi)
// Warp affine for rotation correction
var rotAngle = angle
if (rotAngle < -45) rotAngle += 90.0
val autoRoiCenter = Point(autoRoiSrc.width() * 0.5, autoRoiSrc.height() * 0.5)
val waMat = Imgproc.getRotationMatrix2D(autoRoiCenter, rotAngle, 1.0)
val waRoiSrc = Mat()
Imgproc.warpAffine(autoRoiSrc, waRoiSrc, waMat, autoRoiSrc.size(), Imgproc.INTER_LINEAR)
waMat.release()
val rotRect2W = Math.max(rotRect2.size.width, rotRect2.size.height).toInt()
val rotRect2H = Math.min(rotRect2.size.width, rotRect2.size.height).toInt()
val roi = Rect(
(autoRoiCenter.x - rotRect2W * 0.45).toInt(),
(autoRoiCenter.y - rotRect2H * 0.45).toInt(),
(rotRect2W * 0.9).toInt(),
(rotRect2H * 0.9).toInt()
)
val lastRoiSrc = waRoiSrc.submat(roi)
// Extract G channel
val lastRoiSrcMats = mutableListOf<Mat>()
Core.split(lastRoiSrc, lastRoiSrcMats)
val lastRoiG = lastRoiSrcMats[1] // G channel
// Denoise
Imgproc.medianBlur(lastRoiG, lastRoiG, 3)
Imgproc.GaussianBlur(lastRoiG, lastRoiG, Size(5.0, 5.0), 0.0, 0.0)
// Adaptive threshold for C/T line detection
val matInternal = Mat()
Imgproc.adaptiveThreshold(lastRoiG, matInternal, 255.0,
Imgproc.ADAPTIVE_THRESH_MEAN_C, Imgproc.THRESH_BINARY_INV, 37, 4.0)
// Find internal contours (C/T lines)
val contoursInternal = mutableListOf<MatOfPoint>()
val kernel = Mat()
Imgproc.findContours(matInternal, contoursInternal, kernel, Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
kernel.release()
val gfsList10 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.calContoursGf(contoursInternal, gfsList10, lastRoiG)
if (gfsList10.isEmpty()) {
cleanup(s, sCpy, sThresoldImg, roiSrc, mask, maskSrc, autoRoiSrc, waRoiSrc,
lastRoiSrc, lastRoiG, matInternal, lastRoiSrcMats)
return StripResult(errorCode = -7, paperColor = paperColor)
}
// Filter contours by position and size
val gfsList01 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList10, gfsList01,
lastRoiG.cols() * 0.15, lastRoiG.cols() * 0.95, GfFlag.RightBottomX)
val gfsList110 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList01, gfsList110,
lastRoiG.cols() * 0.05, lastRoiG.cols() * 0.9, GfFlag.LeftTopX)
val gfsList101 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList110, gfsList101, 0.30, 1.15, GfFlag.Rectangularity)
val gfsList102 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList101, gfsList102,
lastRoiG.cols() * 0.03, lastRoiG.cols() * 0.25, GfFlag.Width)
val gfsList11 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList102, gfsList11,
lastRoiG.rows() * 0.4, lastRoiG.rows() * 1.1, GfFlag.Height)
Log.d(TAG, "locate C/T filter stages: raw=${gfsList10.size}" +
"rbX=${gfsList01.size} → ltX=${gfsList110.size}" +
"rect=${gfsList101.size} → w=${gfsList102.size} → h=${gfsList11.size}")
ContourSelector.sortContoursByGf(gfsList11, GfFlag.CenterX, false)
// Keep only the 2 contours closest to the image center (C/T lines)
if (gfsList11.size > 2) {
val centerX = lastRoiG.cols() / 2.0
val centerY = lastRoiG.rows() / 2.0
gfsList11.sortBy { (_, gf) ->
val dx = gf.contourCenter.x - centerX
val dy = gf.contourCenter.y - centerY
dx * dx + dy * dy
}
while (gfsList11.size > 2) {
gfsList11.removeAt(gfsList11.size - 1)
}
gfsList11.sortBy { it.second.contourCenter.x }
}
// --- Extract ROIs ---
val leftImg = Mat()
val rightImg = Mat()
val wbImg = Mat()
when (gfsList11.size) {
2 -> {
// Normal case: C line and T line both detected
val gfLeft = gfsList11[0]
val gfRight = gfsList11[1]
val leftRotRect = gfLeft.second.rotRect
val rightRotRect = gfRight.second.rotRect
val r1 = leftRotRect.boundingRect()
val r2 = rightRotRect.boundingRect()
val wbCenterX = ((leftRotRect.center.x + rightRotRect.center.x) * 0.5).toInt()
val wbCenterY = ((leftRotRect.center.y + rightRotRect.center.y) * 0.5).toInt()
val w = ((r1.width + r2.width) * 0.2).toInt()
val wbRoi = Rect(wbCenterX - w, wbCenterY - w, w * 2, w * 2)
// Clamp rects to image bounds
val r1C = clampRect(r1, lastRoiSrc.cols(), lastRoiSrc.rows())
val r2C = clampRect(r2, lastRoiSrc.cols(), lastRoiSrc.rows())
val wbRoiC = clampRect(wbRoi, lastRoiSrc.cols(), lastRoiSrc.rows())
if (wbRoiC.height < MIN_PIX_VAL || wbRoiC.width < MIN_PIX_VAL) {
cleanup(s, sCpy, sThresoldImg, roiSrc, mask, maskSrc, autoRoiSrc, waRoiSrc,
lastRoiSrc, lastRoiG, matInternal, lastRoiSrcMats)
return StripResult(errorCode = 3, paperColor = paperColor)
}
val mat1 = lastRoiSrc.submat(r1C)
val mat2 = lastRoiSrc.submat(r2C)
val wbMat = lastRoiSrc.submat(wbRoiC)
wbMat.copyTo(wbImg)
if (!bRot) {
mat1.copyTo(leftImg)
mat2.copyTo(rightImg)
} else {
mat1.copyTo(rightImg)
mat2.copyTo(leftImg)
}
bcs.addAll(buildContours)
errorCode = 0
}
1 -> {
// Negative case: only C line detected, create artificial T line
val gfInternal = gfsList11[0]
val rotRect = gfInternal.second.rotRect
val innerRoi = rotRect.boundingRect()
val innerRoiC = clampRect(innerRoi, lastRoiSrc.cols(), lastRoiSrc.rows())
if ((innerRoiC.y + innerRoiC.height) > (lastRoiSrc.rows() - 1)
|| (innerRoiC.x + innerRoiC.width) > lastRoiSrc.cols()
) {
cleanup(s, sCpy, sThresoldImg, roiSrc, mask, maskSrc, autoRoiSrc, waRoiSrc,
lastRoiSrc, lastRoiG, matInternal, lastRoiSrcMats)
return StripResult(errorCode = 3, paperColor = paperColor)
}
val gMidX = (lastRoiG.cols() * 0.5).toInt()
val cPt = Point((centerX + roiRect.x).toDouble(), (centerY + roiRect.y).toDouble())
val bRight = !bRot && rotRect.center.x > gMidX
val bLeft = bRot && rotRect.center.x < gMidX
if (bRight || bLeft) {
bNegative = true
val dw1 = (rotRect2H * 0.15).toInt()
val dw2 = dw1 * 2
// Extract white balance ROI from source
val rc1 = Rect(cPt.x.toInt() - dw1, cPt.y.toInt() - dw1, dw2, dw2)
val rc1C = clampRect(rc1, srcRgba.cols(), srcRgba.rows())
srcRgba.submat(rc1C).copyTo(wbImg)
// Create artificial T line ROI
val rc2 = Rect(cPt.x.toInt() - dw1, cPt.y.toInt() - dw1, dw2, dw2)
rc2.y = innerRoiC.y
rc2.width = innerRoiC.width
rc2.height = innerRoiC.height
if (bRight) {
// Artificial T line to the left
rc2.x = innerRoiC.x - innerRoiC.width * 4
}
if (bLeft) {
// Artificial T line to the right
rc2.x = innerRoiC.x + innerRoiC.width * 4
}
val rc2C = clampRect(rc2, lastRoiSrc.cols(), lastRoiSrc.rows())
val matt1 = lastRoiSrc.submat(innerRoiC)
val matt2 = lastRoiSrc.submat(rc2C)
matt1.copyTo(rightImg)
matt2.copyTo(leftImg)
bcs.addAll(buildContours)
errorCode = 0
}
}
else -> {
errorCode = 100
}
}
// Cleanup intermediate mats
cleanup(s, sCpy, sThresoldImg, roiSrc, mask, maskSrc, autoRoiSrc, waRoiSrc,
lastRoiSrc, lastRoiG, matInternal, lastRoiSrcMats)
return StripResult(
errorCode = errorCode,
isNegative = bNegative,
leftImg = if (leftImg.empty()) null else leftImg,
rightImg = if (rightImg.empty()) null else rightImg,
wbImg = if (wbImg.empty()) null else wbImg,
paperColor = paperColor,
definitionValue = definitionValue,
boundContours = bcs.toList(),
roiRect = roiRect,
stripCenter = Point((centerX + roiRect.x).toDouble(), (centerY + roiRect.y).toDouble())
)
}
/**
* Detect C/T lines and extract ROIs from an already-cropped strip image.
*
* This is a simplified pipeline for pre-cropped strip images (e.g., from the
* papers/ directory). It skips the paper-background location steps (OTSU,
* morphology, green block detection) and goes directly to C/T line detection
* via adaptive thresholding.
*
* @param stripRgb The cropped strip image in RGB format (3 channels)
* @return [StripResult] with extracted ROIs and metadata
*/
fun locateCroppedStrip(stripRgb: Mat): StripResult {
val imgW = stripRgb.cols()
val imgH = stripRgb.rows()
Log.d(TAG, "locateCroppedStrip: ${imgW}x${imgH}")
// Extract G channel
val channels = mutableListOf<Mat>()
Core.split(stripRgb, channels)
val gChannel = channels[1]
channels[0].release()
channels[2].release()
// Denoise
Imgproc.medianBlur(gChannel, gChannel, 3)
Imgproc.GaussianBlur(gChannel, gChannel, Size(5.0, 5.0), 0.0, 0.0)
// Adaptive threshold — scale block size for small images
val blockSize = minOf(37, maxOf(3, imgH / 2) or 1)
val matInternal = Mat()
Imgproc.adaptiveThreshold(gChannel, matInternal, 255.0,
Imgproc.ADAPTIVE_THRESH_MEAN_C, Imgproc.THRESH_BINARY_INV, blockSize, 4.0)
// Find internal contours (C/T lines)
val contoursInternal = mutableListOf<MatOfPoint>()
Imgproc.findContours(matInternal, contoursInternal, Mat(), Imgproc.RETR_EXTERNAL, Imgproc.CHAIN_APPROX_SIMPLE)
if (contoursInternal.isEmpty()) {
Log.d(TAG, "locateCroppedStrip: no C/T line contours found")
gChannel.release(); matInternal.release()
return StripResult(errorCode = -7, paperColor = PaperColor.Green)
}
val gfsList10 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.calContoursGf(contoursInternal, gfsList10, gChannel)
if (gfsList10.isEmpty()) {
Log.d(TAG, "locateCroppedStrip: no valid contours after GF calc")
gChannel.release(); matInternal.release()
return StripResult(errorCode = -7, paperColor = PaperColor.Green)
}
// Filter contours by position and size (relative to image dimensions)
val gfsList01 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList10, gfsList01,
imgW * 0.15, imgW * 0.95, GfFlag.RightBottomX)
val gfsList110 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList01, gfsList110,
imgW * 0.05, imgW * 0.9, GfFlag.LeftTopX)
val gfsList101 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList110, gfsList101, 0.30, 1.15, GfFlag.Rectangularity)
val gfsList102 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList101, gfsList102,
imgW * 0.01, imgW * 0.35, GfFlag.Width)
val gfsList11 = mutableListOf<Pair<MatOfPoint, ContourGf>>()
ContourSelector.selectContour(gfsList102, gfsList11,
imgH * 0.15, imgH * 1.1, GfFlag.Height)
Log.d(TAG, "locateCroppedStrip filter stages: raw=${gfsList10.size}" +
"rbX=${gfsList01.size} → ltX=${gfsList110.size}" +
"rect=${gfsList101.size} → w=${gfsList102.size} → h=${gfsList11.size}")
ContourSelector.sortContoursByGf(gfsList11, GfFlag.CenterX, false)
// Keep only the 2 contours closest to the image center (C/T lines)
if (gfsList11.size > 2) {
// Sort by distance from image center
val centerX = imgW / 2.0
val centerY = imgH / 2.0
gfsList11.sortBy { (_, gf) ->
val dx = gf.contourCenter.x - centerX
val dy = gf.contourCenter.y - centerY
dx * dx + dy * dy
}
// Keep only the 2 closest to center
while (gfsList11.size > 2) {
gfsList11.removeAt(gfsList11.size - 1)
}
// Re-sort by center X for left/right ordering
gfsList11.sortBy { it.second.contourCenter.x }
}
Log.d(TAG, "locateCroppedStrip: found ${gfsList11.size} C/T line(s) after filtering")
// Extract ROIs
val leftImg = Mat()
val rightImg = Mat()
val wbImg = Mat()
var errorCode = 100
var isNegative = false
when (gfsList11.size) {
2 -> {
val gfLeft = gfsList11[0]
val gfRight = gfsList11[1]
val leftRotRect = gfLeft.second.rotRect
val rightRotRect = gfRight.second.rotRect
val r1 = leftRotRect.boundingRect()
val r2 = rightRotRect.boundingRect()
val wbCenterX = ((leftRotRect.center.x + rightRotRect.center.x) * 0.5).toInt()
val wbCenterY = ((leftRotRect.center.y + rightRotRect.center.y) * 0.5).toInt()
val w = ((r1.width + r2.width) * 0.2).toInt()
val wbRoi = Rect(wbCenterX - w, wbCenterY - w, w * 2, w * 2)
val r1C = clampRect(r1, imgW, imgH)
val r2C = clampRect(r2, imgW, imgH)
val wbRoiC = clampRect(wbRoi, imgW, imgH)
if (wbRoiC.height >= MIN_PIX_VAL && wbRoiC.width >= MIN_PIX_VAL) {
stripRgb.submat(r1C).copyTo(leftImg)
stripRgb.submat(r2C).copyTo(rightImg)
stripRgb.submat(wbRoiC).copyTo(wbImg)
errorCode = 0
} else {
errorCode = 3
}
}
1 -> {
// Single C/T line — negative case
isNegative = true
val gfInternal = gfsList11[0]
val rotRect = gfInternal.second.rotRect
val innerRoi = rotRect.boundingRect()
val innerRoiC = clampRect(innerRoi, imgW, imgH)
if (innerRoiC.y + innerRoiC.height <= imgH - 1
&& innerRoiC.x + innerRoiC.width <= imgW
) {
stripRgb.submat(innerRoiC).copyTo(rightImg)
// Create artificial T line ROI to the left
val artX = maxOf(0, innerRoiC.x - innerRoiC.width * 4)
val artRoi = Rect(artX, innerRoiC.y, innerRoiC.width, innerRoiC.height)
val artRoiC = clampRect(artRoi, imgW, imgH)
stripRgb.submat(artRoiC).copyTo(leftImg)
// White balance from middle of strip
val midX = imgW / 2
val midY = imgH / 2
val wbSize = minOf(imgW / 6, imgH / 2)
val wbRoi = Rect(maxOf(0, midX - wbSize), maxOf(0, midY - wbSize),
minOf(wbSize * 2, imgW - maxOf(0, midX - wbSize)),
minOf(wbSize * 2, imgH - maxOf(0, midY - wbSize)))
val wbRoiC = clampRect(wbRoi, imgW, imgH)
stripRgb.submat(wbRoiC).copyTo(wbImg)
errorCode = 0
}
}
else -> {
errorCode = 100
}
}
gChannel.release(); matInternal.release()
Log.d(TAG, "locateCroppedStrip: errorCode=$errorCode, isNegative=$isNegative")
return StripResult(
errorCode = errorCode,
isNegative = isNegative,
leftImg = if (leftImg.empty()) null else leftImg,
rightImg = if (rightImg.empty()) null else rightImg,
wbImg = if (wbImg.empty()) null else wbImg,
paperColor = PaperColor.Green
)
}
// --- Private helpers ---
/**
* Judge paper color from an HSV analysis of the image region.
* Only returns [PaperColor.Green] or [PaperColor.Unknown] for green strips.
*/
private fun judgePaperColor(img: Mat): PaperColor {
Imgproc.blur(img, img, Size(3.0, 3.0))
val mcAvgBgr = Core.mean(img)
val avgB = mcAvgBgr.`val`[2]
val avgG = mcAvgBgr.`val`[1]
val avgR = mcAvgBgr.`val`[0]
Imgproc.cvtColor(img, img, Imgproc.COLOR_RGB2HSV)
val mvHsv = mutableListOf<Mat>()
Core.split(img, mvHsv)
val mcH = mvHsv[0]
Core.meanStdDev(mcH, mean, dev)
val avgH = mean.toArray()[0]
val d = dev.toArray()[0]
// Release split channels
mvHsv.forEach { it.release() }
if (d < 22.5) {
if ((avgG > avgB && avgG > avgR) && (avgH >= H_GREEN_MIN && avgH <= H_GREEN_MAX)) {
return PaperColor.Green
}
}
return PaperColor.Unknown
}
/**
* Calculate image definition using the Scharr operator.
* Returns the maximum ratio between adjacent row averages.
*/
private fun calImgDefinition(
img: Mat,
diffOut: DoubleArray, // [0] = max adjacent difference
lowLinesOut: IntArray, // [0] = count of rows with val > 60
upLinesOut: IntArray // [0] = count of rows with val > 200
): Double {
val sobelX = Mat()
val sobelY = Mat()
val absSum = Mat()
val scalar = Scalar(0.0)
Imgproc.Scharr(img, sobelX, CvType.CV_16S, 1, 0)
Imgproc.Scharr(img, sobelY, CvType.CV_16S, 0, 1)
Core.absdiff(sobelX, scalar, sobelX)
Core.absdiff(sobelY, scalar, sobelY)
Core.add(sobelX, sobelY, absSum)
absSum.convertTo(absSum, CvType.CV_8UC1, 0.25)
var pre = 0.0
var diff = 0.0
var multiple = 0.0
var lowLines = 0
var upLines = 0
for (y in 0 until absSum.rows()) {
val sum = Core.sumElems(absSum.row(y))
val avg = sum.`val`[0] / absSum.cols()
if (pre == 0.0) pre = avg
if (pre == 0.0) pre = 1.0
if (avg < 10) {
pre = avg
continue
}
if (avg > 60) lowLines++
if (avg > 200) upLines++
if (Math.abs(avg - pre) > diff) {
multiple = Math.max(avg, pre) / Math.min(avg, pre)
diff = Math.abs(avg - pre)
}
pre = avg
}
sobelX.release(); sobelY.release(); absSum.release()
diffOut[0] = diff
lowLinesOut[0] = lowLines
upLinesOut[0] = upLines
return multiple
}
/** Clamp a rectangle to image bounds. */
private fun clampRect(r: Rect, imgW: Int, imgH: Int): Rect {
var x = r.x; var y = r.y; var w = r.width; var h = r.height
if (x < 1) x = 0
if (y < 1) y = 0
if (x + w > imgW - 1) w = imgW - x - 1
if (y + h > imgH - 1) h = imgH - y - 1
return Rect(x, y, maxOf(w, 0), maxOf(h, 0))
}
/** Release multiple intermediate Mats. */
private fun cleanup(vararg mats: Mat) {
mats.forEach { it.release() }
}
private fun cleanup(
s: Mat, sCpy: Mat, sThresoldImg: Mat, roiSrc: Mat,
mask: Mat, maskSrc: Mat, autoRoiSrc: Mat, waRoiSrc: Mat,
lastRoiSrc: Mat, lastRoiG: Mat, matInternal: Mat,
lastRoiSrcMats: List<Mat>
) {
s.release(); sCpy.release(); sThresoldImg.release(); roiSrc.release()
mask.release(); maskSrc.release(); autoRoiSrc.release(); waRoiSrc.release()
lastRoiSrc.release(); lastRoiG.release(); matInternal.release()
lastRoiSrcMats.forEach { it.release() }
}
private const val TAG = "StripLocator"
}
@@ -0,0 +1,11 @@
package com.pinkbear.pinkov.ui.theme
import androidx.compose.ui.graphics.Color
val Purple80 = Color(0xFFD0BCFF)
val PurpleGrey80 = Color(0xFFCCC2DC)
val Pink80 = Color(0xFFEFB8C8)
val Purple40 = Color(0xFF6650a4)
val PurpleGrey40 = Color(0xFF625b71)
val Pink40 = Color(0xFF7D5260)
@@ -0,0 +1,58 @@
package com.pinkbear.pinkov.ui.theme
import android.app.Activity
import android.os.Build
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.darkColorScheme
import androidx.compose.material3.dynamicDarkColorScheme
import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.material3.lightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
private val DarkColorScheme = darkColorScheme(
primary = Purple80,
secondary = PurpleGrey80,
tertiary = Pink80
)
private val LightColorScheme = lightColorScheme(
primary = Purple40,
secondary = PurpleGrey40,
tertiary = Pink40
/* Other default colors to override
background = Color(0xFFFFFBFE),
surface = Color(0xFFFFFBFE),
onPrimary = Color.White,
onSecondary = Color.White,
onTertiary = Color.White,
onBackground = Color(0xFF1C1B1F),
onSurface = Color(0xFF1C1B1F),
*/
)
@Composable
fun PinkOVTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
// Dynamic color is available on Android 12+
dynamicColor: Boolean = true,
content: @Composable () -> Unit
) {
val colorScheme = when {
dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> {
val context = LocalContext.current
if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context)
}
darkTheme -> DarkColorScheme
else -> LightColorScheme
}
MaterialTheme(
colorScheme = colorScheme,
typography = Typography,
content = content
)
}
@@ -0,0 +1,34 @@
package com.pinkbear.pinkov.ui.theme
import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
// Set of Material typography styles to start with
val Typography = Typography(
bodyLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 16.sp,
lineHeight = 24.sp,
letterSpacing = 0.5.sp
)
/* Other default text styles to override
titleLarge = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Normal,
fontSize = 22.sp,
lineHeight = 28.sp,
letterSpacing = 0.sp
),
labelSmall = TextStyle(
fontFamily = FontFamily.Default,
fontWeight = FontWeight.Medium,
fontSize = 11.sp,
lineHeight = 16.sp,
letterSpacing = 0.5.sp
)
*/
)
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M180,743Q240,687 315.9,652.5Q391.79,618 479.9,618Q568,618 644,652.5Q720,687 780,743L780,180Q780,180 780,180Q780,180 780,180L180,180Q180,180 180,180Q180,180 180,180L180,743ZM482,539Q540,539 580,499Q620,459 620,401Q620,343 580,303Q540,263 482,263Q424,263 384,303Q344,343 344,401Q344,459 384,499Q424,539 482,539ZM180,840Q156,840 138,822Q120,804 120,780L120,180Q120,156 138,138Q156,120 180,120L780,120Q804,120 822,138Q840,156 840,180L840,780Q840,804 822,822Q804,840 780,840L180,840ZM223,780L736,780Q736,780 736,780Q736,780 736,780Q674,727 610.5,702.5Q547,678 480,678Q413,678 349.5,702.5Q286,727 223,780Q223,780 223,780Q223,780 223,780ZM482,479Q449.5,479 426.75,456.25Q404,433.5 404,401Q404,368.5 426.75,345.75Q449.5,323 482,323Q514.5,323 537.25,345.75Q560,368.5 560,401Q560,433.5 537.25,456.25Q514.5,479 482,479ZM480,461L480,461Q480,461 480,461Q480,461 480,461L480,461Q480,461 480,461Q480,461 480,461L480,461Q480,461 480,461Q480,461 480,461Q480,461 480,461Q480,461 480,461Z" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M240,760L360,760L360,520L600,520L600,760L720,760L720,400L480,220L240,400L240,760ZM160,840L160,360L480,120L800,360L800,840L520,840L520,600L440,600L440,840L160,840ZM480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490L480,490Z" />
</vector>
@@ -0,0 +1,170 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#3DDC84"
android:pathData="M0,0h108v108h-108z" />
<path
android:fillColor="#00000000"
android:pathData="M9,0L9,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,0L19,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,0L29,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,0L39,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,0L49,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,0L59,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,0L69,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,0L79,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M89,0L89,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M99,0L99,108"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,9L108,9"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,19L108,19"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,29L108,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,39L108,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,49L108,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,59L108,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,69L108,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,79L108,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,89L108,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M0,99L108,99"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,29L89,29"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,39L89,39"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,49L89,49"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,59L89,59"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,69L89,69"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M19,79L89,79"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M29,19L29,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M39,19L39,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M49,19L49,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M59,19L59,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M69,19L69,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
<path
android:fillColor="#00000000"
android:pathData="M79,19L79,89"
android:strokeWidth="0.8"
android:strokeColor="#33FFFFFF" />
</vector>
@@ -0,0 +1,30 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path android:pathData="M31,63.928c0,0 6.4,-11 12.1,-13.1c7.2,-2.6 26,-1.4 26,-1.4l38.1,38.1L107,108.928l-32,-1L31,63.928z">
<aapt:attr name="android:fillColor">
<gradient
android:endX="85.84757"
android:endY="92.4963"
android:startX="42.9492"
android:startY="49.59793"
android:type="linear">
<item
android:color="#44000000"
android:offset="0.0" />
<item
android:color="#00000000"
android:offset="1.0" />
</gradient>
</aapt:attr>
</path>
<path
android:fillColor="#FFFFFF"
android:fillType="nonZero"
android:pathData="M65.3,45.828l3.8,-6.6c0.2,-0.4 0.1,-0.9 -0.3,-1.1c-0.4,-0.2 -0.9,-0.1 -1.1,0.3l-3.9,6.7c-6.3,-2.8 -13.4,-2.8 -19.7,0l-3.9,-6.7c-0.2,-0.4 -0.7,-0.5 -1.1,-0.3C38.8,38.328 38.7,38.828 38.9,39.228l3.8,6.6C36.2,49.428 31.7,56.028 31,63.928h46C76.3,56.028 71.8,49.428 65.3,45.828zM43.4,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2c-0.3,-0.7 -0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C45.3,56.528 44.5,57.328 43.4,57.328L43.4,57.328zM64.6,57.328c-0.8,0 -1.5,-0.5 -1.8,-1.2s-0.1,-1.5 0.4,-2.1c0.5,-0.5 1.4,-0.7 2.1,-0.4c0.7,0.3 1.2,1 1.2,1.8C66.5,56.528 65.6,57.328 64.6,57.328L64.6,57.328z"
android:strokeWidth="1"
android:strokeColor="#00000000" />
</vector>
@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="48dp"
android:height="48dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="@android:color/white"
android:pathData="M480,480Q528,480 560,448Q592,416 592,368Q592,320 560,288Q528,256 480,256Q432,256 400,288Q368,320 368,368Q368,416 400,448Q432,480 480,480ZM160,800L160,704Q160,670 185,649Q210,628 256,628L704,628Q750,628 775,649Q800,670 800,704L800,800L160,800Z" />
</vector>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@drawable/ic_launcher_background" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
<monochrome android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">PinkOV</string>
<string name="tab_home">Home</string>
<string name="tab_mine">Mine</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PinkOV" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.pinkbear.pinkov
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}
+5
View File
@@ -0,0 +1,5 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
alias(libs.plugins.android.application) apply false
alias(libs.plugins.kotlin.compose) apply false
}
+636
View File
@@ -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 线的情况 |
+15
View File
@@ -0,0 +1,15 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects
# org.gradle.parallel=true
# Kotlin code style for this project: "official" or "obsolete":
kotlin.code.style=official
+12
View File
@@ -0,0 +1,12 @@
#This file is generated by updateDaemonJvm
toolchainUrl.FREE_BSD.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.FREE_BSD.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.LINUX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.LINUX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.MAC_OS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/73bcfb608d1fde9fb62e462f834a3299/redirect
toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/846ee0d876d26a26f37aa1ce8de73224/redirect
toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/ec7520a1e057cd116f9544c42142a16b/redirect
toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/4c4f879899012ff0a8b2e2117df03b0e/redirect
toolchainUrl.WINDOWS.AARCH64=https\://api.foojay.io/disco/v3.0/ids/9482ddec596298c84656d31d16652665/redirect
toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/39701d92e1756bb2f141eb67cd4c660e/redirect
toolchainVersion=21
+40
View File
@@ -0,0 +1,40 @@
[versions]
agp = "9.2.1"
coreKtx = "1.10.1"
junit = "4.13.2"
junitVersion = "1.1.5"
espressoCore = "3.5.1"
lifecycleRuntimeKtx = "2.8.7"
activityCompose = "1.8.0"
kotlin = "2.2.10"
composeBom = "2025.12.00"
camerax = "1.4.1"
accompanist = "0.36.0"
[libraries]
androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" }
junit = { group = "junit", name = "junit", version.ref = "junit" }
androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "junitVersion" }
androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" }
androidx-lifecycle-runtime-ktx = { group = "androidx.lifecycle", name = "lifecycle-runtime-ktx", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-runtime-compose = { group = "androidx.lifecycle", name = "lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-lifecycle-viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycleRuntimeKtx" }
androidx-activity-compose = { group = "androidx.activity", name = "activity-compose", version.ref = "activityCompose" }
androidx-compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" }
androidx-compose-ui = { group = "androidx.compose.ui", name = "ui" }
androidx-compose-ui-graphics = { group = "androidx.compose.ui", name = "ui-graphics" }
androidx-compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling" }
androidx-compose-ui-tooling-preview = { group = "androidx.compose.ui", name = "ui-tooling-preview" }
androidx-compose-ui-test-manifest = { group = "androidx.compose.ui", name = "ui-test-manifest" }
androidx-compose-ui-test-junit4 = { group = "androidx.compose.ui", name = "ui-test-junit4" }
androidx-compose-material3 = { group = "androidx.compose.material3", name = "material3" }
androidx-compose-material3-adaptive-navigation-suite = { group = "androidx.compose.material3", name = "material3-adaptive-navigation-suite" }
androidx-camera-core = { group = "androidx.camera", name = "camera-core", version.ref = "camerax" }
androidx-camera-camera2 = { group = "androidx.camera", name = "camera-camera2", version.ref = "camerax" }
androidx-camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", version.ref = "camerax" }
accompanist-permissions = { group = "com.google.accompanist", name = "accompanist-permissions", version.ref = "accompanist" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
Binary file not shown.
+9
View File
@@ -0,0 +1,9 @@
#Wed Jul 08 15:35:02 CST 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=2ab2958f2a1e51120c326cad6f385153bb11ee93b3c216c5fccebfdfbb7ec6cb
distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
+251
View File
@@ -0,0 +1,251 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH="\\\"\\\""
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"
+94
View File
@@ -0,0 +1,94 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega
+23
View File
@@ -0,0 +1,23 @@
plugins {
id("com.android.library")
}
android {
namespace = "org.opencv"
compileSdk = 36
defaultConfig {
minSdk = 24
}
buildTypes {
release {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
}
@@ -0,0 +1 @@
o/bundleLibRuntimeToDirDebug

Some files were not shown because too many files have changed in this diff Show More