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 --- /**