a
@@ -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)
|
||||
}
|
||||
}
|
||||