SmoothTraining-FaceScanning

Adding visual clarity for duplicates detected
This commit is contained in:
genki
2026-01-16 09:30:39 -05:00
parent 4325f7f178
commit 9312fcf645
2 changed files with 187 additions and 267 deletions

View File

@@ -1,6 +1,5 @@
package com.placeholder.sherpai2.ui.modelinventory
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
@@ -14,17 +13,19 @@ import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
/**
* PersonInventoryScreen - SUPERCHARGED UI with warnings and improved buttons
* PersonInventoryScreen - Simplified to match corrected ViewModel
*
* Features:
* - Background scanning with navigation warnings
* - Real-time performance stats (images/sec)
* - Fixed button layout (no text wrapping)
* - Progress tracking
* - List of all persons with face models
* - Scan button to find person in library
* - Real-time scanning progress
* - Delete person functionality
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -32,53 +33,8 @@ fun PersonInventoryScreen(
viewModel: PersonInventoryViewModel = hiltViewModel(),
onNavigateToPersonDetail: (String) -> Unit
) {
val uiState by viewModel.uiState.collectAsState()
val scanningState by viewModel.scanningState.collectAsState()
val isScanningInBackground by viewModel.isScanningInBackground.collectAsState()
// Navigation warning dialog
var showNavigationWarning by remember { mutableStateOf(false) }
var pendingNavigation by remember { mutableStateOf<(() -> Unit)?>(null) }
// Intercept back button when scanning
BackHandler(enabled = isScanningInBackground) {
showNavigationWarning = true
}
// Navigation warning dialog
if (showNavigationWarning) {
AlertDialog(
onDismissRequest = { showNavigationWarning = false },
title = { Text("Scan in Progress") },
text = {
Column {
Text("A face scanning operation is running in the background.")
Spacer(Modifier.height(8.dp))
Text(
"Leaving now will cancel the scan and you'll need to restart it.",
style = MaterialTheme.typography.bodySmall
)
}
},
confirmButton = {
TextButton(
onClick = {
viewModel.cancelScan()
showNavigationWarning = false
pendingNavigation?.invoke()
pendingNavigation = null
}
) {
Text("Cancel Scan & Leave", color = MaterialTheme.colorScheme.error)
}
},
dismissButton = {
TextButton(onClick = { showNavigationWarning = false }) {
Text("Continue Scanning")
}
}
)
}
val personsWithModels by viewModel.personsWithModels.collectAsStateWithLifecycle()
val scanningState by viewModel.scanningState.collectAsStateWithLifecycle()
Scaffold(
topBar = {
@@ -86,9 +42,9 @@ fun PersonInventoryScreen(
title = {
Column {
Text("People")
if (isScanningInBackground) {
if (scanningState is ScanningState.Scanning) {
Text(
"⚡ Scanning in background",
"⚡ Scanning...",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
@@ -103,69 +59,51 @@ fun PersonInventoryScreen(
) { padding ->
Column(Modifier.padding(padding)) {
// Stats card
when (uiState) {
is PersonInventoryViewModel.InventoryUiState.Success -> {
StatsCard((uiState as PersonInventoryViewModel.InventoryUiState.Success).persons)
if (personsWithModels.isNotEmpty()) {
StatsCard(personsWithModels)
}
// Scanning progress (if active)
when (val state = scanningState) {
is ScanningState.Scanning -> {
ScanningProgressCard(state)
}
is ScanningState.Complete -> {
CompletionCard(state) {
viewModel.resetScanningState()
}
}
is ScanningState.Error -> {
ErrorCard(state) {
viewModel.resetScanningState()
}
}
else -> {}
}
// Scanning progress (if active)
if (scanningState is PersonInventoryViewModel.ScanningState.Scanning) {
ScanningProgressCard(scanningState as PersonInventoryViewModel.ScanningState.Scanning)
}
// Completion message
if (scanningState is PersonInventoryViewModel.ScanningState.Complete) {
CompletionCard(scanningState as PersonInventoryViewModel.ScanningState.Complete)
}
// Person list
when (val state = uiState) {
is PersonInventoryViewModel.InventoryUiState.Loading -> {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
is PersonInventoryViewModel.InventoryUiState.Success -> {
if (state.persons.isEmpty()) {
if (personsWithModels.isEmpty()) {
EmptyState()
} else {
PersonList(
persons = state.persons,
onScan = { personId, faceModelId ->
viewModel.scanLibraryForPerson(personId, faceModelId)
},
onImprove = { personId, faceModelId ->
viewModel.startModelImprovement(personId, faceModelId)
persons = personsWithModels,
onScan = { personId ->
viewModel.scanForPerson(personId)
},
onView = { personId ->
if (isScanningInBackground) {
showNavigationWarning = true
pendingNavigation = { onNavigateToPersonDetail(personId) }
} else {
onNavigateToPersonDetail(personId)
}
},
onDelete = { personId, faceModelId ->
viewModel.deletePerson(personId, faceModelId)
onDelete = { personId ->
viewModel.deletePerson(personId)
}
)
}
}
is PersonInventoryViewModel.InventoryUiState.Error -> {
ErrorState(state.message)
}
}
}
}
}
@Composable
fun StatsCard(persons: List<PersonInventoryViewModel.PersonWithStats>) {
private fun StatsCard(persons: List<PersonWithModelInfo>) {
Card(
modifier = Modifier
.fillMaxWidth()
@@ -187,7 +125,7 @@ fun StatsCard(persons: List<PersonInventoryViewModel.PersonWithStats>) {
)
StatItem(
icon = Icons.Default.Collections,
value = persons.sumOf { it.stats.taggedPhotoCount }.toString(),
value = persons.sumOf { it.taggedPhotoCount }.toString(),
label = "Tagged"
)
}
@@ -195,7 +133,11 @@ fun StatsCard(persons: List<PersonInventoryViewModel.PersonWithStats>) {
}
@Composable
fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, value: String, label: String) {
private fun StatItem(
icon: androidx.compose.ui.graphics.vector.ImageVector,
value: String,
label: String
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(8.dp)
@@ -210,6 +152,7 @@ fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, value: Strin
Text(
value,
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
@@ -221,16 +164,21 @@ fun StatItem(icon: androidx.compose.ui.graphics.vector.ImageVector, value: Strin
}
@Composable
fun ScanningProgressCard(state: PersonInventoryViewModel.ScanningState.Scanning) {
private fun ScanningProgressCard(state: ScanningState.Scanning) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
)
) {
Column(Modifier.padding(16.dp)) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
@@ -238,45 +186,34 @@ fun ScanningProgressCard(state: PersonInventoryViewModel.ScanningState.Scanning)
) {
Text(
"Scanning for ${state.personName}",
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
"${state.progress}/${state.total}",
style = MaterialTheme.typography.bodySmall
"${state.completed} / ${state.total}",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.secondary
)
}
Spacer(Modifier.height(8.dp))
LinearProgressIndicator(
progress = { state.progress.toFloat() / state.total.toFloat() },
modifier = Modifier.fillMaxWidth()
progress = { if (state.total > 0) state.completed.toFloat() / state.total.toFloat() else 0f },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
// Performance stats
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
" ${String.format("%.1f", state.imagesPerSecond)} images/sec",
" ${state.facesFound} matches found",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary
)
Text(
"Found: ${state.facesFound}",
style = MaterialTheme.typography.bodySmall
)
}
if (state.imagesSkipped > 0) {
Spacer(Modifier.height(4.dp))
Text(
"Skipped: ${state.imagesSkipped} (no faces)",
"%.1f img/sec".format(state.speed),
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
@@ -284,73 +221,127 @@ fun ScanningProgressCard(state: PersonInventoryViewModel.ScanningState.Scanning)
}
@Composable
fun CompletionCard(state: PersonInventoryViewModel.ScanningState.Complete) {
private fun CompletionCard(state: ScanningState.Complete, onDismiss: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.tertiaryContainer
containerColor = MaterialTheme.colorScheme.primaryContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.tertiary
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(32.dp)
)
Spacer(Modifier.width(12.dp))
Column {
Text(
"Scan Complete!",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.onTertiaryContainer
fontWeight = FontWeight.Bold
)
Text(
"Found ${state.facesFound} photos of ${state.personName} in ${String.format("%.1f", state.durationSeconds)}s",
"Found ${state.personName} in ${state.facesFound} photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Dismiss")
}
}
}
}
@Composable
fun PersonList(
persons: List<PersonInventoryViewModel.PersonWithStats>,
onScan: (String, String) -> Unit,
onImprove: (String, String) -> Unit,
private fun ErrorCard(state: ScanningState.Error, onDismiss: () -> Unit) {
Card(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 8.dp),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Error,
contentDescription = null,
tint = MaterialTheme.colorScheme.error,
modifier = Modifier.size(32.dp)
)
Column {
Text(
"Scan Failed",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
state.message,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
IconButton(onClick = onDismiss) {
Icon(Icons.Default.Close, "Dismiss")
}
}
}
}
@Composable
private fun PersonList(
persons: List<PersonWithModelInfo>,
onScan: (String) -> Unit,
onView: (String) -> Unit,
onDelete: (String, String) -> Unit
onDelete: (String) -> Unit
) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
contentPadding = PaddingValues(bottom = 16.dp)
contentPadding = PaddingValues(vertical = 8.dp)
) {
items(persons, key = { it.person.id }) { personWithStats ->
items(
items = persons,
key = { it.person.id }
) { person ->
PersonCard(
person = personWithStats,
onScan = { onScan(personWithStats.person.id, personWithStats.stats.faceModelId) },
onImprove = { onImprove(personWithStats.person.id, personWithStats.stats.faceModelId) },
onView = { onView(personWithStats.person.id) },
onDelete = { onDelete(personWithStats.person.id, personWithStats.stats.faceModelId) }
person = person,
onScan = { onScan(person.person.id) },
onView = { onView(person.person.id) },
onDelete = { onDelete(person.person.id) }
)
}
}
}
@Composable
fun PersonCard(
person: PersonInventoryViewModel.PersonWithStats,
private fun PersonCard(
person: PersonWithModelInfo,
onScan: () -> Unit,
onImprove: () -> Unit,
onView: () -> Unit,
onDelete: () -> Unit
) {
@@ -410,10 +401,13 @@ fun PersonCard(
Column(Modifier.weight(1f)) {
Text(
person.person.name,
style = MaterialTheme.typography.titleMedium
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
val trainingCount = person.faceModel?.trainingImageCount ?: 0
Text(
"${person.stats.taggedPhotoCount} photos • ${person.stats.trainingImageCount} trained",
"${person.taggedPhotoCount} photos • $trainingCount trained",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.outline
)
@@ -431,12 +425,12 @@ fun PersonCard(
Spacer(Modifier.height(12.dp))
// IMPROVED BUTTON LAYOUT - No text wrapping!
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Primary action - Scan
// Scan button
Button(
onClick = onScan,
modifier = Modifier.weight(1f)
@@ -447,10 +441,10 @@ fun PersonCard(
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("Scan", maxLines = 1)
Text("Scan Library", maxLines = 1)
}
// Secondary action - View
// View button
OutlinedButton(
onClick = onView,
modifier = Modifier.weight(1f)
@@ -461,51 +455,7 @@ fun PersonCard(
modifier = Modifier.size(18.dp)
)
Spacer(Modifier.width(4.dp))
Text("View", maxLines = 1)
}
// Tertiary - More menu
var showMenu by remember { mutableStateOf(false) }
Box {
IconButton(
onClick = { showMenu = true },
modifier = Modifier
.size(48.dp)
.border(
1.dp,
MaterialTheme.colorScheme.outline,
MaterialTheme.shapes.small
)
) {
Icon(Icons.Default.MoreVert, "More")
}
DropdownMenu(
expanded = showMenu,
onDismissRequest = { showMenu = false }
) {
DropdownMenuItem(
text = { Text("Improve Model") },
onClick = {
showMenu = false
onImprove()
},
leadingIcon = {
Icon(Icons.Default.TrendingUp, null)
}
)
DropdownMenuItem(
text = { Text("Export Data") },
onClick = {
showMenu = false
// TODO: Handle export
},
leadingIcon = {
Icon(Icons.Default.Share, null)
}
)
}
Text("View Photos", maxLines = 1)
}
}
}
@@ -513,7 +463,7 @@ fun PersonCard(
}
@Composable
fun EmptyState() {
private fun EmptyState() {
Box(
modifier = Modifier
.fillMaxSize()
@@ -522,21 +472,19 @@ fun EmptyState() {
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(64.dp),
modifier = Modifier.size(72.dp),
tint = MaterialTheme.colorScheme.outline
)
Spacer(Modifier.height(16.dp))
Text(
"No People Yet",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(8.dp))
Text(
"Train your first face model to get started",
style = MaterialTheme.typography.bodyMedium,
@@ -545,37 +493,3 @@ fun EmptyState() {
}
}
}
@Composable
fun ErrorState(message: String) {
Box(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
contentAlignment = Alignment.Center
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(16.dp))
Text(
"Error Loading People",
style = MaterialTheme.typography.titleLarge,
color = MaterialTheme.colorScheme.onSurface
)
Spacer(Modifier.height(8.dp))
Text(
message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.outline
)
}
}
}

View File

@@ -19,6 +19,8 @@ import com.placeholder.sherpai2.ml.FaceNetModel
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
@@ -186,26 +188,27 @@ class PersonInventoryViewModel @Inject constructor(
val startTime = System.currentTimeMillis()
// Batch collection for DB writes (mutex-protected)
val batchMatches = mutableListOf<Triple<String, String, Float>>() // (personId, imageId, confidence)
val batchMatches = mutableListOf<Triple<String, String, Float>>()
// ✅ MASSIVE PARALLELIZATION: Process all images concurrently
// Semaphore(50) limits to 50 simultaneous operations
untaggedImages.map { image ->
kotlinx.coroutines.async(Dispatchers.IO) {
val deferredResults = untaggedImages.map { image ->
async(Dispatchers.IO) {
semaphore.withPermit {
try {
// Load and detect faces
val uri = Uri.parse(image.imageUri)
val inputStream = context.contentResolver.openInputStream(uri) ?: return@withPermit
val inputStream = context.contentResolver.openInputStream(uri)
if (inputStream == null) return@withPermit
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream.close()
if (bitmap == null) return@withPermit
val mlImage = InputImage.fromBitmap(bitmap, 0)
val faces = com.google.android.gms.tasks.Tasks.await(
detector.process(mlImage)
)
val facesTask = detector.process(mlImage)
val faces = com.google.android.gms.tasks.Tasks.await(facesTask)
// Check each detected face
for (face in faces) {
@@ -271,7 +274,10 @@ class PersonInventoryViewModel @Inject constructor(
}
}
}
}.forEach { it.await() } // Wait for all to complete
}
// Wait for all to complete
deferredResults.awaitAll()
// Flush remaining batch
batchUpdateMutex.withLock {