发士大夫

This commit is contained in:
coco
2026-07-14 18:09:11 +08:00
parent 51ed82cddf
commit f42d112862
12 changed files with 640 additions and 51 deletions
+4
View File
@@ -1,6 +1,7 @@
plugins { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp)
} }
android { android {
@@ -55,6 +56,9 @@ dependencies {
implementation(libs.androidx.camera.camera2) implementation(libs.androidx.camera.camera2)
implementation(libs.androidx.camera.lifecycle) implementation(libs.androidx.camera.lifecycle)
implementation(libs.accompanist.permissions) implementation(libs.accompanist.permissions)
implementation(libs.androidx.room.runtime)
implementation(libs.androidx.room.ktx)
ksp(libs.androidx.room.compiler)
implementation(project(":openCVLibrary300")) implementation(project(":openCVLibrary300"))
testImplementation(libs.junit) testImplementation(libs.junit)
androidTestImplementation(platform(libs.androidx.compose.bom)) androidTestImplementation(platform(libs.androidx.compose.bom))
+1
View File
@@ -5,6 +5,7 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<application <application
android:name=".PinkApplication"
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
@@ -0,0 +1,21 @@
package com.pinkbear.pinkov
import android.app.Application
import com.pinkbear.pinkov.data.database.AppDatabase
/**
* Application class providing the Room database singleton.
*/
class PinkApplication : Application() {
/** Lazily-initialized database instance. */
val database: AppDatabase by lazy {
AppDatabase.getInstance(this)
}
override fun onCreate() {
super.onCreate()
// Force initialization on app start
database
}
}
@@ -0,0 +1,25 @@
package com.pinkbear.pinkov.data.dao
import androidx.room.Dao
import androidx.room.Delete
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.pinkbear.pinkov.data.entity.ScanRecord
import kotlinx.coroutines.flow.Flow
@Dao
interface ScanRecordDao {
/** Get all records ordered by most recent first (for the Home page list). */
@Query("SELECT * FROM scan_records ORDER BY timestamp DESC")
fun getAllRecords(): Flow<List<ScanRecord>>
/** Insert a new record. */
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(record: ScanRecord)
/** Delete a record. */
@Delete
suspend fun delete(record: ScanRecord)
}
@@ -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 }
}
}
}
}
@@ -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
)
@@ -1,47 +1,317 @@
package com.pinkbear.pinkov.ui.page 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.foundation.layout.*
import androidx.compose.material3.Button import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.material3.Scaffold import androidx.compose.foundation.lazy.items
import androidx.compose.material3.Text import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier 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 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 @Composable
fun HomePage( fun HomePage(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onScanClick: () -> Unit = {}, onScanClick: () -> Unit = {},
homeViewModel: HomeViewModel = viewModel()
) { ) {
val records by homeViewModel.records.collectAsStateWithLifecycle(initialValue = emptyList())
// Delete confirmation dialog state
var recordToDelete by remember { mutableStateOf<ScanRecord?>(null) }
Scaffold(modifier = modifier) { innerPadding -> Scaffold(modifier = modifier) { innerPadding ->
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding), .padding(innerPadding)
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
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) { Box(
Text("扫描") 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()) {
Spacer(modifier = Modifier.weight(1f)) 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
)
}
}
} }
} }
} }
@Preview(showBackground = true) // 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 @Composable
fun HomePagePreview() { private fun RecordHeaderRow() {
PinkOVTheme { Row(
HomePage() 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
)
}
}
}
}
/**
* 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
} }
} }
@@ -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<PinkApplication>()).database.scanRecordDao()
/** All scan records, ordered by most recent first. */
val records: Flow<List<ScanRecord>> = recordDao.getAllRecords()
/** Delete a record from the database. */
fun deleteRecord(record: ScanRecord) {
viewModelScope.launch(Dispatchers.IO) {
recordDao.delete(record)
}
}
}
@@ -137,7 +137,7 @@ fun ScanPage(onBack: () -> Unit) {
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.fillMaxHeight() .fillMaxHeight()
.background(Color(0xFF1A1A1A)), .background(MaterialTheme.colorScheme.surface),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center verticalArrangement = Arrangement.Center
) { ) {
@@ -150,14 +150,14 @@ fun ScanPage(onBack: () -> Unit) {
0, 1 -> Color(0xFF4CAF50) // Green for negative 0, 1 -> Color(0xFF4CAF50) // Green for negative
2, 3 -> Color(0xFFFF9800) // Orange for positive 2, 3 -> Color(0xFFFF9800) // Orange for positive
4 -> Color(0xFFF44336) // Red for strong positive 4 -> Color(0xFFF44336) // Red for strong positive
else -> Color.White else -> MaterialTheme.colorScheme.onSurface
}, },
style = MaterialTheme.typography.headlineMedium, style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
Text( Text(
text = "LH值: ${result.value}", text = "LH值: ${result.value}",
color = Color.White, color = MaterialTheme.colorScheme.onSurface,
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
modifier = Modifier.padding(bottom = 24.dp) modifier = Modifier.padding(bottom = 24.dp)
) )
@@ -165,7 +165,7 @@ fun ScanPage(onBack: () -> Unit) {
// Scanning progress // Scanning progress
Text( Text(
text = "采集中... ${scanState.frameCount}/${scanState.totalFrames}", text = "采集中... ${scanState.frameCount}/${scanState.totalFrames}",
color = Color.White, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
// Progress bar // Progress bar
@@ -173,20 +173,20 @@ fun ScanPage(onBack: () -> Unit) {
modifier = Modifier modifier = Modifier
.fillMaxWidth(0.7f) .fillMaxWidth(0.7f)
.height(4.dp) .height(4.dp)
.background(Color.DarkGray) .background(MaterialTheme.colorScheme.surfaceVariant)
) { ) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth(scanState.frameCount.toFloat() / scanState.totalFrames) .fillMaxWidth(scanState.frameCount.toFloat() / scanState.totalFrames)
.height(4.dp) .height(4.dp)
.background(Color(0xFF4CAF50)) .background(MaterialTheme.colorScheme.primary)
) )
} }
Spacer(modifier = Modifier.height(24.dp)) Spacer(modifier = Modifier.height(24.dp))
} else { } else {
Text( Text(
text = "请将排卵试纸置于扫描框内", text = "请将排卵试纸置于扫描框内",
color = Color.White, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.padding(bottom = 24.dp) modifier = Modifier.padding(bottom = 24.dp)
) )
} }
@@ -195,7 +195,7 @@ fun ScanPage(onBack: () -> Unit) {
if (scanState.errorMessage != null) { if (scanState.errorMessage != null) {
Text( Text(
text = scanState.errorMessage!!, text = scanState.errorMessage!!,
color = Color(0xFFF44336), color = MaterialTheme.colorScheme.error,
modifier = Modifier.padding(bottom = 8.dp) modifier = Modifier.padding(bottom = 8.dp)
) )
} }
@@ -219,11 +219,13 @@ fun ScanPage(onBack: () -> Unit) {
} else { } else {
// Permission denied // Permission denied
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
Column(horizontalAlignment = Alignment.CenterHorizontally) { Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text("需要相机权限才能扫描", color = Color.White) Text("需要相机权限才能扫描", color = MaterialTheme.colorScheme.onBackground)
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Button(onClick = { Button(onClick = {
if (permissionState.shouldShowRationale) { if (permissionState.shouldShowRationale) {
@@ -23,13 +23,13 @@ data class OvResult(
override fun hashCode(): Int = label override fun hashCode(): Int = label
companion object { 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( private val MAPPING = mapOf(
0 to OvResult(0, "阴性", "0", doubleArrayOf()), 0 to OvResult(0, "阴性", "0", doubleArrayOf()),
1 to OvResult(1, "阴性", "10", doubleArrayOf()), 1 to OvResult(1, "阴性", "10", doubleArrayOf()),
2 to OvResult(2, "阳性", "25", doubleArrayOf()), 2 to OvResult(2, "阳性", "25", doubleArrayOf()),
3 to OvResult(3, "阳性", "50", 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 { fun fromLabel(label: Int, probabilities: DoubleArray): OvResult {
@@ -1,22 +1,26 @@
package com.pinkbear.pinkov.ui.scan package com.pinkbear.pinkov.ui.scan
import android.app.Application
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Matrix 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 androidx.lifecycle.viewModelScope
import com.pinkbear.pinkov.PinkApplication
import com.pinkbear.pinkov.data.entity.ScanRecord
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import android.util.Log
import org.opencv.android.Utils import org.opencv.android.Utils
import org.opencv.core.Core import org.opencv.core.Core
import org.opencv.core.CvType import org.opencv.core.CvType
import org.opencv.core.Mat import org.opencv.core.Mat
import org.opencv.core.Rect import org.opencv.core.Rect
import org.opencv.imgproc.Imgproc import java.io.ByteArrayOutputStream
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
/** /**
@@ -50,9 +54,10 @@ data class ScanState(
* - Process frames in background via coroutines (Dispatchers.Default) * - Process frames in background via coroutines (Dispatchers.Default)
* - Collect 15 successful frames, classify each with random forest * - Collect 15 successful frames, classify each with random forest
* - Take the mode (众数) of all 15 results as the final answer * - 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 * - Stop the camera once the final result is obtained
*/ */
class ScanViewModel : ViewModel() { class ScanViewModel(application: Application) : AndroidViewModel(application) {
private val _state = MutableStateFlow(ScanState()) private val _state = MutableStateFlow(ScanState())
val state: StateFlow<ScanState> = _state.asStateFlow() val state: StateFlow<ScanState> = _state.asStateFlow()
@@ -72,6 +77,12 @@ class ScanViewModel : ViewModel() {
/** Monotonically increasing frame counter to force StateFlow emissions */ /** Monotonically increasing frame counter to force StateFlow emissions */
private var frameVersion: Long = 0 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<PinkApplication>()).database.scanRecordDao()
/** /**
* Process a camera frame (RGBA byte array). * Process a camera frame (RGBA byte array).
* Called from the CameraX analyzer thread. * Called from the CameraX analyzer thread.
@@ -125,25 +136,24 @@ class ScanViewModel : ViewModel() {
srcRaw.put(0, 0, rgbaData) srcRaw.put(0, 0, rgbaData)
// Rotate image to upright orientation for the algorithm // Rotate image to upright orientation for the algorithm
// Core.rotate() not available in OpenCV 3.0; use transpose+flip
val srcRgba = when (rotationDegrees) { val srcRgba = when (rotationDegrees) {
90 -> { 90 -> {
val rotated = Mat() val rotated = Mat()
Core.transpose(srcRaw, rotated) Core.transpose(srcRaw, rotated)
Core.flip(rotated, rotated, 1) // flip horizontally Core.flip(rotated, rotated, 1)
srcRaw.release() srcRaw.release()
rotated rotated
} }
180 -> { 180 -> {
val rotated = Mat() val rotated = Mat()
Core.flip(srcRaw, rotated, -1) // flip both axes Core.flip(srcRaw, rotated, -1)
srcRaw.release() srcRaw.release()
rotated rotated
} }
270 -> { 270 -> {
val rotated = Mat() val rotated = Mat()
Core.transpose(srcRaw, rotated) Core.transpose(srcRaw, rotated)
Core.flip(rotated, rotated, 0) // flip vertically Core.flip(rotated, rotated, 0)
srcRaw.release() srcRaw.release()
rotated rotated
} }
@@ -153,19 +163,18 @@ class ScanViewModel : ViewModel() {
val procW = srcRgba.width() val procW = srcRgba.width()
val procH = srcRgba.height() 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) { if (roiRect == null) {
roiRect = Rect(0, 0, procW, procH) roiRect = Rect(0, 0, procW, procH)
} }
// Step 1: Locate the strip using C++ pipeline (find green blocks → crop → detect C/T lines) // Step 1: Locate the strip using C++ pipeline
var stripResult = StripLocator.locateAndDetectCT(srcRgba, roiRect!!) val stripResult = StripLocator.locateAndDetectCT(srcRgba, roiRect!!)
if (stripResult.errorCode != 0) { if (stripResult.errorCode != 0) {
Log.d(TAG, "locateAndDetectCT failed (errorCode=${stripResult.errorCode}), skipping frame") Log.d(TAG, "locateAndDetectCT failed (errorCode=${stripResult.errorCode}), skipping frame")
} }
if (stripResult.errorCode != 0) { if (stripResult.errorCode != 0) {
// Update preview bitmap even on failure (shows the source image)
Log.d(TAG, "Strip locate failed, errorCode=${stripResult.errorCode}") Log.d(TAG, "Strip locate failed, errorCode=${stripResult.errorCode}")
val rotated = updatePreview(srcRgba, 0) val rotated = updatePreview(srcRgba, 0)
frameVersion++ frameVersion++
@@ -195,6 +204,7 @@ class ScanViewModel : ViewModel() {
stripResult.leftImg?.release() stripResult.leftImg?.release()
stripResult.rightImg?.release() stripResult.rightImg?.release()
stripResult.wbImg?.release() stripResult.wbImg?.release()
stripResult.paperImg?.release()
return@withContext return@withContext
} }
@@ -213,6 +223,14 @@ class ScanViewModel : ViewModel() {
Log.d(TAG, "Frame $count/15 classified: ${result.name}") 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 // Update preview
val rotated = updatePreview(srcRgba, 0) val rotated = updatePreview(srcRgba, 0)
@@ -231,6 +249,11 @@ class ScanViewModel : ViewModel() {
shouldStopCamera = true, shouldStopCamera = true,
isScanning = false isScanning = false
) )
// Save to Room database in background
if (finalResult != null) {
saveRecord(finalResult)
}
} }
} }
@@ -239,6 +262,7 @@ class ScanViewModel : ViewModel() {
stripResult.leftImg?.release() stripResult.leftImg?.release()
stripResult.rightImg?.release() stripResult.rightImg?.release()
stripResult.wbImg?.release() stripResult.wbImg?.release()
// Note: paperImg is now owned by lastPaperImg, don't release here
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "processFrameInternal error: ${e.message}", e) 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. * 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? { private fun updatePreview(srcRgba: Mat, rotationDegrees: Int): Bitmap? {
try { try {
val w = srcRgba.width() val w = srcRgba.width()
val h = srcRgba.height() val h = srcRgba.height()
// Create a temporary bitmap in sensor orientation (don't recycle old — UI may still render it)
if (bitmapBuffer == null if (bitmapBuffer == null
|| bitmapBuffer?.width != w || bitmapBuffer?.width != w
|| bitmapBuffer?.height != h || bitmapBuffer?.height != h
@@ -274,9 +352,7 @@ class ScanViewModel : ViewModel() {
Utils.matToBitmap(srcRgba, bitmapBuffer!!) Utils.matToBitmap(srcRgba, bitmapBuffer!!)
// Apply rotation to match display orientation
if (rotationDegrees == 0 || rotationDegrees == 180) { if (rotationDegrees == 0 || rotationDegrees == 180) {
// No size swap needed, just copy
val copy = bitmapBuffer!!.copy(Bitmap.Config.ARGB_8888, false) val copy = bitmapBuffer!!.copy(Bitmap.Config.ARGB_8888, false)
if (rotationDegrees == 180) { if (rotationDegrees == 180) {
val matrix = Matrix().apply { postRotate(180f) } val matrix = Matrix().apply { postRotate(180f) }
@@ -284,7 +360,6 @@ class ScanViewModel : ViewModel() {
} }
return copy return copy
} else { } else {
// 90 or 270: swap width and height
val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) } val matrix = Matrix().apply { postRotate(rotationDegrees.toFloat()) }
return Bitmap.createBitmap( return Bitmap.createBitmap(
bitmapBuffer!!, 0, 0, w, h, matrix, true bitmapBuffer!!, 0, 0, w, h, matrix, true
@@ -306,6 +381,8 @@ class ScanViewModel : ViewModel() {
results.clear() results.clear()
} }
isProcessing.set(false) isProcessing.set(false)
lastPaperImg?.release()
lastPaperImg = null
_state.value = ScanState() _state.value = ScanState()
} }
@@ -313,5 +390,7 @@ class ScanViewModel : ViewModel() {
super.onCleared() super.onCleared()
bitmapBuffer?.recycle() bitmapBuffer?.recycle()
bitmapBuffer = null bitmapBuffer = null
lastPaperImg?.release()
lastPaperImg = null
} }
} }
@@ -35,6 +35,8 @@ data class StripResult(
val rightImg: Mat? = null, val rightImg: Mat? = null,
/** White block ROI (between C and T lines) */ /** White block ROI (between C and T lines) */
val wbImg: Mat? = null, val wbImg: Mat? = null,
/** Full strip paper image (cropped from between green blocks), RGB format */
val paperImg: Mat? = null,
/** Detected paper color */ /** Detected paper color */
val paperColor: PaperColor = PaperColor.Unknown, val paperColor: PaperColor = PaperColor.Unknown,
/** Image sharpness / definition value */ /** Image sharpness / definition value */
@@ -1407,6 +1409,12 @@ object StripLocator {
} }
val ctMat = waRoiSrc.submat(clampedRoi) // This is the cropped strip (C++: ctMat) 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) // PHASE 4: C/T LINE DETECTION ON CROPPED STRIP (C++ thresholds)
// ===================================================================== // =====================================================================
@@ -1674,6 +1682,7 @@ object StripLocator {
leftImg = if (leftImg.empty()) null else leftImg, leftImg = if (leftImg.empty()) null else leftImg,
rightImg = if (rightImg.empty()) null else rightImg, rightImg = if (rightImg.empty()) null else rightImg,
wbImg = if (wbImg.empty()) null else wbImg, wbImg = if (wbImg.empty()) null else wbImg,
paperImg = if (errorCode == 0 && !paperImg.empty()) paperImg else { paperImg.release(); null },
paperColor = paperColor paperColor = paperColor
) )
} }
@@ -1685,6 +1694,95 @@ object StripLocator {
waRoiSrc.release(); autoRoiSrc.release(); maskSrc.release(); mask.release() 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<Point>(4)
bigRotRect.points(bigPoints)
val smallPoints = arrayOfNulls<Point>(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<Point>()
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 --- // --- Private helpers ---
/** /**