a
@@ -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
|
||||
@@ -0,0 +1,3 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
@@ -0,0 +1 @@
|
||||
PinkOV
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="CompilerConfiguration">
|
||||
<bytecodeTargetLevel target="21" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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.
|
||||
@@ -0,0 +1 @@
|
||||
/build
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
*/
|
||||
)
|
||||
@@ -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>
|
||||
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 982 B |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.8 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
<resources>
|
||||
<string name="app_name">PinkOV</string>
|
||||
<string name="tab_home">Home</string>
|
||||
<string name="tab_mine">Mine</string>
|
||||
</resources>
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 线的情况 |
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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" }
|
||||
|
||||
@@ -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
|
||||
@@ -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" "$@"
|
||||
@@ -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
|
||||
@@ -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
|
||||