发士大夫
This commit is contained in:
@@ -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))
|
||||||
|
|||||||
@@ -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 ---
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user