diff --git a/.idea/deviceManager.xml b/.idea/deviceManager.xml new file mode 100644 index 0000000..91f9558 --- /dev/null +++ b/.idea/deviceManager.xml @@ -0,0 +1,13 @@ + + + + + + \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml new file mode 100644 index 0000000..7061a0d --- /dev/null +++ b/.idea/inspectionProfiles/Project_Default.xml @@ -0,0 +1,61 @@ + + + + \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerScreen.kt new file mode 100644 index 0000000..fc1a53b --- /dev/null +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/FacePickerScreen.kt @@ -0,0 +1,56 @@ +package com.placeholder.sherpai2.ui.trainingprep +import android.graphics.Rect +import android.net.Uri +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Card +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter + +@Composable +fun FacePickerScreen( + uri: Uri, + faceBoxes: List, + onFaceSelected: (Rect) -> Unit +) { + Column(modifier = Modifier.fillMaxSize().padding(16.dp)) { + Text("Multiple faces detected!", style = MaterialTheme.typography.headlineSmall) + Text("Tap the person you want to train on.") + + Box(modifier = Modifier.weight(1f).fillMaxWidth().padding(vertical = 16.dp)) { + // Main Image + Image( + painter = rememberAsyncImagePainter(uri), + contentDescription = null, + modifier = Modifier.fillMaxSize(), + contentScale = ContentScale.Fit + ) + + // Overlay Clickable Boxes + // Note: In a production app, you'd need to map Rect coordinates + // from the Bitmap scale to the UI View scale. + Canvas(modifier = Modifier.fillMaxSize().clickable { /* Handle general tap */ }) { + // Implementation of coordinate mapping goes here + } + + // Simplified: Just show the options as a list of crops if Canvas mapping is too complex for now + LazyRow { + items(faceBoxes) { box -> + Card(modifier = Modifier.padding(8.dp).size(100.dp).clickable { onFaceSelected(box) }) { + Text("Face ${faceBoxes.indexOf(box) + 1}", Modifier.align(Alignment.CenterHorizontally)) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt index 88345bf..9b61b5b 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/ScanResultsScreen.kt @@ -2,9 +2,10 @@ package com.placeholder.sherpai2.ui.trainingprep import android.net.Uri import androidx.compose.foundation.Image +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.items // CRITICAL: This is the correct import for List items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.Composable @@ -14,14 +15,20 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter +import androidx.compose.foundation.lazy.items +/** + * Displays the outcome of the face detection process. + */ @Composable fun ScanResultsScreen( state: ScanningState, onFinish: () -> Unit ) { Column( - modifier = Modifier.fillMaxSize().padding(16.dp), + modifier = Modifier + .fillMaxSize() + .padding(16.dp), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.Center ) { @@ -37,16 +44,25 @@ fun ScanResultsScreen( style = MaterialTheme.typography.headlineMedium ) - LazyColumn(modifier = Modifier.weight(1f).padding(vertical = 16.dp)) { + LazyColumn( + modifier = Modifier + .weight(1f) + .padding(vertical = 16.dp) + ) { + // FIX: Ensure 'items' is the one that takes a List, not a count items(state.results) { result -> Row( - modifier = Modifier.fillMaxWidth().padding(8.dp), + modifier = Modifier + .fillMaxWidth() + .padding(8.dp), verticalAlignment = Alignment.CenterVertically ) { Image( painter = rememberAsyncImagePainter(result.uri), contentDescription = null, - modifier = Modifier.size(60.dp).clip(RoundedCornerShape(8.dp)), + modifier = Modifier + .size(64.dp) + .clip(RoundedCornerShape(8.dp)), contentScale = ContentScale.Crop ) Spacer(Modifier.width(16.dp)) @@ -54,7 +70,7 @@ fun ScanResultsScreen( Text(if (result.faceCount > 0) "✅ Face Detected" else "❌ No Face") if (result.hasMultipleFaces) { Text( - "⚠️ Multiple faces (${result.faceCount})", + text = "⚠️ Multiple faces (${result.faceCount})", color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall ) @@ -64,11 +80,17 @@ fun ScanResultsScreen( } } - Button(onClick = onFinish, modifier = Modifier.fillMaxWidth()) { + Button( + onClick = onFinish, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(8.dp) + ) { Text("Done") } } - else -> {} + // Add fallback for other states (Idle/RequiresCrop) + // so the compiler doesn't complain about non-exhaustive 'when' + else -> { } } } } \ No newline at end of file diff --git a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainViewModel.kt b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainViewModel.kt index bb68472..572712e 100644 --- a/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainViewModel.kt +++ b/app/src/main/java/com/placeholder/sherpai2/ui/trainingprep/TrainViewModel.kt @@ -1,6 +1,7 @@ package com.placeholder.sherpai2.ui.trainingprep import android.content.Context +import android.graphics.Rect import android.net.Uri import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -12,8 +13,6 @@ import com.placeholder.sherpai2.domain.repository.TaggingRepository 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.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,9 +24,11 @@ import kotlinx.coroutines.tasks.await import kotlinx.coroutines.withContext import javax.inject.Inject +// 1. DEFINE THESE AT TOP LEVEL (Outside the class) so the UI can see them sealed class ScanningState { object Idle : ScanningState() data class Processing(val current: Int, val total: Int) : ScanningState() + data class RequiresCrop(val uri: Uri, val faceBoxes: List, val remainingUris: List) : ScanningState() data class Success(val results: List) : ScanningState() } @@ -48,60 +49,72 @@ class TrainViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val semaphore = Semaphore(2) + private val finalResults = mutableListOf() fun scanAndTagFaces(uris: List) = viewModelScope.launch { - val total = uris.size - _uiState.value = ScanningState.Processing(0, total) - - val detector = FaceDetection.getClient(faceOptions()) + // Goal: Deduplicate by SHA256 before starting val allImages = imageRepository.getAllImages().first() - val uriToIdMap = allImages.associate { it.image.imageUri to it.image.imageId } + val uriToShaMap = allImages.associate { it.image.imageUri to it.image.sha256 } - var completedCount = 0 - - val scanResults = withContext(Dispatchers.Default) { - uris.map { uri -> - async { - semaphore.withPermit { - val faceCount = detectFaceCount(detector, uri) - - // Tagging logic - if (faceCount > 0) { - uriToIdMap[uri.toString()]?.let { id -> - taggingRepository.addTagToImage(id, "face", "ML_KIT", 1.0f) - if (faceCount > 1) { - taggingRepository.addTagToImage(id, "multiple_faces", "ML_KIT", 1.0f) - } - } - } - - completedCount++ - _uiState.value = ScanningState.Processing(completedCount, total) - - ScanResult(uri, faceCount) - } - } - }.awaitAll() + val uniqueUris = uris.distinctBy { uri -> + uriToShaMap[uri.toString()] ?: uri.toString() } - detector.close() - _uiState.value = ScanningState.Success(scanResults) + processNext(uniqueUris) } - private suspend fun detectFaceCount( - detector: com.google.mlkit.vision.face.FaceDetector, - uri: Uri - ): Int = withContext(Dispatchers.IO) { - return@withContext try { - val image = InputImage.fromFilePath(context, uri) - val faces = detector.process(image).await() - faces.size // Returns actual count - } catch (e: Exception) { - 0 + private suspend fun processNext(remaining: List) { + if (remaining.isEmpty()) { + _uiState.value = ScanningState.Success(finalResults.toList()) + return } + + val currentUri = remaining.first() + val nextList = remaining.drop(1) + + _uiState.value = ScanningState.Processing(finalResults.size + 1, finalResults.size + remaining.size) + + val detector = FaceDetection.getClient(faceOptions()) + try { + val image = InputImage.fromFilePath(context, currentUri) + val faces = detector.process(image).await() + + if (faces.size > 1) { + // FORCE USER TO CROP: Transition to RequiresCrop state + _uiState.value = ScanningState.RequiresCrop( + uri = currentUri, + faceBoxes = faces.map { it.boundingBox }, + remainingUris = nextList + ) + } else { + val faceCount = faces.size + if (faceCount > 0) tagImage(currentUri) + + finalResults.add(ScanResult(currentUri, faceCount)) + processNext(nextList) + } + } catch (e: Exception) { + processNext(nextList) + } finally { + detector.close() + } + } + + private suspend fun tagImage(uri: Uri) { + val allImages = imageRepository.getAllImages().first() + val imageId = allImages.find { it.image.imageUri == uri.toString() }?.image?.imageId + if (imageId != null) { + taggingRepository.addTagToImage(imageId, "face", "ML_KIT", 1.0f) + } + } + + fun onFaceSelected(uri: Uri, box: Rect, remaining: List) = viewModelScope.launch { + tagImage(uri) + finalResults.add(ScanResult(uri, 1)) + processNext(remaining) } private fun faceOptions() = FaceDetectorOptions.Builder() - .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_FAST) + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE) .build() } \ No newline at end of file