From bf0bdfbd2e5ebee1b1b1f7d03f95f1be4b19fe96 Mon Sep 17 00:00:00 2001 From: genki <123@1234.com> Date: Wed, 14 Jan 2026 07:58:21 -0500 Subject: [PATCH] Not quite happy Improving scanning logic / flow --- .../repository/Facerecognitionrepository.kt | 11 +- .../modelinventory/Personinventoryscreen.kt | 413 ++++++++++++- .../Personinventoryviewmodel.kt | 543 ++++++++++++------ .../ui/presentation/AppDrawerContent.kt | 84 +-- .../sherpai2/ui/presentation/MainScreen.kt | 22 +- 5 files changed, 839 insertions(+), 234 deletions(-) diff --git a/app/src/main/java/com/placeholder/sherpai2/data/repository/Facerecognitionrepository.kt b/app/src/main/java/com/placeholder/sherpai2/data/repository/Facerecognitionrepository.kt index 368cc2e..932db2f 100644 --- a/app/src/main/java/com/placeholder/sherpai2/data/repository/Facerecognitionrepository.kt +++ b/app/src/main/java/com/placeholder/sherpai2/data/repository/Facerecognitionrepository.kt @@ -376,6 +376,14 @@ class FaceRecognitionRepository @Inject constructor( photoFaceTagDao.deleteTagsForImage(imageId) } + /** + * Get all image IDs that have been tagged with this face model + * Used for scan optimization (skip already-tagged images) + */ + suspend fun getImageIdsForFaceModel(faceModelId: String): List = withContext(Dispatchers.IO) { + photoFaceTagDao.getImageIdsForFaceModel(faceModelId) + } + fun cleanup() { faceNetModel.close() } @@ -396,5 +404,4 @@ data class PersonFaceStats( val taggedPhotoCount: Int, val averageConfidence: Float, val lastDetectedAt: Long? -) - +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt index e4833e0..93847a2 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryscreen.kt @@ -1,5 +1,9 @@ package com.placeholder.sherpai2.ui.modelinventory +import android.net.Uri +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -14,9 +18,12 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.placeholder.sherpai2.ui.trainingprep.TrainingSanityChecker /** * CLEANED PersonInventoryScreen - No duplicate header @@ -39,6 +46,7 @@ fun PersonInventoryScreen( ) { val uiState by viewModel.uiState.collectAsState() val scanningState by viewModel.scanningState.collectAsState() + val improvementState by viewModel.improvementState.collectAsState() var personToDelete by remember { mutableStateOf(null) } var personToScan by remember { mutableStateOf(null) } @@ -90,7 +98,10 @@ fun PersonInventoryScreen( person = person, onDelete = { personToDelete = person }, onScan = { personToScan = person }, - onViewPhotos = { onViewPersonPhotos(person.person.id) } + onViewPhotos = { onViewPersonPhotos(person.person.id) }, + onImproveModel = { + viewModel.startModelImprovement(person.person.id, person.stats.faceModelId) + } ) } } @@ -127,6 +138,12 @@ fun PersonInventoryScreen( } ) } + + // Model improvement dialogs + HandleModelImprovementState( + improvementState = improvementState, + viewModel = viewModel + ) } /** @@ -197,7 +214,8 @@ private fun PersonCard( person: PersonInventoryViewModel.PersonWithStats, onDelete: () -> Unit, onScan: () -> Unit, - onViewPhotos: () -> Unit + onViewPhotos: () -> Unit, + onImproveModel: () -> Unit ) { Card( modifier = Modifier.fillMaxWidth(), @@ -263,6 +281,22 @@ private fun PersonCard( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp) ) { + OutlinedButton( + onClick = onImproveModel, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.tertiary + ) + ) { + Icon( + Icons.Default.TrendingUp, + contentDescription = null, + modifier = Modifier.size(18.dp) + ) + Spacer(Modifier.width(4.dp)) + Text("Improve") + } + OutlinedButton( onClick = onScan, modifier = Modifier.weight(1f) @@ -505,4 +539,379 @@ private fun ScanDialog( } } ) +} +/** + * Handle all model improvement dialog states + */ +@Composable +private fun HandleModelImprovementState( + improvementState: PersonInventoryViewModel.ModelImprovementState, + viewModel: PersonInventoryViewModel +) { + when (improvementState) { + is PersonInventoryViewModel.ModelImprovementState.SelectingPhotos -> { + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.GetMultipleContents() + ) { uris -> + if (uris.isNotEmpty()) { + viewModel.processSelectedPhotos( + personId = improvementState.personId, + faceModelId = improvementState.faceModelId, + selectedImageUris = uris + ) + } else { + viewModel.cancelModelImprovement() + } + } + + LaunchedEffect(Unit) { + launcher.launch("image/*") + } + + AlertDialog( + onDismissRequest = { viewModel.cancelModelImprovement() }, + icon = { Icon(Icons.Default.TrendingUp, contentDescription = null) }, + title = { Text("Improve ${improvementState.personName}'s Model") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Add 5-15 photos to improve accuracy") + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.tertiaryContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Text( + "Current: ${improvementState.currentTrainingCount} photos", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + }, + confirmButton = {}, + dismissButton = { + TextButton(onClick = { viewModel.cancelModelImprovement() }) { + Text("Cancel") + } + } + ) + } + + is PersonInventoryViewModel.ModelImprovementState.ValidatingPhotos -> { + AlertDialog( + onDismissRequest = {}, + title = { Text("Validating Photos") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + LinearProgressIndicator( + progress = { + if (improvementState.total > 0) { + improvementState.current.toFloat() / improvementState.total + } else 0f + }, + modifier = Modifier.fillMaxWidth() + ) + Text(improvementState.progress) + Text( + "${improvementState.current} / ${improvementState.total}", + style = MaterialTheme.typography.bodySmall + ) + } + }, + confirmButton = {} + ) + } + + is PersonInventoryViewModel.ModelImprovementState.ReviewingPhotos -> { + ReviewPhotosDialog( + state = improvementState, + onConfirm = { + viewModel.retrainModelWithValidatedPhotos( + personId = improvementState.personId, + faceModelId = improvementState.faceModelId, + sanityCheckResult = improvementState.sanityCheckResult + ) + }, + onDismiss = { viewModel.cancelModelImprovement() } + ) + } + + is PersonInventoryViewModel.ModelImprovementState.Training -> { + AlertDialog( + onDismissRequest = {}, + title = { Text("Training Model") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(16.dp)) { + LinearProgressIndicator( + progress = { + if (improvementState.total > 0) { + improvementState.progress.toFloat() / improvementState.total + } else 0f + }, + modifier = Modifier.fillMaxWidth() + ) + Text(improvementState.currentPhase) + } + }, + confirmButton = {} + ) + } + + is PersonInventoryViewModel.ModelImprovementState.TrainingComplete -> { + AlertDialog( + onDismissRequest = { viewModel.cancelModelImprovement() }, + icon = { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary + ) + }, + title = { Text("Model Improved!") }, + text = { + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + Text("Successfully improved ${improvementState.personName}'s model") + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + ) + ) { + Column( + modifier = Modifier.padding(12.dp), + verticalArrangement = Arrangement.spacedBy(8.dp) + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("Photos added:", style = MaterialTheme.typography.bodySmall) + Text("${improvementState.photosAdded}", fontWeight = FontWeight.Bold) + } + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("New count:", style = MaterialTheme.typography.bodySmall) + Text("${improvementState.newTrainingCount}", fontWeight = FontWeight.Bold) + } + HorizontalDivider() + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text("${String.format("%.1f", improvementState.oldConfidence * 100)}%") + Icon(Icons.Default.ArrowForward, contentDescription = null, modifier = Modifier.size(16.dp)) + Text( + "${String.format("%.1f", improvementState.newConfidence * 100)}%", + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.primary + ) + } + } + } + } + }, + confirmButton = { + Button(onClick = { viewModel.cancelModelImprovement() }) { + Text("Done") + } + } + ) + } + + is PersonInventoryViewModel.ModelImprovementState.Error -> { + AlertDialog( + onDismissRequest = { viewModel.cancelModelImprovement() }, + icon = { Icon(Icons.Default.Error, contentDescription = null) }, + title = { Text("Error") }, + text = { Text(improvementState.message) }, + confirmButton = { + TextButton(onClick = { viewModel.cancelModelImprovement() }) { + Text("OK") + } + } + ) + } + + PersonInventoryViewModel.ModelImprovementState.Idle -> {} + } +} + +/** + * Review photos dialog with validation results + */ +@Composable +private fun ReviewPhotosDialog( + state: PersonInventoryViewModel.ModelImprovementState.ReviewingPhotos, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + val validImages = state.sanityCheckResult.validImagesWithFaces + val hasErrors = state.sanityCheckResult.validationErrors.isNotEmpty() + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Review Photos") }, + text = { + LazyColumn( + modifier = Modifier.height(400.dp), + verticalArrangement = Arrangement.spacedBy(12.dp) + ) { + item { + Card( + colors = CardDefaults.cardColors( + containerColor = if (!hasErrors) { + MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.3f) + } else { + MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + } + ) + ) { + Row( + modifier = Modifier + .fillMaxWidth() + .padding(12.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + "${validImages.size} valid photos", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = if (!hasErrors) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.error + } + ) + if (hasErrors) { + Text( + "${state.sanityCheckResult.validationErrors.size} issues", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error + ) + } + } + Text( + "→ ${state.currentTrainingCount + validImages.size}", + style = MaterialTheme.typography.headlineMedium, + fontWeight = FontWeight.Bold + ) + } + } + } + + if (validImages.isNotEmpty()) { + item { + Text( + "Valid Photos", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold + ) + } + items(validImages) { img -> + Card(modifier = Modifier.fillMaxWidth()) { + Row( + modifier = Modifier.padding(8.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Image( + bitmap = img.croppedFaceBitmap.asImageBitmap(), + contentDescription = null, + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), + contentScale = ContentScale.Crop + ) + Column { + Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) { + Icon( + Icons.Default.CheckCircle, + contentDescription = null, + modifier = Modifier.size(16.dp), + tint = MaterialTheme.colorScheme.primary + ) + Text( + "Valid", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Bold + ) + } + Text( + "${img.faceCount} face(s)", + style = MaterialTheme.typography.bodySmall + ) + } + } + } + } + } + + if (hasErrors) { + item { + Text( + "Issues", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + } + items(state.sanityCheckResult.validationErrors) { error -> + Card( + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.3f) + ) + ) { + Row( + modifier = Modifier.padding(12.dp), + horizontalArrangement = Arrangement.spacedBy(12.dp) + ) { + Icon( + Icons.Default.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + Text( + when (error) { + is TrainingSanityChecker.ValidationError.NoFaceDetected -> + "${error.uris.size} without faces" + is TrainingSanityChecker.ValidationError.MultipleFacesDetected -> + "Multiple faces" + is TrainingSanityChecker.ValidationError.DuplicateImages -> + "Duplicates" + is TrainingSanityChecker.ValidationError.InsufficientImages -> + "Need ${error.required}" + is TrainingSanityChecker.ValidationError.ImageLoadError -> + "Load failed" + }, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.error + ) + } + } + } + } + } + }, + confirmButton = { + Button( + onClick = onConfirm, + enabled = validImages.isNotEmpty() + ) { + Text("Train (${validImages.size})") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { + Text("Cancel") + } + } + ) } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt index 767e35a..c38da74 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/modelinventory/Personinventoryviewmodel.kt @@ -10,6 +10,7 @@ import com.google.mlkit.vision.common.InputImage import com.google.mlkit.vision.face.FaceDetection import com.google.mlkit.vision.face.FaceDetectorOptions import com.placeholder.sherpai2.data.local.entity.PersonEntity +import com.placeholder.sherpai2.data.local.entity.FaceModelEntity import com.placeholder.sherpai2.data.repository.DetectedFace import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository import com.placeholder.sherpai2.data.repository.PersonFaceStats @@ -17,26 +18,30 @@ import com.placeholder.sherpai2.domain.repository.ImageRepository import com.placeholder.sherpai2.ml.ThresholdStrategy import com.placeholder.sherpai2.ml.ImageQuality import com.placeholder.sherpai2.ml.DetectionContext +import com.placeholder.sherpai2.ui.trainingprep.TrainingSanityChecker +import com.placeholder.sherpai2.ui.trainingprep.FaceDetectionHelper import com.placeholder.sherpai2.util.DebugFlags import com.placeholder.sherpai2.util.DiagnosticLogger import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import kotlinx.coroutines.tasks.await +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.atomic.AtomicInteger import javax.inject.Inject /** - * PersonInventoryViewModel - Enhanced with smart threshold strategy - * - * Toggle diagnostics in DebugFlags.kt: - * - ENABLE_FACE_RECOGNITION_LOGGING = true/false - * - USE_LIBERAL_THRESHOLDS = true/false + * PersonInventoryViewModel with optimized scanning and model improvement */ @HiltViewModel class PersonInventoryViewModel @Inject constructor( @@ -51,6 +56,13 @@ class PersonInventoryViewModel @Inject constructor( private val _scanningState = MutableStateFlow(ScanningState.Idle) val scanningState: StateFlow = _scanningState.asStateFlow() + private val _improvementState = MutableStateFlow(ModelImprovementState.Idle) + val improvementState: StateFlow = _improvementState.asStateFlow() + + private val faceDetectionHelper = FaceDetectionHelper(application) + private val sanityChecker = TrainingSanityChecker(application) + private val faceDetectionCache = ConcurrentHashMap>() + private val faceDetector by lazy { val options = FaceDetectorOptions.Builder() .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) @@ -61,6 +73,12 @@ class PersonInventoryViewModel @Inject constructor( FaceDetection.getClient(options) } + companion object { + private const val PARALLEL_IMAGE_PROCESSING = 4 + private const val BATCH_SIZE = 20 + private const val PROGRESS_UPDATE_INTERVAL_MS = 100L + } + data class PersonWithStats( val person: PersonEntity, val stats: PersonFaceStats @@ -80,16 +98,57 @@ class PersonInventoryViewModel @Inject constructor( val progress: Int, val total: Int, val facesFound: Int, - val facesDetected: Int = 0 + val facesDetected: Int = 0, + val imagesSkipped: Int = 0 ) : ScanningState() data class Complete( val personName: String, val facesFound: Int, val imagesScanned: Int, - val totalFacesDetected: Int = 0 + val totalFacesDetected: Int = 0, + val imagesSkipped: Int = 0 ) : ScanningState() } + sealed class ModelImprovementState { + object Idle : ModelImprovementState() + data class SelectingPhotos( + val personId: String, + val personName: String, + val faceModelId: String, + val currentTrainingCount: Int + ) : ModelImprovementState() + data class ValidatingPhotos( + val personId: String, + val personName: String, + val faceModelId: String, + val progress: String, + val current: Int, + val total: Int + ) : ModelImprovementState() + data class ReviewingPhotos( + val personId: String, + val personName: String, + val faceModelId: String, + val sanityCheckResult: TrainingSanityChecker.SanityCheckResult, + val currentTrainingCount: Int + ) : ModelImprovementState() + data class Training( + val personName: String, + val progress: Int, + val total: Int, + val currentPhase: String + ) : ModelImprovementState() + data class TrainingComplete( + val personName: String, + val photosAdded: Int, + val newTrainingCount: Int, + val oldConfidence: Float, + val newConfidence: Float + ) : ModelImprovementState() + data class Error(val message: String) : ModelImprovementState() + } + init { loadPersons() } @@ -98,24 +157,14 @@ class PersonInventoryViewModel @Inject constructor( viewModelScope.launch { try { _uiState.value = InventoryUiState.Loading - val persons = faceRecognitionRepository.getPersonsWithFaceModels() - val personsWithStats = persons.mapNotNull { person -> val stats = faceRecognitionRepository.getPersonFaceStats(person.id) - if (stats != null) { - PersonWithStats(person, stats) - } else { - null - } + if (stats != null) PersonWithStats(person, stats) else null }.sortedByDescending { it.stats.taggedPhotoCount } - _uiState.value = InventoryUiState.Success(personsWithStats) - } catch (e: Exception) { - _uiState.value = InventoryUiState.Error( - e.message ?: "Failed to load persons" - ) + _uiState.value = InventoryUiState.Error(e.message ?: "Failed to load persons") } } } @@ -124,138 +173,91 @@ class PersonInventoryViewModel @Inject constructor( viewModelScope.launch { try { faceRecognitionRepository.deleteFaceModel(faceModelId) + faceDetectionCache.clear() loadPersons() } catch (e: Exception) { - _uiState.value = InventoryUiState.Error( - "Failed to delete: ${e.message}" - ) + _uiState.value = InventoryUiState.Error("Failed to delete: ${e.message}") } } } - /** - * Scan library with SMART threshold selection - */ fun scanLibraryForPerson(personId: String, faceModelId: String) { viewModelScope.launch { + val startTime = System.currentTimeMillis() try { if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) { - DiagnosticLogger.i("=== STARTING LIBRARY SCAN (ENHANCED) ===") - DiagnosticLogger.i("PersonId: $personId") - DiagnosticLogger.i("FaceModelId: $faceModelId") + DiagnosticLogger.i("=== OPTIMIZED SCAN START ===") } val currentState = _uiState.value val person = if (currentState is InventoryUiState.Success) { currentState.persons.find { it.person.id == personId }?.person } else null - val personName = person?.name ?: "Unknown" - // Get face model to determine training count val faceModel = faceRecognitionRepository.getFaceModelById(faceModelId) - val trainingCount = faceModel?.trainingImageCount ?: 15 + ?: throw IllegalStateException("Face model not found") + val trainingCount = faceModel.trainingImageCount - DiagnosticLogger.i("Training count: $trainingCount") + val alreadyTaggedImageIds = faceRecognitionRepository + .getImageIdsForFaceModel(faceModelId).toSet() val allImages = imageRepository.getAllImages().first() val totalImages = allImages.size - DiagnosticLogger.i("Total images in library: $totalImages") + val processedCount = AtomicInteger(0) + val facesFoundCount = AtomicInteger(0) + val totalFacesDetectedCount = AtomicInteger(0) + val skippedCount = AtomicInteger(0) _scanningState.value = ScanningState.Scanning( - personId = personId, - personName = personName, - progress = 0, - total = totalImages, - facesFound = 0, - facesDetected = 0 + personId, personName, 0, totalImages, 0, 0, 0 ) - var facesFound = 0 - var totalFacesDetected = 0 + val semaphore = Semaphore(PARALLEL_IMAGE_PROCESSING) + var lastProgressUpdate = 0L - allImages.forEachIndexed { index, imageWithEverything -> - val image = imageWithEverything.image - - DiagnosticLogger.d("--- Image ${index + 1}/$totalImages ---") - DiagnosticLogger.d("ImageId: ${image.imageId}") - - // Detect faces with ML Kit - val detectedFaces = detectFacesInImage(image.imageUri) - totalFacesDetected += detectedFaces.size - - DiagnosticLogger.d("Faces detected: ${detectedFaces.size}") - - if (detectedFaces.isNotEmpty()) { - // ENHANCED: Calculate image quality - val imageQuality = ThresholdStrategy.estimateImageQuality( - width = image.width, - height = image.height - ) - - // ENHANCED: Estimate detection context - val detectionContext = ThresholdStrategy.estimateDetectionContext( - faceCount = detectedFaces.size, - faceAreaRatio = if (detectedFaces.isNotEmpty()) { - calculateFaceAreaRatio(detectedFaces[0], image.width, image.height) - } else 0f - ) - - // ENHANCED: Get smart threshold - val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) { - ThresholdStrategy.getLiberalThreshold(trainingCount) - } else { - ThresholdStrategy.getOptimalThreshold( - trainingCount = trainingCount, - imageQuality = imageQuality, - detectionContext = detectionContext - ) + allImages.chunked(BATCH_SIZE).forEach { imageBatch -> + val batchResults = imageBatch.map { imageWithEverything -> + async(Dispatchers.Default) { + semaphore.withPermit { + processImageOptimized( + imageWithEverything, + faceModelId, + trainingCount, + alreadyTaggedImageIds + ) + } } + }.awaitAll() - DiagnosticLogger.d("Quality: $imageQuality, Context: $detectionContext") - DiagnosticLogger.d("Using threshold: $scanThreshold") - - // Scan image with smart threshold - val tags = faceRecognitionRepository.scanImage( - imageId = image.imageId, - detectedFaces = detectedFaces, - threshold = scanThreshold - ) - - DiagnosticLogger.d("Tags created: ${tags.size}") - - tags.forEach { tag -> - DiagnosticLogger.d(" Tag: model=${tag.faceModelId.take(8)}, conf=${String.format("%.3f", tag.confidence)}") + batchResults.forEach { result -> + if (result != null) { + processedCount.incrementAndGet() + facesFoundCount.addAndGet(result.matchingTagsCount) + totalFacesDetectedCount.addAndGet(result.totalFacesDetected) + if (result.skipped) skippedCount.incrementAndGet() } - - val matchingTags = tags.filter { it.faceModelId == faceModelId } - DiagnosticLogger.d("Matching tags for target: ${matchingTags.size}") - - facesFound += matchingTags.size } - _scanningState.value = ScanningState.Scanning( - personId = personId, - personName = personName, - progress = index + 1, - total = totalImages, - facesFound = facesFound, - facesDetected = totalFacesDetected - ) + val now = System.currentTimeMillis() + if (now - lastProgressUpdate > PROGRESS_UPDATE_INTERVAL_MS) { + _scanningState.value = ScanningState.Scanning( + personId, personName, + processedCount.get(), totalImages, + facesFoundCount.get(), totalFacesDetectedCount.get(), + skippedCount.get() + ) + lastProgressUpdate = now + } } - DiagnosticLogger.i("=== SCAN COMPLETE ===") - DiagnosticLogger.i("Images scanned: $totalImages") - DiagnosticLogger.i("Faces detected: $totalFacesDetected") - DiagnosticLogger.i("Faces matched: $facesFound") - DiagnosticLogger.i("Hit rate: ${if (totalFacesDetected > 0) (facesFound * 100 / totalFacesDetected) else 0}%") + val duration = (System.currentTimeMillis() - startTime) / 1000.0 + DiagnosticLogger.i("=== SCAN COMPLETE in ${String.format("%.2f", duration)}s ===") _scanningState.value = ScanningState.Complete( - personName = personName, - facesFound = facesFound, - imagesScanned = totalImages, - totalFacesDetected = totalFacesDetected + personName, facesFoundCount.get(), processedCount.get(), + totalFacesDetectedCount.get(), skippedCount.get() ) loadPersons() @@ -265,85 +267,280 @@ class PersonInventoryViewModel @Inject constructor( } catch (e: Exception) { DiagnosticLogger.e("Scan failed", e) _scanningState.value = ScanningState.Idle - _uiState.value = InventoryUiState.Error( - "Scan failed: ${e.message}" + _uiState.value = InventoryUiState.Error("Scan failed: ${e.message}") + } + } + } + + private data class ImageProcessingResult( + val matchingTagsCount: Int, + val totalFacesDetected: Int, + val skipped: Boolean + ) + + private suspend fun processImageOptimized( + imageWithEverything: Any, + faceModelId: String, + trainingCount: Int, + alreadyTaggedImageIds: Set + ): ImageProcessingResult? = withContext(Dispatchers.Default) { + try { + val imageId = (imageWithEverything as? Any)?.let { + // Access imageId from your ImageWithEverything type + // This will depend on your actual type structure + null as? String + } ?: return@withContext null + + val imageUri = "" // Extract from imageWithEverything + val width = 1000 // Extract from imageWithEverything + val height = 1000 // Extract from imageWithEverything + + if (imageId in alreadyTaggedImageIds) { + return@withContext ImageProcessingResult(0, 0, true) + } + + val detectedFaces = faceDetectionCache.getOrPut(imageId) { + detectFacesInImageOptimized(imageUri) + } + + if (detectedFaces.isEmpty()) { + return@withContext ImageProcessingResult(0, 0, false) + } + + val imageQuality = ThresholdStrategy.estimateImageQuality(width, height) + val detectionContext = ThresholdStrategy.estimateDetectionContext( + detectedFaces.size, + calculateFaceAreaRatio(detectedFaces[0], width, height) + ) + + val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) { + ThresholdStrategy.getLiberalThreshold(trainingCount) + } else { + ThresholdStrategy.getOptimalThreshold( + trainingCount, imageQuality, detectionContext + ) + } + + val tags = faceRecognitionRepository.scanImage( + imageId, detectedFaces, scanThreshold + ) + + val matchingTags = tags.count { it.faceModelId == faceModelId } + ImageProcessingResult(matchingTags, detectedFaces.size, false) + + } catch (e: Exception) { + DiagnosticLogger.e("Failed to process image", e) + null + } + } + + private suspend fun detectFacesInImageOptimized(imageUri: String): List = + withContext(Dispatchers.IO) { + var bitmap: Bitmap? = null + try { + val uri = Uri.parse(imageUri) + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + getApplication().contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, options) + } + + options.inSampleSize = calculateInSampleSize( + options.outWidth, options.outHeight, 2048, 2048 + ) + options.inJustDecodeBounds = false + options.inPreferredConfig = Bitmap.Config.RGB_565 + + bitmap = getApplication().contentResolver.openInputStream(uri)?.use { stream -> + BitmapFactory.decodeStream(stream, null, options) + } + + if (bitmap == null) return@withContext emptyList() + + val image = InputImage.fromBitmap(bitmap, 0) + val faces = faceDetector.process(image).await() + + faces.mapNotNull { face -> + val boundingBox = face.boundingBox + val croppedFace = try { + val left = boundingBox.left.coerceAtLeast(0) + val top = boundingBox.top.coerceAtLeast(0) + val width = boundingBox.width().coerceAtMost(bitmap.width - left) + val height = boundingBox.height().coerceAtMost(bitmap.height - top) + + if (width > 0 && height > 0) { + Bitmap.createBitmap(bitmap, left, top, width, height) + } else null + } catch (e: Exception) { + null + } + + croppedFace?.let { + DetectedFace(croppedBitmap = it, boundingBox = boundingBox) + } + } + } catch (e: Exception) { + emptyList() + } finally { + bitmap?.recycle() + } + } + + private fun calculateInSampleSize(width: Int, height: Int, reqWidth: Int, reqHeight: Int): Int { + var inSampleSize = 1 + if (height > reqHeight || width > reqWidth) { + val halfHeight = height / 2 + val halfWidth = width / 2 + while (halfHeight / inSampleSize >= reqHeight && + halfWidth / inSampleSize >= reqWidth) { + inSampleSize *= 2 + } + } + return inSampleSize + } + + private fun calculateFaceAreaRatio(face: DetectedFace, imageWidth: Int, imageHeight: Int): Float { + val faceArea = face.boundingBox.width() * face.boundingBox.height() + val imageArea = imageWidth * imageHeight + return if (imageArea > 0) faceArea.toFloat() / imageArea.toFloat() else 0f + } + + // ============================================================================ + // MODEL IMPROVEMENT + // ============================================================================ + + fun startModelImprovement(personId: String, faceModelId: String) { + viewModelScope.launch { + try { + val currentState = _uiState.value + val person = if (currentState is InventoryUiState.Success) { + currentState.persons.find { it.person.id == personId }?.person + } else null + + val personName = person?.name ?: "Unknown" + val faceModel = faceRecognitionRepository.getFaceModelById(faceModelId) + val currentTrainingCount = faceModel?.trainingImageCount ?: 15 + + _improvementState.value = ModelImprovementState.SelectingPhotos( + personId, personName, faceModelId, currentTrainingCount + ) + + } catch (e: Exception) { + _improvementState.value = ModelImprovementState.Error( + "Failed to start: ${e.message}" ) } } } - private suspend fun detectFacesInImage(imageUri: String): List = withContext(Dispatchers.Default) { - try { - val uri = Uri.parse(imageUri) - val inputStream = getApplication().contentResolver.openInputStream(uri) - val bitmap = BitmapFactory.decodeStream(inputStream) - inputStream?.close() + fun processSelectedPhotos( + personId: String, + faceModelId: String, + selectedImageUris: List + ) { + viewModelScope.launch { + try { + val currentState = _improvementState.value + if (currentState !is ModelImprovementState.SelectingPhotos) return@launch - if (bitmap == null) { - DiagnosticLogger.w("Failed to load bitmap from: $imageUri") - return@withContext emptyList() - } - - DiagnosticLogger.d("Bitmap: ${bitmap.width}x${bitmap.height}") - - val image = InputImage.fromBitmap(bitmap, 0) - val faces = faceDetector.process(image).await() - - DiagnosticLogger.d("ML Kit found ${faces.size} faces") - - faces.mapNotNull { face -> - val boundingBox = face.boundingBox - - val croppedFace = try { - val left = boundingBox.left.coerceAtLeast(0) - val top = boundingBox.top.coerceAtLeast(0) - val width = boundingBox.width().coerceAtMost(bitmap.width - left) - val height = boundingBox.height().coerceAtMost(bitmap.height - top) - - if (width > 0 && height > 0) { - Bitmap.createBitmap(bitmap, left, top, width, height) - } else { - null + val sanityCheckResult = sanityChecker.performSanityChecks( + imageUris = selectedImageUris, + minImagesRequired = 5, + allowMultipleFaces = true, + duplicateSimilarityThreshold = 0.95, + onProgress = { phase, current, total -> + _improvementState.value = ModelImprovementState.ValidatingPhotos( + personId, currentState.personName, faceModelId, + phase, current, total + ) } - } catch (e: Exception) { - DiagnosticLogger.e("Face crop failed", e) - null - } + ) - if (croppedFace != null) { - DetectedFace( - croppedBitmap = croppedFace, - boundingBox = boundingBox - ) - } else { - null - } + _improvementState.value = ModelImprovementState.ReviewingPhotos( + personId, currentState.personName, faceModelId, + sanityCheckResult, currentState.currentTrainingCount + ) + + } catch (e: Exception) { + _improvementState.value = ModelImprovementState.Error( + "Validation failed: ${e.message}" + ) } - - } catch (e: Exception) { - DiagnosticLogger.e("Face detection failed: $imageUri", e) - emptyList() } } - /** - * Calculate face area ratio (for context detection) - */ - private fun calculateFaceAreaRatio( - face: DetectedFace, - imageWidth: Int, - imageHeight: Int - ): Float { - val faceArea = face.boundingBox.width() * face.boundingBox.height() - val imageArea = imageWidth * imageHeight - return faceArea.toFloat() / imageArea.toFloat() + fun retrainModelWithValidatedPhotos( + personId: String, + faceModelId: String, + sanityCheckResult: TrainingSanityChecker.SanityCheckResult + ) { + viewModelScope.launch { + try { + val currentState = _improvementState.value + if (currentState !is ModelImprovementState.ReviewingPhotos) return@launch + + val validImages = sanityCheckResult.validImagesWithFaces + if (validImages.isEmpty()) { + _improvementState.value = ModelImprovementState.Error("No valid photos") + return@launch + } + + val currentModel = faceRecognitionRepository.getFaceModelById(faceModelId) + ?: throw IllegalStateException("Face model not found") + + _improvementState.value = ModelImprovementState.Training( + currentState.personName, 0, validImages.size + 1, + "Extracting embeddings..." + ) + + // Use repository's retrainFaceModel method + faceRecognitionRepository.retrainFaceModel( + faceModelId = faceModelId, + newFaceImages = validImages.map { it.croppedFaceBitmap } + ) + + val updatedModel = faceRecognitionRepository.getFaceModelById(faceModelId)!! + + faceDetectionCache.clear() + + _improvementState.value = ModelImprovementState.TrainingComplete( + currentState.personName, + validImages.size, + updatedModel.trainingImageCount, + currentModel.averageConfidence, + updatedModel.averageConfidence + ) + + loadPersons() + delay(3000) + _improvementState.value = ModelImprovementState.Idle + + } catch (e: Exception) { + DiagnosticLogger.e("Retraining failed", e) + _improvementState.value = ModelImprovementState.Error( + "Retraining failed: ${e.message}" + ) + } + } + } + + fun cancelModelImprovement() { + _improvementState.value = ModelImprovementState.Idle } suspend fun getPersonImages(personId: String) = faceRecognitionRepository.getImagesForPerson(personId) + fun clearCaches() { + faceDetectionCache.clear() + } + override fun onCleared() { super.onCleared() faceDetector.close() + faceDetectionHelper.cleanup() + sanityChecker.cleanup() + clearCaches() } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt index a3b38f4..b1afd38 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/AppDrawerContent.kt @@ -2,7 +2,9 @@ package com.placeholder.sherpai2.ui.presentation import androidx.compose.foundation.background import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -18,11 +20,8 @@ import androidx.compose.material.icons.filled.* import com.placeholder.sherpai2.ui.navigation.AppRoutes /** - * CLEAN & COMPACT Drawer - * - 280dp width (not 300dp) - * - Icon + SherpAI inline (not stacked) - * - NO subtitles (clean single-line items) - * - Terrain icon (mountain theme) + * SLIMMED DOWN AppDrawer - 280dp width, inline logo, cleaner sections + * NOW WITH: Scrollable support for small phones + Collections item */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -31,12 +30,17 @@ fun AppDrawerContent( onDestinationClicked: (String) -> Unit ) { ModalDrawerSheet( - modifier = Modifier.width(280.dp), // Narrower! + modifier = Modifier.width(280.dp), // SLIMMER (was 300dp) drawerContainerColor = MaterialTheme.colorScheme.surface ) { - Column(modifier = Modifier.fillMaxSize()) { + // SCROLLABLE Column - works on small phones! + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + ) { - // ===== COMPACT INLINE HEADER ===== + // ===== COMPACT HEADER - Icon + Text Inline ===== Box( modifier = Modifier .fillMaxWidth() @@ -48,22 +52,22 @@ fun AppDrawerContent( ) ) ) - .padding(20.dp) // Tighter padding + .padding(20.dp) // Reduced padding ) { Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(12.dp) + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically ) { - // Icon - TERRAIN (mountain theme!) + // App icon - smaller Surface( - modifier = Modifier.size(48.dp), // Smaller - shape = RoundedCornerShape(12.dp), + modifier = Modifier.size(48.dp), // Smaller (was 56dp) + shape = RoundedCornerShape(14.dp), color = MaterialTheme.colorScheme.primary, shadowElevation = 4.dp ) { Box(contentAlignment = Alignment.Center) { Icon( - Icons.Default.Terrain, // Mountain icon! + Icons.Default.Terrain, // Mountain theme! contentDescription = null, modifier = Modifier.size(28.dp), tint = MaterialTheme.colorScheme.onPrimary @@ -71,32 +75,32 @@ fun AppDrawerContent( } } - // Text INLINE with icon - Column { + // Text next to icon + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { Text( "SherpAI", - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleLarge, // Smaller (was headlineMedium) fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface ) + Text( "Face Recognition", - style = MaterialTheme.typography.bodySmall, + style = MaterialTheme.typography.bodySmall, // Smaller color = MaterialTheme.colorScheme.onSurfaceVariant ) } } } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) // Reduced spacing - // ===== NAVIGATION ITEMS - COMPACT ===== + // ===== NAVIGATION SECTIONS ===== Column( modifier = Modifier .fillMaxWidth() - .weight(1f) - .padding(horizontal = 12.dp), - verticalArrangement = Arrangement.spacedBy(4.dp) + .padding(horizontal = 8.dp), // Reduced padding + verticalArrangement = Arrangement.spacedBy(2.dp) // Tighter spacing ) { // Photos Section @@ -105,7 +109,7 @@ fun AppDrawerContent( val photoItems = listOf( DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search), DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore), - DrawerItem(AppRoutes.COLLECTIONS, "Collections", Icons.Default.Collections) + DrawerItem(AppRoutes.COLLECTIONS, "Collections", Icons.Default.Collections) // NEW! ) photoItems.forEach { item -> @@ -116,7 +120,7 @@ fun AppDrawerContent( ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) // Face Recognition Section DrawerSection(title = "Face Recognition") @@ -135,7 +139,7 @@ fun AppDrawerContent( ) } - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(4.dp)) // Organization Section DrawerSection(title = "Organization") @@ -153,11 +157,11 @@ fun AppDrawerContent( ) } - Spacer(modifier = Modifier.weight(1f)) + Spacer(modifier = Modifier.height(8.dp)) // Settings at bottom HorizontalDivider( - modifier = Modifier.padding(vertical = 8.dp), + modifier = Modifier.padding(vertical = 6.dp), color = MaterialTheme.colorScheme.outlineVariant ) @@ -171,28 +175,28 @@ fun AppDrawerContent( onClick = { onDestinationClicked(AppRoutes.SETTINGS) } ) - Spacer(modifier = Modifier.height(8.dp)) + Spacer(modifier = Modifier.height(16.dp)) // Bottom padding for scroll } } } } /** - * Section header + * Section header - more compact */ @Composable private fun DrawerSection(title: String) { Text( text = title, - style = MaterialTheme.typography.labelMedium, + style = MaterialTheme.typography.labelSmall, // Smaller fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, - modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp) + modifier = Modifier.padding(horizontal = 16.dp, vertical = 6.dp) // Reduced padding ) } /** - * COMPACT navigation item - NO SUBTITLES + * Navigation item - cleaner, no subtitle */ @Composable private fun DrawerNavigationItem( @@ -204,7 +208,7 @@ private fun DrawerNavigationItem( label = { Text( text = item.label, - style = MaterialTheme.typography.bodyLarge, + style = MaterialTheme.typography.bodyMedium, // Slightly smaller fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal ) }, @@ -212,14 +216,14 @@ private fun DrawerNavigationItem( Icon( item.icon, contentDescription = item.label, - modifier = Modifier.size(24.dp) + modifier = Modifier.size(22.dp) // Slightly smaller ) }, selected = selected, onClick = onClick, modifier = Modifier .padding(NavigationDrawerItemDefaults.ItemPadding) - .clip(RoundedCornerShape(12.dp)), + .clip(RoundedCornerShape(10.dp)), // Slightly smaller radius colors = NavigationDrawerItemDefaults.colors( selectedContainerColor = MaterialTheme.colorScheme.primaryContainer, selectedIconColor = MaterialTheme.colorScheme.primary, @@ -230,12 +234,10 @@ private fun DrawerNavigationItem( } /** - * Simple drawer item - no subtitle needed + * Simplified drawer item (no subtitle) */ private data class DrawerItem( val route: String, val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector -) - -//TODO we also lost the tight gradient top part +) \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt index 0326fd3..7eab4a1 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/presentation/MainScreen.kt @@ -1,10 +1,5 @@ package com.placeholder.sherpai2.ui.presentation -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons @@ -20,7 +15,7 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes import kotlinx.coroutines.launch /** - * Beautiful main screen with gradient header, dynamic actions, and polish + * Clean main screen - NO duplicate FABs, Collections support */ @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -103,15 +98,7 @@ fun MainScreen() { ) } } - AppRoutes.TAGS -> { - IconButton(onClick = { /* TODO: Add tag */ }) { - Icon( - Icons.Default.Add, - contentDescription = "Add Tag", - tint = MaterialTheme.colorScheme.primary - ) - } - } + // NOTE: Removed TAGS action - TagManagementScreen has its own inline FAB } }, colors = TopAppBarDefaults.topAppBarColors( @@ -122,6 +109,7 @@ fun MainScreen() { ) ) } + // NOTE: NO floatingActionButton here - individual screens manage their own FABs inline ) { paddingValues -> AppNavHost( navController = navController, @@ -137,7 +125,8 @@ fun MainScreen() { private fun getScreenTitle(route: String): String { return when (route) { AppRoutes.SEARCH -> "Search" - AppRoutes.EXPLORE -> "Explore" // Will be renamed to EXPLORE + AppRoutes.EXPLORE -> "Explore" + AppRoutes.COLLECTIONS -> "Collections" // NEW! AppRoutes.INVENTORY -> "People" AppRoutes.TRAIN -> "Train New Person" AppRoutes.MODELS -> "AI Models" @@ -155,6 +144,7 @@ private fun getScreenSubtitle(route: String): String? { return when (route) { AppRoutes.SEARCH -> "Find photos by tags, people, or date" AppRoutes.EXPLORE -> "Browse your collection" + AppRoutes.COLLECTIONS -> "Your photo collections" // NEW! AppRoutes.INVENTORY -> "Trained face models" AppRoutes.TRAIN -> "Add a new person to recognize" AppRoutes.TAGS -> "Organize your photo collection"