发士大夫

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 {
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))
+1
View File
@@ -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 ---
/**