diff --git a/Pink/app/build.gradle.kts b/Pink/app/build.gradle.kts
index e374c95..f640390 100644
--- a/Pink/app/build.gradle.kts
+++ b/Pink/app/build.gradle.kts
@@ -1,6 +1,7 @@
plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose)
+ alias(libs.plugins.ksp)
}
android {
@@ -55,6 +56,9 @@ dependencies {
implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle)
implementation(libs.accompanist.permissions)
+ implementation(libs.androidx.room.runtime)
+ implementation(libs.androidx.room.ktx)
+ ksp(libs.androidx.room.compiler)
implementation(project(":openCVLibrary300"))
testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom))
diff --git a/Pink/app/src/main/AndroidManifest.xml b/Pink/app/src/main/AndroidManifest.xml
index 371b8f0..2b5cca3 100644
--- a/Pink/app/src/main/AndroidManifest.xml
+++ b/Pink/app/src/main/AndroidManifest.xml
@@ -5,6 +5,7 @@
>
+
+ /** Insert a new record. */
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ suspend fun insert(record: ScanRecord)
+
+ /** Delete a record. */
+ @Delete
+ suspend fun delete(record: ScanRecord)
+}
\ No newline at end of file
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/database/AppDatabase.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/database/AppDatabase.kt
new file mode 100644
index 0000000..6da7996
--- /dev/null
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/database/AppDatabase.kt
@@ -0,0 +1,29 @@
+package com.pinkbear.pinkov.data.database
+
+import android.content.Context
+import androidx.room.Database
+import androidx.room.Room
+import androidx.room.RoomDatabase
+import com.pinkbear.pinkov.data.dao.ScanRecordDao
+import com.pinkbear.pinkov.data.entity.ScanRecord
+
+@Database(entities = [ScanRecord::class], version = 1, exportSchema = false)
+abstract class AppDatabase : RoomDatabase() {
+
+ abstract fun scanRecordDao(): ScanRecordDao
+
+ companion object {
+ @Volatile
+ private var INSTANCE: AppDatabase? = null
+
+ fun getInstance(context: Context): AppDatabase {
+ return INSTANCE ?: synchronized(this) {
+ INSTANCE ?: Room.databaseBuilder(
+ context.applicationContext,
+ AppDatabase::class.java,
+ "pinkov_database"
+ ).build().also { INSTANCE = it }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/ScanRecord.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/ScanRecord.kt
new file mode 100644
index 0000000..344077a
--- /dev/null
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/data/entity/ScanRecord.kt
@@ -0,0 +1,30 @@
+package com.pinkbear.pinkov.data.entity
+
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+
+/**
+ * Room entity for a saved scan record.
+ */
+@Entity(tableName = "scan_records")
+data class ScanRecord(
+ @PrimaryKey(autoGenerate = true)
+ val id: Long = 0,
+
+ /** Result value: "0", "10", "25", "50", "75" */
+ @ColumnInfo(name = "result_value")
+ val resultValue: String,
+
+ /** Chinese description: 阴性, 阳性, 强阳 */
+ @ColumnInfo(name = "result_name")
+ val resultName: String,
+
+ /** Detection timestamp (epoch millis) */
+ @ColumnInfo(name = "timestamp")
+ val timestamp: Long,
+
+ /** Base64-encoded JPEG image of the test strip (compressed to ~320px width) */
+ @ColumnInfo(name = "image_base64")
+ val imageBase64: String
+)
\ No newline at end of file
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/HomePage.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/HomePage.kt
index 0a3a2db..27fc7fb 100644
--- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/HomePage.kt
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/HomePage.kt
@@ -1,47 +1,317 @@
package com.pinkbear.pinkov.ui.page
+import android.graphics.Bitmap
+import android.graphics.BitmapFactory
+import android.util.Base64
+import androidx.compose.animation.core.FastOutLinearInEasing
+import androidx.compose.animation.core.RepeatMode
+import androidx.compose.animation.core.animateFloat
+import androidx.compose.animation.core.infiniteRepeatable
+import androidx.compose.animation.core.rememberInfiniteTransition
+import androidx.compose.animation.core.tween
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.combinedClickable
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.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.*
+import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.scale
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.asImageBitmap
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextAlign
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
-import com.pinkbear.pinkov.ui.theme.PinkOVTheme
+import androidx.compose.ui.unit.sp
+import androidx.lifecycle.compose.collectAsStateWithLifecycle
+import androidx.lifecycle.viewmodel.compose.viewModel
+import com.pinkbear.pinkov.data.entity.ScanRecord
+import java.text.SimpleDateFormat
+import java.util.Date
+import java.util.Locale
+@OptIn(ExperimentalFoundationApi::class)
@Composable
fun HomePage(
modifier: Modifier = Modifier,
onScanClick: () -> Unit = {},
+ homeViewModel: HomeViewModel = viewModel()
) {
+ val records by homeViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
+
+ // Delete confirmation dialog state
+ var recordToDelete by remember { mutableStateOf(null) }
+
Scaffold(modifier = modifier) { innerPadding ->
Column(
modifier = Modifier
.fillMaxSize()
- .padding(innerPadding),
- horizontalAlignment = Alignment.CenterHorizontally
+ .padding(innerPadding)
) {
- Spacer(modifier = Modifier.height(16.dp))
+ // ---- Top 1/3: Scan button area (reserved for future expansion) ----
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(1f / 3f),
+ contentAlignment = Alignment.Center
+ ) {
+ // Breathing animation for the scan button
+ val infiniteTransition = rememberInfiniteTransition()
+ val breathScale by infiniteTransition.animateFloat(
+ initialValue = 0.88f,
+ targetValue = 1f,
+ animationSpec = infiniteRepeatable(
+ animation = tween(2000, easing = FastOutLinearInEasing),
+ repeatMode = RepeatMode.Reverse
+ ),
+ label = "breath"
+ )
- Button(onClick = onScanClick) {
- Text("扫描")
+ Box(
+ modifier = Modifier
+ .size(150.dp)
+ .scale(breathScale)
+ .clip(CircleShape)
+ .background(MaterialTheme.colorScheme.primary)
+ .clickable { onScanClick() },
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "扫描",
+ color = MaterialTheme.colorScheme.onPrimary,
+ fontSize = 18.sp,
+ fontWeight = FontWeight.Bold
+ )
+ }
}
- Spacer(modifier = Modifier.weight(1f))
+ HorizontalDivider(thickness = 1.dp, color = MaterialTheme.colorScheme.outlineVariant)
- Text(text = "Home")
+ // ---- Bottom 2/3: Result list ----
+ if (records.isEmpty()) {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = "暂无检测记录",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 14.sp
+ )
+ }
+ } else {
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .fillMaxHeight()
+ ) {
+ item {
+ // Header row
+ RecordHeaderRow()
+ }
+ items(records, key = { it.id }) { record ->
+ RecordRow(
+ record = record,
+ onLongPress = { recordToDelete = record }
+ )
+ HorizontalDivider(
+ thickness = 0.5.dp,
+ color = MaterialTheme.colorScheme.outlineVariant
+ )
+ }
+ }
+ }
+ }
+ }
- Spacer(modifier = Modifier.weight(1f))
+ // Delete confirmation dialog
+ recordToDelete?.let { record ->
+ AlertDialog(
+ onDismissRequest = { recordToDelete = null },
+ title = { Text("删除记录") },
+ text = {
+ Text("确定要删除这条检测记录吗?\n结果: ${record.resultName} (${record.resultValue})")
+ },
+ confirmButton = {
+ TextButton(onClick = {
+ homeViewModel.deleteRecord(record)
+ recordToDelete = null
+ }) {
+ Text("删除", color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = {
+ TextButton(onClick = { recordToDelete = null }) {
+ Text("取消")
+ }
+ }
+ )
+ }
+}
+
+/**
+ * Header row for the record list.
+ */
+@Composable
+private fun RecordHeaderRow() {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(vertical = 8.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ Text(
+ text = "结果",
+ modifier = Modifier.weight(0.8f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = "判读",
+ modifier = Modifier.weight(1.0f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = "时间",
+ modifier = Modifier.weight(2.0f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ Text(
+ text = "图片",
+ modifier = Modifier.weight(2.0f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ }
+}
+
+/**
+ * A single record row. Image column takes ~40% of screen width.
+ */
+@OptIn(ExperimentalFoundationApi::class)
+@Composable
+private fun RecordRow(
+ record: ScanRecord,
+ onLongPress: () -> Unit
+) {
+ // Decode Base64 image to Bitmap
+ val thumbnail = remember(record.imageBase64) {
+ decodeBase64ToBitmap(record.imageBase64)
+ }
+
+ // Format timestamp
+ val timeStr = remember(record.timestamp) {
+ val sdf = SimpleDateFormat("yyyy-MM-dd\nHH:mm:ss", Locale.getDefault())
+ sdf.format(Date(record.timestamp))
+ }
+
+ // Result color
+ val resultColor = when (record.resultName) {
+ "阴性" -> Color(0xFF4CAF50)
+ "阳性" -> Color(0xFFFF9800)
+ "强阳" -> Color(0xFFF44336)
+ else -> Color.White
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .combinedClickable(
+ onClick = { /* Click could open detail view in future */ },
+ onLongClick = onLongPress
+ )
+ .background(MaterialTheme.colorScheme.surface)
+ .padding(vertical = 6.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically
+ ) {
+ // Result value
+ Text(
+ text = record.resultValue,
+ modifier = Modifier.weight(0.8f),
+ color = resultColor,
+ fontSize = 16.sp,
+ fontWeight = FontWeight.Bold,
+ textAlign = TextAlign.Center
+ )
+ // Chinese name
+ Text(
+ text = record.resultName,
+ modifier = Modifier.weight(1.0f),
+ color = resultColor,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.Medium,
+ textAlign = TextAlign.Center
+ )
+ // Time
+ Text(
+ text = timeStr,
+ modifier = Modifier.weight(2.0f),
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 11.sp,
+ textAlign = TextAlign.Center,
+ lineHeight = 14.sp,
+ overflow = TextOverflow.Ellipsis
+ )
+ // Image thumbnail (40% = 2.0 of total 5.8 weight)
+ Box(
+ modifier = Modifier
+ .weight(2.0f)
+ .fillMaxWidth()
+ .aspectRatio(4f / 3f)
+ .clip(RoundedCornerShape(4.dp))
+ .background(MaterialTheme.colorScheme.surface),
+ contentAlignment = Alignment.Center
+ ) {
+ if (thumbnail != null) {
+ Image(
+ bitmap = thumbnail.asImageBitmap(),
+ contentDescription = "试纸图片",
+ modifier = Modifier.fillMaxSize(),
+ contentScale = ContentScale.Fit
+ )
+ } else {
+ Text(
+ text = "无图片",
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ fontSize = 10.sp
+ )
+ }
}
}
}
-@Preview(showBackground = true)
-@Composable
-fun HomePagePreview() {
- PinkOVTheme {
- HomePage()
+/**
+ * Decode a Base64-encoded JPEG string to a Bitmap, or null on failure.
+ */
+private fun decodeBase64ToBitmap(base64: String): Bitmap? {
+ if (base64.isBlank()) return null
+ return try {
+ val bytes = Base64.decode(base64, Base64.DEFAULT)
+ BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
+ } catch (e: Exception) {
+ null
}
}
\ No newline at end of file
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/HomeViewModel.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/HomeViewModel.kt
new file mode 100644
index 0000000..7b769e7
--- /dev/null
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/HomeViewModel.kt
@@ -0,0 +1,30 @@
+package com.pinkbear.pinkov.ui.page
+
+import android.app.Application
+import androidx.lifecycle.AndroidViewModel
+import androidx.lifecycle.viewModelScope
+import com.pinkbear.pinkov.PinkApplication
+import com.pinkbear.pinkov.data.entity.ScanRecord
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.launch
+
+/**
+ * ViewModel for the Home page.
+ *
+ * Observes the Room database for scan records and provides delete functionality.
+ */
+class HomeViewModel(application: Application) : AndroidViewModel(application) {
+
+ private val recordDao = (getApplication()).database.scanRecordDao()
+
+ /** All scan records, ordered by most recent first. */
+ val records: Flow> = recordDao.getAllRecords()
+
+ /** Delete a record from the database. */
+ fun deleteRecord(record: ScanRecord) {
+ viewModelScope.launch(Dispatchers.IO) {
+ recordDao.delete(record)
+ }
+ }
+}
\ No newline at end of file
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt
index f16f869..5716f01 100644
--- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/page/ScanPage.kt
@@ -137,7 +137,7 @@ fun ScanPage(onBack: () -> Unit) {
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
- .background(Color(0xFF1A1A1A)),
+ .background(MaterialTheme.colorScheme.surface),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
@@ -150,14 +150,14 @@ fun ScanPage(onBack: () -> Unit) {
0, 1 -> Color(0xFF4CAF50) // Green for negative
2, 3 -> Color(0xFFFF9800) // Orange for positive
4 -> Color(0xFFF44336) // Red for strong positive
- else -> Color.White
+ else -> MaterialTheme.colorScheme.onSurface
},
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 8.dp)
)
Text(
text = "LH值: ${result.value}",
- color = Color.White,
+ color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 24.dp)
)
@@ -165,7 +165,7 @@ fun ScanPage(onBack: () -> Unit) {
// Scanning progress
Text(
text = "采集中... ${scanState.frameCount}/${scanState.totalFrames}",
- color = Color.White,
+ color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp)
)
// Progress bar
@@ -173,20 +173,20 @@ fun ScanPage(onBack: () -> Unit) {
modifier = Modifier
.fillMaxWidth(0.7f)
.height(4.dp)
- .background(Color.DarkGray)
+ .background(MaterialTheme.colorScheme.surfaceVariant)
) {
Box(
modifier = Modifier
.fillMaxWidth(scanState.frameCount.toFloat() / scanState.totalFrames)
.height(4.dp)
- .background(Color(0xFF4CAF50))
+ .background(MaterialTheme.colorScheme.primary)
)
}
Spacer(modifier = Modifier.height(24.dp))
} else {
Text(
text = "请将排卵试纸置于扫描框内",
- color = Color.White,
+ color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 24.dp)
)
}
@@ -195,7 +195,7 @@ fun ScanPage(onBack: () -> Unit) {
if (scanState.errorMessage != null) {
Text(
text = scanState.errorMessage!!,
- color = Color(0xFFF44336),
+ color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp)
)
}
@@ -219,11 +219,13 @@ fun ScanPage(onBack: () -> Unit) {
} else {
// Permission denied
Box(
- modifier = Modifier.fillMaxSize(),
+ modifier = Modifier
+ .fillMaxSize()
+ .background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
- Text("需要相机权限才能扫描", color = Color.White)
+ Text("需要相机权限才能扫描", color = MaterialTheme.colorScheme.onBackground)
Spacer(modifier = Modifier.height(16.dp))
Button(onClick = {
if (permissionState.shouldShowRationale) {
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/OvClassifier.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/OvClassifier.kt
index 7cdcd9a..b3c62ce 100644
--- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/OvClassifier.kt
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/OvClassifier.kt
@@ -23,13 +23,13 @@ data class OvResult(
override fun hashCode(): Int = label
companion object {
- /** Result mapping: 0=阴性, 1=阴性(10), 2=阳性(25), 3=阳性(50), 4=强阳(75/100) */
+ /** Result mapping: 0=阴性(0), 1=阴性(10), 2=阳性(25), 3=阳性(50), 4=强阳(75) */
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())
+ 4 to OvResult(4, "强阳", "75", doubleArrayOf())
)
fun fromLabel(label: Int, probabilities: DoubleArray): OvResult {
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt
index 2b133c9..7016f36 100644
--- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/ScanViewModel.kt
@@ -1,22 +1,26 @@
package com.pinkbear.pinkov.ui.scan
+import android.app.Application
import android.graphics.Bitmap
import android.graphics.Matrix
-import androidx.lifecycle.ViewModel
+import android.util.Base64
+import android.util.Log
+import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
+import com.pinkbear.pinkov.PinkApplication
+import com.pinkbear.pinkov.data.entity.ScanRecord
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 org.opencv.imgproc.Imgproc
+import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicBoolean
/**
@@ -50,9 +54,10 @@ data class ScanState(
* - 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
+ * - Save the last successful frame's paper image to Room database
* - Stop the camera once the final result is obtained
*/
-class ScanViewModel : ViewModel() {
+class ScanViewModel(application: Application) : AndroidViewModel(application) {
private val _state = MutableStateFlow(ScanState())
val state: StateFlow = _state.asStateFlow()
@@ -72,6 +77,12 @@ class ScanViewModel : ViewModel() {
/** Monotonically increasing frame counter to force StateFlow emissions */
private var frameVersion: Long = 0
+ /** Last successful frame's paper image (kept for DB save after voting) */
+ private var lastPaperImg: Mat? = null
+
+ /** Room database DAO */
+ private val recordDao = (getApplication()).database.scanRecordDao()
+
/**
* Process a camera frame (RGBA byte array).
* Called from the CameraX analyzer thread.
@@ -125,25 +136,24 @@ class ScanViewModel : ViewModel() {
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
+ Core.flip(rotated, rotated, 1)
srcRaw.release()
rotated
}
180 -> {
val rotated = Mat()
- Core.flip(srcRaw, rotated, -1) // flip both axes
+ Core.flip(srcRaw, rotated, -1)
srcRaw.release()
rotated
}
270 -> {
val rotated = Mat()
Core.transpose(srcRaw, rotated)
- Core.flip(rotated, rotated, 0) // flip vertically
+ Core.flip(rotated, rotated, 0)
srcRaw.release()
rotated
}
@@ -153,19 +163,18 @@ class ScanViewModel : ViewModel() {
val procW = srcRgba.width()
val procH = srcRgba.height()
- // Define ROI: use the full frame (the user will align the strip)
+ // Define ROI: use the full frame
if (roiRect == null) {
roiRect = Rect(0, 0, procW, procH)
}
- // Step 1: Locate the strip using C++ pipeline (find green blocks → crop → detect C/T lines)
- var stripResult = StripLocator.locateAndDetectCT(srcRgba, roiRect!!)
+ // Step 1: Locate the strip using C++ pipeline
+ val stripResult = StripLocator.locateAndDetectCT(srcRgba, roiRect!!)
if (stripResult.errorCode != 0) {
Log.d(TAG, "locateAndDetectCT failed (errorCode=${stripResult.errorCode}), skipping frame")
}
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++
@@ -195,6 +204,7 @@ class ScanViewModel : ViewModel() {
stripResult.leftImg?.release()
stripResult.rightImg?.release()
stripResult.wbImg?.release()
+ stripResult.paperImg?.release()
return@withContext
}
@@ -213,6 +223,14 @@ class ScanViewModel : ViewModel() {
Log.d(TAG, "Frame $count/15 classified: ${result.name}")
+ // Keep the latest paper image for DB save
+ lastPaperImg?.release()
+ lastPaperImg = if (stripResult.paperImg != null && !stripResult.paperImg!!.empty()) {
+ stripResult.paperImg // transfer ownership
+ } else {
+ null
+ }
+
// Update preview
val rotated = updatePreview(srcRgba, 0)
@@ -231,6 +249,11 @@ class ScanViewModel : ViewModel() {
shouldStopCamera = true,
isScanning = false
)
+
+ // Save to Room database in background
+ if (finalResult != null) {
+ saveRecord(finalResult)
+ }
}
}
@@ -239,6 +262,7 @@ class ScanViewModel : ViewModel() {
stripResult.leftImg?.release()
stripResult.rightImg?.release()
stripResult.wbImg?.release()
+ // Note: paperImg is now owned by lastPaperImg, don't release here
} catch (e: Exception) {
Log.e(TAG, "processFrameInternal error: ${e.message}", e)
@@ -248,19 +272,73 @@ class ScanViewModel : ViewModel() {
}
}
+ /**
+ * Save the final result and last paper image to Room database.
+ */
+ private fun saveRecord(finalResult: OvResult) {
+ viewModelScope.launch(Dispatchers.Default) {
+ try {
+ val imageBase64 = lastPaperImg?.let { paperImgToBase64(it) } ?: ""
+ lastPaperImg?.release()
+ lastPaperImg = null
+
+ val record = ScanRecord(
+ resultValue = finalResult.value,
+ resultName = finalResult.name,
+ timestamp = System.currentTimeMillis(),
+ imageBase64 = imageBase64
+ )
+ recordDao.insert(record)
+ Log.d(TAG, "Record saved: ${finalResult.name} (${finalResult.value})")
+ } catch (e: Exception) {
+ Log.e(TAG, "saveRecord error: ${e.message}", e)
+ }
+ }
+ }
+
+ /**
+ * Convert an OpenCV Mat (RGB) to a Base64-encoded JPEG string.
+ * Resizes to 320px width to keep the database size manageable.
+ */
+ private suspend fun paperImgToBase64(paperImg: Mat): String? = withContext(Dispatchers.Default) {
+ try {
+ // Convert Mat to Bitmap
+ val bmp = Bitmap.createBitmap(paperImg.cols(), paperImg.rows(), Bitmap.Config.ARGB_8888)
+ Utils.matToBitmap(paperImg, bmp)
+
+ // Resize to max 320px width (maintain aspect ratio)
+ val targetWidth = 320
+ val resized: Bitmap = if (bmp.width > targetWidth) {
+ val ratio = targetWidth.toDouble() / bmp.width
+ val newHeight = (bmp.height * ratio).toInt()
+ val scaled = Bitmap.createScaledBitmap(bmp, targetWidth, maxOf(newHeight, 1), true)
+ bmp.recycle()
+ scaled
+ } else {
+ bmp
+ }
+
+ // Compress to JPEG
+ val baos = ByteArrayOutputStream()
+ resized.compress(Bitmap.CompressFormat.JPEG, 80, baos)
+ resized.recycle()
+
+ // Base64 encode
+ Base64.encodeToString(baos.toByteArray(), Base64.NO_WRAP)
+ } catch (e: Exception) {
+ Log.e(TAG, "paperImgToBase64 error: ${e.message}")
+ null
+ }
+ }
+
/**
* 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
@@ -274,9 +352,7 @@ class ScanViewModel : ViewModel() {
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) }
@@ -284,7 +360,6 @@ class ScanViewModel : ViewModel() {
}
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
@@ -306,6 +381,8 @@ class ScanViewModel : ViewModel() {
results.clear()
}
isProcessing.set(false)
+ lastPaperImg?.release()
+ lastPaperImg = null
_state.value = ScanState()
}
@@ -313,5 +390,7 @@ class ScanViewModel : ViewModel() {
super.onCleared()
bitmapBuffer?.recycle()
bitmapBuffer = null
+ lastPaperImg?.release()
+ lastPaperImg = null
}
}
\ No newline at end of file
diff --git a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt
index 046114f..5ee583a 100644
--- a/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt
+++ b/Pink/app/src/main/java/com/pinkbear/pinkov/ui/scan/StripLocator.kt
@@ -35,6 +35,8 @@ data class StripResult(
val rightImg: Mat? = null,
/** White block ROI (between C and T lines) */
val wbImg: Mat? = null,
+ /** Full strip paper image (cropped from between green blocks), RGB format */
+ val paperImg: Mat? = null,
/** Detected paper color */
val paperColor: PaperColor = PaperColor.Unknown,
/** Image sharpness / definition value */
@@ -1407,6 +1409,12 @@ object StripLocator {
}
val ctMat = waRoiSrc.submat(clampedRoi) // This is the cropped strip (C++: ctMat)
+ // =====================================================================
+ // Extract full strip paper image (C++: _savePaperImg)
+ // Uses corner points of both green blocks + midpoint → bounding rect
+ // =====================================================================
+ val paperImg = extractPaperImage(roiSrc, bigRotRect, smallRotRect, bRot)
+
// =====================================================================
// PHASE 4: C/T LINE DETECTION ON CROPPED STRIP (C++ thresholds)
// =====================================================================
@@ -1674,6 +1682,7 @@ object StripLocator {
leftImg = if (leftImg.empty()) null else leftImg,
rightImg = if (rightImg.empty()) null else rightImg,
wbImg = if (wbImg.empty()) null else wbImg,
+ paperImg = if (errorCode == 0 && !paperImg.empty()) paperImg else { paperImg.release(); null },
paperColor = paperColor
)
}
@@ -1685,6 +1694,95 @@ object StripLocator {
waRoiSrc.release(); autoRoiSrc.release(); maskSrc.release(); mask.release()
}
+ /**
+ * Extract the full strip paper image from the ROI.
+ *
+ * Ported from C++ ImageLocationColloidalGold::_savePaperImg():
+ * 1. Collect 4 corner points of each green block (big + small)
+ * 2. Compute midpoint between the two blocks
+ * 3. Compute bounding rect of all 9 points
+ * 4. Crop from roiSrc, flip if rotated
+ *
+ * @param roiSrc ROI image in RGB format
+ * @param bigRotRect RotatedRect of the big green block
+ * @param smallRotRect RotatedRect of the small green block
+ * @param bRot Whether the paper is rotated (big block on left)
+ * @return Cropped paper image in RGB, or empty Mat on failure
+ */
+ private fun extractPaperImage(
+ roiSrc: Mat,
+ bigRotRect: RotatedRect,
+ smallRotRect: RotatedRect,
+ bRot: Boolean
+ ): Mat {
+ val result = Mat()
+ try {
+ // Get 4 corner points of each rotated rect
+ val bigPoints = arrayOfNulls(4)
+ bigRotRect.points(bigPoints)
+ val smallPoints = arrayOfNulls(4)
+ smallRotRect.points(smallPoints)
+
+ // Compute midpoint p1 (C++ logic)
+ val p1 = Point()
+ p1.y = smallRotRect.center.y
+ val x: Double = if (bigRotRect.center.x > smallRotRect.center.x)
+ Math.abs(smallRotRect.center.x - (bigRotRect.center.x - smallRotRect.center.x) * 0.5)
+ else
+ Math.abs(smallRotRect.center.x + (smallRotRect.center.x - bigRotRect.center.x) * 0.5)
+ p1.x = x
+ if (x < 1) p1.x = 0.0
+ if (x > roiSrc.cols()) p1.x = roiSrc.cols().toDouble()
+
+ // Collect all 9 points (8 corner points + midpoint)
+ val allPoints = mutableListOf()
+ bigPoints.forEach { allPoints.add(it!!) }
+ smallPoints.forEach { allPoints.add(it!!) }
+ allPoints.add(p1)
+
+ val pointsMat = MatOfPoint()
+ pointsMat.fromList(allPoints)
+ val maxRect = Imgproc.boundingRect(pointsMat)
+ pointsMat.release()
+
+ // Clamp to roiSrc bounds
+ var rx = maxRect.x; var ry = maxRect.y
+ var rw = maxRect.width; var rh = maxRect.height
+ if (rx < 1) rx = 0
+ if (ry < 1) ry = 0
+ if (rx > roiSrc.cols() || ry > roiSrc.rows()) {
+ return result // empty
+ }
+ if (rx + rw > roiSrc.cols() - 1) rw = roiSrc.cols() - rx - 1
+ if (ry + rh > roiSrc.rows() - 1) rh = roiSrc.rows() - ry - 1
+ if (rw <= 0 || rh <= 0) {
+ return result // empty
+ }
+
+ val clampedRect = Rect(rx, ry, rw, rh)
+ if (clampedRect.x + clampedRect.width <= roiSrc.cols()
+ && clampedRect.y + clampedRect.height <= roiSrc.rows()
+ ) {
+ roiSrc.submat(clampedRect).copyTo(result)
+ } else {
+ roiSrc.copyTo(result)
+ }
+
+ // Flip if rotated (C++: flip(paperImg, paperImg, -1))
+ if (bRot) {
+ Core.flip(result, result, -1)
+ }
+
+ // Note: roiSrc is already RGB, no need for COLOR_BGRA2RGB conversion
+ // (C++ does cvtColor because its roiSrc may be BGRA at this point)
+ } catch (e: Exception) {
+ Log.e(TAG, "extractPaperImage error: ${e.message}")
+ result.release()
+ return Mat()
+ }
+ return result
+ }
+
// --- Private helpers ---
/**