发士大夫
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
|
||||
<application
|
||||
android:name=".PinkApplication"
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_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
|
||||
|
||||
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<ScanRecord?>(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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
.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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ScanState> = _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<PinkApplication>()).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
|
||||
}
|
||||
}
|
||||
@@ -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<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 ---
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user