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

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

+10
View File
@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="purple_200">#FFBB86FC</color>
<color name="purple_500">#FF6200EE</color>
<color name="purple_700">#FF3700B3</color>
<color name="teal_200">#FF03DAC5</color>
<color name="teal_700">#FF018786</color>
<color name="black">#FF000000</color>
<color name="white">#FFFFFFFF</color>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<resources>
<string name="app_name">PinkOV</string>
<string name="tab_home">Home</string>
<string name="tab_mine">Mine</string>
</resources>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.PinkOV" parent="android:Theme.Material.Light.NoActionBar" />
</resources>
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample backup rules file; uncomment and customize as necessary.
See https://developer.android.com/guide/topics/data/autobackup
for details.
Note: This file is ignored for devices older than API 31
See https://developer.android.com/about/versions/12/backup-restore
-->
<full-backup-content>
<!--
<include domain="sharedpref" path="."/>
<exclude domain="sharedpref" path="device.xml"/>
-->
</full-backup-content>
@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="utf-8"?><!--
Sample data extraction rules file; uncomment and customize as necessary.
See https://developer.android.com/about/versions/12/backup-restore#xml-changes
for details.
-->
<data-extraction-rules>
<cloud-backup>
<!-- TODO: Use <include> and <exclude> to control what is backed up.
<include .../>
<exclude .../>
-->
</cloud-backup>
<!--
<device-transfer>
<include .../>
<exclude .../>
</device-transfer>
-->
</data-extraction-rules>
@@ -0,0 +1,17 @@
package com.pinkbear.pinkov
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}