Improved Training Screen and underlying

Added diagnostic view model with flag for picture detection but broke fucking everything meassing with tagDAO. au demain
This commit is contained in:
genki
2026-01-08 00:02:27 -05:00
parent 6ce115baa9
commit 51fdfbf3d6
15 changed files with 1411 additions and 380 deletions

View File

@@ -21,7 +21,6 @@ import com.placeholder.sherpai2.data.local.entity.*
TagEntity::class,
EventEntity::class,
ImageTagEntity::class,
ImagePersonEntity::class,
ImageEventEntity::class,
// ===== NEW ENTITIES =====
@@ -29,7 +28,7 @@ import com.placeholder.sherpai2.data.local.entity.*
FaceModelEntity::class, // NEW: Face embeddings
PhotoFaceTagEntity::class // NEW: Face tags
],
version = 3,
version = 4,
exportSchema = false
)
// No TypeConverters needed - embeddings stored as strings
@@ -40,7 +39,6 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun tagDao(): TagDao
abstract fun eventDao(): EventDao
abstract fun imageTagDao(): ImageTagDao
abstract fun imagePersonDao(): ImagePersonDao
abstract fun imageEventDao(): ImageEventDao
abstract fun imageAggregateDao(): ImageAggregateDao

View File

@@ -1,25 +0,0 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImagePersonEntity
@Dao
interface ImagePersonDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun upsert(entity: ImagePersonEntity)
/**
* All images containing a specific person.
*/
@Query("""
SELECT imageId FROM image_persons
WHERE personId = :personId
AND visibility = 'PUBLIC'
AND confirmed = 1
""")
suspend fun findImagesForPerson(personId: String): List<String>
}

View File

@@ -50,4 +50,6 @@ interface ImageTagDao {
""")
fun getTagsForImage(imageId: String): Flow<List<TagEntity>>
}

View File

@@ -4,21 +4,206 @@ import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.data.local.entity.TagEntity
import com.placeholder.sherpai2.data.local.entity.TagWithUsage
import kotlinx.coroutines.flow.Flow
/**
* TagDao - Tag management with face recognition integration
*/
@Dao
interface TagDao {
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(tag: TagEntity)
// ======================
// BASIC OPERATIONS
// ======================
@Insert(onConflict = OnConflictStrategy.IGNORE)
suspend fun insert(tag: TagEntity): Long
/**
* Resolve a tag by value.
* Example: "park"
*/
@Query("SELECT * FROM tags WHERE value = :value LIMIT 1")
suspend fun getByValue(value: String): TagEntity?
@Query("SELECT * FROM tags")
@Query("SELECT * FROM tags WHERE tagId = :tagId")
suspend fun getById(tagId: String): TagEntity?
@Query("SELECT * FROM tags ORDER BY value ASC")
suspend fun getAll(): List<TagEntity>
}
@Query("SELECT * FROM tags ORDER BY value ASC")
fun getAllFlow(): Flow<List<TagEntity>>
@Query("SELECT * FROM tags WHERE type = :type ORDER BY value ASC")
suspend fun getByType(type: String): List<TagEntity>
@Query("DELETE FROM tags WHERE tagId = :tagId")
suspend fun delete(tagId: String)
// ======================
// STATISTICS (returns TagWithUsage)
// ======================
/**
* Get most used tags WITH usage counts
*/
@Query("""
SELECT t.tagId, t.type, t.value, t.createdAt,
COUNT(it.imageId) as usage_count
FROM tags t
LEFT JOIN image_tags it ON t.tagId = it.tagId
GROUP BY t.tagId
ORDER BY usage_count DESC
LIMIT :limit
""")
suspend fun getMostUsedTags(limit: Int = 10): List<TagWithUsage>
/**
* Get tag usage count
*/
@Query("""
SELECT COUNT(DISTINCT it.imageId)
FROM image_tags it
WHERE it.tagId = :tagId
""")
suspend fun getTagUsageCount(tagId: String): Int
// ======================
// PERSON INTEGRATION
// ======================
/**
* Get all tags used for images containing a specific person
*/
@Query("""
SELECT DISTINCT t.* FROM tags t
INNER JOIN image_tags it ON t.tagId = it.tagId
INNER JOIN photo_face_tags pft ON it.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE fm.personId = :personId
ORDER BY t.value ASC
""")
suspend fun getTagsForPerson(personId: String): List<TagEntity>
/**
* Get images that have both a specific tag AND contain a specific person
*/
@Query("""
SELECT DISTINCT i.* FROM images i
INNER JOIN image_tags it ON i.imageId = it.imageId
INNER JOIN photo_face_tags pft ON i.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE it.tagId = :tagId AND fm.personId = :personId
ORDER BY i.capturedAt DESC
""")
suspend fun getImagesWithTagAndPerson(
tagId: String,
personId: String
): List<ImageEntity>
/**
* Get images with tag and person as Flow
*/
@Query("""
SELECT DISTINCT i.* FROM images i
INNER JOIN image_tags it ON i.imageId = it.imageId
INNER JOIN photo_face_tags pft ON i.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE it.tagId = :tagId AND fm.personId = :personId
ORDER BY i.capturedAt DESC
""")
fun getImagesWithTagAndPersonFlow(
tagId: String,
personId: String
): Flow<List<ImageEntity>>
/**
* Count images with tag and person
*/
@Query("""
SELECT COUNT(DISTINCT i.imageId) FROM images i
INNER JOIN image_tags it ON i.imageId = it.imageId
INNER JOIN photo_face_tags pft ON i.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
WHERE it.tagId = :tagId AND fm.personId = :personId
""")
suspend fun countImagesWithTagAndPerson(
tagId: String,
personId: String
): Int
// ======================
// AUTO-SUGGESTIONS
// ======================
/**
* Suggest tags based on person's relationship
*/
@Query("""
SELECT DISTINCT t.* FROM tags t
INNER JOIN image_tags it ON t.tagId = it.tagId
INNER JOIN photo_face_tags pft ON it.imageId = pft.imageId
INNER JOIN face_models fm ON pft.faceModelId = fm.id
INNER JOIN persons p ON fm.personId = p.id
WHERE p.relationship = :relationship
AND p.id != :excludePersonId
GROUP BY t.tagId
ORDER BY COUNT(it.imageId) DESC
LIMIT :limit
""")
suspend fun suggestTagsBasedOnRelationship(
relationship: String,
excludePersonId: String,
limit: Int = 5
): List<TagEntity>
/**
* Get tags commonly used with this tag
*/
@Query("""
SELECT DISTINCT t2.* FROM tags t2
INNER JOIN image_tags it2 ON t2.tagId = it2.tagId
WHERE it2.imageId IN (
SELECT it1.imageId FROM image_tags it1
WHERE it1.tagId = :tagId
)
AND t2.tagId != :tagId
GROUP BY t2.tagId
ORDER BY COUNT(it2.imageId) DESC
LIMIT :limit
""")
suspend fun getRelatedTags(
tagId: String,
limit: Int = 5
): List<TagEntity>
// ======================
// SEARCH
// ======================
/**
* Search tags by value (partial match)
*/
@Query("""
SELECT * FROM tags
WHERE value LIKE '%' || :query || '%'
ORDER BY value ASC
LIMIT :limit
""")
suspend fun searchTags(query: String, limit: Int = 20): List<TagEntity>
/**
* Search tags with usage count
*/
@Query("""
SELECT t.tagId, t.type, t.value, t.createdAt,
COUNT(it.imageId) as usage_count
FROM tags t
LEFT JOIN image_tags it ON t.tagId = it.tagId
WHERE t.value LIKE '%' || :query || '%'
GROUP BY t.tagId
ORDER BY usage_count DESC, t.value ASC
LIMIT :limit
""")
suspend fun searchTagsWithUsage(query: String, limit: Int = 20): List<TagWithUsage>
}

View File

@@ -18,15 +18,92 @@ import java.util.UUID
Index(value = ["name"])
]
)
/**
* PersonEntity - Represents a person in your app
*
* CLEAN DESIGN:
* - Uses String UUID for id (matches your ImageEntity.imageId pattern)
* - Face embeddings stored separately in FaceModelEntity
* - Simple, extensible schema
* - Now includes DOB and relationship for better organization
*/
data class PersonEntity(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
/**
* Person's name (required)
*/
val name: String,
val createdAt: Long = System.currentTimeMillis(),
val updatedAt: Long = System.currentTimeMillis()
)
/**
* Date of birth (optional)
* Stored as Unix timestamp (milliseconds)
*/
val dateOfBirth: Long? = null,
/**
* Relationship to user (optional)
* Examples: "Family", "Friend", "Partner", "Child", "Parent", "Sibling", "Colleague", "Other"
*/
val relationship: String? = null,
/**
* When this person was added
*/
val createdAt: Long = System.currentTimeMillis(),
/**
* Last time this person's data was updated
*/
val updatedAt: Long = System.currentTimeMillis()
) {
companion object {
/**
* Create PersonEntity with optional fields
*/
fun create(
name: String,
dateOfBirth: Long? = null,
relationship: String? = null
): PersonEntity {
return PersonEntity(
name = name,
dateOfBirth = dateOfBirth,
relationship = relationship
)
}
}
/**
* Calculate age if date of birth is available
*/
fun getAge(): Int? {
if (dateOfBirth == null) return null
val now = System.currentTimeMillis()
val ageInMillis = now - dateOfBirth
val ageInYears = ageInMillis / (1000L * 60 * 60 * 24 * 365)
return ageInYears.toInt()
}
/**
* Get relationship emoji
*/
fun getRelationshipEmoji(): String {
return when (relationship) {
"Family" -> "👨‍👩‍👧‍👦"
"Friend" -> "🤝"
"Partner" -> "❤️"
"Child" -> "👶"
"Parent" -> "👪"
"Sibling" -> "👫"
"Colleague" -> "💼"
else -> "👤"
}
}
}
/**
* FaceModelEntity - Stores face recognition model (embedding) for a person
*

View File

@@ -1,40 +0,0 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
@Entity(
tableName = "image_persons",
primaryKeys = ["imageId", "personId"],
foreignKeys = [
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["personId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index("personId")
]
)
data class ImagePersonEntity(
val imageId: String,
val personId: String,
val confidence: Float,
val confirmed: Boolean,
/**
* PUBLIC | PRIVATE
*/
val visibility: String
)

View File

@@ -1,30 +1,119 @@
package com.placeholder.sherpai2.data.local.entity
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.util.UUID
/**
* Represents a conceptual tag.
* TagEntity - Normalized tag storage
*
* Tags are normalized so that:
* - "park" exists once
* - many images can reference it
* DESIGN:
* - Tags exist once (e.g., "vacation")
* - Multiple images reference via ImageTagEntity junction table
* - Type system: GENERIC | SYSTEM | HIDDEN
*/
@Entity(tableName = "tags")
data class TagEntity(
@PrimaryKey
val tagId: String,
val tagId: String = UUID.randomUUID().toString(),
/**
* GENERIC | SYSTEM | HIDDEN
* Tag type: GENERIC | SYSTEM | HIDDEN
*/
val type: String,
val type: String = TagType.GENERIC,
/**
* Human-readable value, e.g. "park", "sunset"
* Human-readable value, e.g. "vacation", "beach"
*/
val value: String,
val createdAt: Long
)
/**
* When tag was created
*/
val createdAt: Long = System.currentTimeMillis()
) {
companion object {
fun createUserTag(value: String): TagEntity {
return TagEntity(
type = TagType.GENERIC,
value = value.trim().lowercase()
)
}
fun createSystemTag(value: String): TagEntity {
return TagEntity(
type = TagType.SYSTEM,
value = value.trim().lowercase()
)
}
fun createHiddenTag(value: String): TagEntity {
return TagEntity(
type = TagType.HIDDEN,
value = value.trim().lowercase()
)
}
}
fun isUserTag(): Boolean = type == TagType.GENERIC
fun isSystemTag(): Boolean = type == TagType.SYSTEM
fun isHiddenTag(): Boolean = type == TagType.HIDDEN
fun getDisplayValue(): String = value.replaceFirstChar { it.uppercase() }
}
/**
* TagWithUsage - For queries that include usage count
*
* Use this for statistics queries
*/
data class TagWithUsage(
@ColumnInfo(name = "tagId")
val tagId: String,
@ColumnInfo(name = "type")
val type: String,
@ColumnInfo(name = "value")
val value: String,
@ColumnInfo(name = "createdAt")
val createdAt: Long,
@ColumnInfo(name = "usage_count")
val usageCount: Int
) {
/**
* Convert to TagEntity (without usage count)
*/
fun toTagEntity(): TagEntity {
return TagEntity(
tagId = tagId,
type = type,
value = value,
createdAt = createdAt
)
}
}
/**
* Tag type constants
*/
object TagType {
const val GENERIC = "GENERIC"
const val SYSTEM = "SYSTEM"
const val HIDDEN = "HIDDEN"
}
/**
* Common system tag values
*/
object SystemTags {
const val HAS_FACES = "has_faces"
const val MULTIPLE_PEOPLE = "multiple_people"
const val LANDSCAPE = "landscape"
const val PORTRAIT = "portrait"
const val LOW_QUALITY = "low_quality"
const val BLURRY = "blurry"
}

View File

@@ -15,12 +15,6 @@ data class ImageWithEverything(
)
val tags: List<ImageTagEntity>,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"
)
val persons: List<ImagePersonEntity>,
@Relation(
parentColumn = "imageId",
entityColumn = "imageId"

View File

@@ -332,6 +332,42 @@ class FaceRecognitionRepository @Inject constructor(
faceModelDao.deleteFaceModelById(faceModelId)
}
// Add this method to FaceRecognitionRepository_StringIds.kt
// Replace the existing createPersonWithFaceModel method with this version:
/**
* Create a new person with face model in one operation.
* Now supports full PersonEntity with optional fields.
*
* @param person PersonEntity with name, DOB, relationship, etc.
* @return PersonId (String UUID)
*/
suspend fun createPersonWithFaceModel(
person: PersonEntity,
validImages: List<TrainingSanityChecker.ValidTrainingImage>,
onProgress: (Int, Int) -> Unit = { _, _ -> }
): String = withContext(Dispatchers.IO) {
// Insert person with all fields
personDao.insert(person)
// Train face model
trainPerson(
personId = person.id,
validImages = validImages,
onProgress = onProgress
)
person.id
}
/**
* Get face model by ID
*/
suspend fun getFaceModelById(faceModelId: String): FaceModelEntity? = withContext(Dispatchers.IO) {
faceModelDao.getFaceModelById(faceModelId)
}
suspend fun deleteTagsForImage(imageId: String) {
photoFaceTagDao.deleteTagsForImage(imageId)
}
@@ -339,6 +375,8 @@ class FaceRecognitionRepository @Inject constructor(
fun cleanup() {
faceNetModel.close()
}
}
data class DetectedFace(
@@ -354,4 +392,5 @@ data class PersonFaceStats(
val taggedPhotoCount: Int,
val averageConfidence: Float,
val lastDetectedAt: Long?
)
)

View File

@@ -14,6 +14,8 @@ import com.placeholder.sherpai2.data.repository.DetectedFace
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.data.repository.PersonFaceStats
import com.placeholder.sherpai2.domain.repository.ImageRepository
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.delay
@@ -27,13 +29,11 @@ import kotlinx.coroutines.tasks.await
import javax.inject.Inject
/**
* PersonInventoryViewModel - Manage trained face models
* PersonInventoryViewModel - Single version with feature flags
*
* Features:
* - List all trained persons with stats
* - Delete models
* - SCAN LIBRARY to find person in all photos
* - View sample photos
* Toggle diagnostics in DebugFlags.kt:
* - ENABLE_FACE_RECOGNITION_LOGGING = true/false
* - USE_LIBERAL_THRESHOLDS = true/false
*/
@HiltViewModel
class PersonInventoryViewModel @Inject constructor(
@@ -48,13 +48,12 @@ class PersonInventoryViewModel @Inject constructor(
private val _scanningState = MutableStateFlow<ScanningState>(ScanningState.Idle)
val scanningState: StateFlow<ScanningState> = _scanningState.asStateFlow()
// ML Kit face detector
private val faceDetector by lazy {
val options = FaceDetectorOptions.Builder()
.setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_NONE)
.setClassificationMode(FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.15f)
.setMinFaceSize(0.10f) // Lower for better detection
.build()
FaceDetection.getClient(options)
}
@@ -77,12 +76,14 @@ class PersonInventoryViewModel @Inject constructor(
val personName: String,
val progress: Int,
val total: Int,
val facesFound: Int
val facesFound: Int,
val facesDetected: Int = 0
) : ScanningState()
data class Complete(
val personName: String,
val facesFound: Int,
val imagesScanned: Int
val imagesScanned: Int,
val totalFacesDetected: Int = 0
) : ScanningState()
}
@@ -90,9 +91,6 @@ class PersonInventoryViewModel @Inject constructor(
loadPersons()
}
/**
* Load all trained persons with their stats
*/
fun loadPersons() {
viewModelScope.launch {
try {
@@ -119,14 +117,11 @@ class PersonInventoryViewModel @Inject constructor(
}
}
/**
* Delete a face model
*/
fun deletePerson(personId: String, faceModelId: String) {
viewModelScope.launch {
try {
faceRecognitionRepository.deleteFaceModel(faceModelId)
loadPersons() // Refresh list
loadPersons()
} catch (e: Exception) {
_uiState.value = InventoryUiState.Error(
"Failed to delete: ${e.message}"
@@ -136,21 +131,17 @@ class PersonInventoryViewModel @Inject constructor(
}
/**
* Scan entire photo library for a specific person
*
* Process:
* 1. Get all images from library
* 2. For each image:
* - Detect faces using ML Kit
* - Generate embeddings for detected faces
* - Compare to person's face model
* - Create PhotoFaceTagEntity if match found
* 3. Update progress throughout
* Scan library with optional diagnostic logging
*/
fun scanLibraryForPerson(personId: String, faceModelId: String) {
viewModelScope.launch {
try {
// Get person name for UI
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
DiagnosticLogger.i("=== STARTING LIBRARY SCAN ===")
DiagnosticLogger.i("PersonId: $personId")
DiagnosticLogger.i("FaceModelId: $faceModelId")
}
val currentState = _uiState.value
val person = if (currentState is InventoryUiState.Success) {
currentState.persons.find { it.person.id == personId }?.person
@@ -158,69 +149,104 @@ class PersonInventoryViewModel @Inject constructor(
val personName = person?.name ?: "Unknown"
// Get all images from library
// Get face model to determine training count
val faceModel = faceRecognitionRepository.getFaceModelById(faceModelId)
val trainingCount = faceModel?.trainingImageCount ?: 15
// Dynamic threshold based on training data and debug flag
val scanThreshold = if (DebugFlags.USE_LIBERAL_THRESHOLDS) {
when {
trainingCount < 20 -> 0.48f // Very liberal
trainingCount < 30 -> 0.52f // Liberal
else -> 0.58f // Moderate
}
} else {
when {
trainingCount < 20 -> 0.55f // Moderate
trainingCount < 30 -> 0.60f // Conservative
else -> 0.65f // Strict
}
}
DiagnosticLogger.i("Training count: $trainingCount")
DiagnosticLogger.i("Using threshold: $scanThreshold")
val allImages = imageRepository.getAllImages().first()
val totalImages = allImages.size
DiagnosticLogger.i("Total images in library: $totalImages")
_scanningState.value = ScanningState.Scanning(
personId = personId,
personName = personName,
progress = 0,
total = totalImages,
facesFound = 0
facesFound = 0,
facesDetected = 0
)
var facesFound = 0
var totalFacesDetected = 0
// Scan each image
allImages.forEachIndexed { index, imageWithEverything ->
val image = imageWithEverything.image
// Detect faces in this image
DiagnosticLogger.d("--- Image ${index + 1}/$totalImages ---")
DiagnosticLogger.d("ImageId: ${image.imageId}")
val detectedFaces = detectFacesInImage(image.imageUri)
totalFacesDetected += detectedFaces.size
DiagnosticLogger.d("Faces detected: ${detectedFaces.size}")
if (detectedFaces.isNotEmpty()) {
// Scan this image for the person
val tags = faceRecognitionRepository.scanImage(
imageId = image.imageId,
detectedFaces = detectedFaces,
threshold = 0.6f // Slightly lower threshold for library scanning
threshold = scanThreshold
)
// Count how many faces matched this person
val matchingTags = tags.filter { tag ->
// Check if this tag belongs to our target person's face model
tag.faceModelId == faceModelId
DiagnosticLogger.d("Tags created: ${tags.size}")
tags.forEach { tag ->
DiagnosticLogger.d(" Tag: model=${tag.faceModelId.take(8)}, conf=${String.format("%.3f", tag.confidence)}")
}
val matchingTags = tags.filter { it.faceModelId == faceModelId }
DiagnosticLogger.d("Matching tags for target: ${matchingTags.size}")
facesFound += matchingTags.size
}
// Update progress
_scanningState.value = ScanningState.Scanning(
personId = personId,
personName = personName,
progress = index + 1,
total = totalImages,
facesFound = facesFound
facesFound = facesFound,
facesDetected = totalFacesDetected
)
}
// Scan complete
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}%")
_scanningState.value = ScanningState.Complete(
personName = personName,
facesFound = facesFound,
imagesScanned = totalImages
imagesScanned = totalImages,
totalFacesDetected = totalFacesDetected
)
// Refresh the list to show updated counts
loadPersons()
// Reset scanning state after 3 seconds
delay(3000)
_scanningState.value = ScanningState.Idle
} catch (e: Exception) {
DiagnosticLogger.e("Scan failed", e)
_scanningState.value = ScanningState.Idle
_uiState.value = InventoryUiState.Error(
"Scan failed: ${e.message}"
@@ -229,33 +255,28 @@ class PersonInventoryViewModel @Inject constructor(
}
}
/**
* Detect faces in an image using ML Kit
*
* @param imageUri URI of the image to scan
* @return List of detected faces with cropped bitmaps
*/
private suspend fun detectFacesInImage(imageUri: String): List<DetectedFace> = withContext(Dispatchers.Default) {
try {
// Load bitmap from URI
val uri = Uri.parse(imageUri)
val inputStream = getApplication<Application>().contentResolver.openInputStream(uri)
val bitmap = BitmapFactory.decodeStream(inputStream)
inputStream?.close()
if (bitmap == null) return@withContext emptyList()
if (bitmap == null) {
DiagnosticLogger.w("Failed to load bitmap from: $imageUri")
return@withContext emptyList()
}
DiagnosticLogger.d("Bitmap: ${bitmap.width}x${bitmap.height}")
// Create ML Kit input image
val image = InputImage.fromBitmap(bitmap, 0)
// Detect faces (await the Task)
val faces = faceDetector.process(image).await()
// Convert to DetectedFace objects
DiagnosticLogger.d("ML Kit found ${faces.size} faces")
faces.mapNotNull { face ->
val boundingBox = face.boundingBox
// Crop face from bitmap with bounds checking
val croppedFace = try {
val left = boundingBox.left.coerceAtLeast(0)
val top = boundingBox.top.coerceAtLeast(0)
@@ -268,6 +289,7 @@ class PersonInventoryViewModel @Inject constructor(
null
}
} catch (e: Exception) {
DiagnosticLogger.e("Face crop failed", e)
null
}
@@ -282,13 +304,11 @@ class PersonInventoryViewModel @Inject constructor(
}
} catch (e: Exception) {
DiagnosticLogger.e("Face detection failed: $imageUri", e)
emptyList()
}
}
/**
* Get sample images for a person
*/
suspend fun getPersonImages(personId: String) =
faceRecognitionRepository.getImagesForPerson(personId)

View File

@@ -3,128 +3,338 @@ package com.placeholder.sherpai2.ui.trainingprep
import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.AddPhotoAlternate
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.material3.Text
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalContext
import coil.compose.AsyncImage
import androidx.compose.foundation.lazy.grid.items
/**
* Enhanced ImageSelectorScreen
*
* Changes:
* - NO LIMIT on photo count (was 10)
* - Recommends 20-30 photos
* - Real-time progress feedback
* - Quality indicators
* - Training tips
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ImageSelectorScreen(
onImagesSelected: (List<Uri>) -> Unit
) {
//1. Persist state across configuration changes
var selectedUris by rememberSaveable { mutableStateOf<List<Uri>>(emptyList()) }
val context = LocalContext.current
var selectedImages by remember { mutableStateOf<List<Uri>>(emptyList()) }
val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.OpenMultipleDocuments()
val photoPicker = rememberLauncherForActivityResult(
contract = ActivityResultContracts.GetMultipleContents()
) { uris ->
// 2. Take first 10 and try to persist permissions
val limitedUris = uris.take(10)
selectedUris = limitedUris
if (uris.isNotEmpty()) {
selectedImages = uris
}
}
Scaffold(
topBar = { TopAppBar(title = { Text("Select Training Photos") }) }
) { padding ->
topBar = {
TopAppBar(
title = { Text("Select Training Photos") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { paddingValues ->
Column(
modifier = Modifier
.padding(padding)
.padding(16.dp)
.fillMaxSize(),
.fillMaxSize()
.padding(paddingValues)
.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
OutlinedCard(
onClick = { launcher.launch(arrayOf("image/*")) },
modifier = Modifier.fillMaxWidth()
// Gradient header with tips
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(Icons.Default.AddPhotoAlternate, contentDescription = null)
Spacer(Modifier.height(8.dp))
Text("Select up to 10 images of the person")
Row(
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.PhotoCamera,
contentDescription = null,
tint = MaterialTheme.colorScheme.onPrimary,
modifier = Modifier.size(28.dp)
)
}
}
Column {
Text(
"Training Tips",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
Text(
"More photos = better recognition",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
}
Spacer(Modifier.height(4.dp))
TipItem("✓ Select 20-30 photos for best results", true)
TipItem("✓ Include different angles and lighting", true)
TipItem("✓ Mix expressions (smile, neutral, laugh)", true)
TipItem("✓ With/without glasses if applicable", true)
TipItem("✗ Avoid blurry or very dark photos", false)
}
}
// Progress indicator
AnimatedVisibility(selectedImages.isNotEmpty()) {
ProgressCard(selectedImages.size)
}
Spacer(Modifier.weight(1f))
// Select photos button
Button(
onClick = { photoPicker.launch("image/*") },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
contentPadding = PaddingValues(vertical = 16.dp)
) {
Icon(Icons.Default.PhotoLibrary, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(
if (selectedImages.isEmpty()) {
"Select Training Photos"
} else {
"Selected: ${selectedImages.size} photos - Tap to change"
},
style = MaterialTheme.typography.titleMedium
)
}
// Continue button
AnimatedVisibility(selectedImages.size >= 15) {
Button(
onClick = { onImagesSelected(selectedImages) },
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.secondary
),
contentPadding = PaddingValues(vertical = 16.dp)
) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(
text = "${selectedUris.size} / 10 selected",
style = MaterialTheme.typography.labelLarge,
color = if (selectedUris.size == 10) MaterialTheme.colorScheme.error
else if (selectedUris.isNotEmpty()) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.outline
"Continue with ${selectedImages.size} photos",
style = MaterialTheme.typography.titleMedium
)
}
}
// 3. Conditional rendering for empty state
if (selectedUris.isEmpty()) {
Box(Modifier
.weight(1f)
.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text("No images selected", style = MaterialTheme.typography.bodyMedium)
}
} else {
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.weight(1f),
contentPadding = PaddingValues(4.dp)
// Minimum warning
if (selectedImages.isNotEmpty() && selectedImages.size < 15) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
)
) {
items(selectedUris, key = { it.toString() }) { uri ->
Box(modifier = Modifier.padding(4.dp)) {
AsyncImage(
model = uri,
contentDescription = null,
modifier = Modifier
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.error
)
Column {
Text(
"Need at least 15 photos",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer
)
Text(
"You have ${selectedImages.size}. Select ${15 - selectedImages.size} more.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.8f)
)
// 4. Ability to remove specific images
Surface(
onClick = { selectedUris = selectedUris - uri },
modifier = Modifier
.align(Alignment.TopEnd)
.padding(4.dp),
shape = CircleShape,
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f)
) {
Icon(
Icons.Default.Close,
contentDescription = "Remove",
modifier = Modifier.size(16.dp)
)
}
}
}
}
}
Button(
modifier = Modifier.fillMaxWidth(),
enabled = selectedUris.isNotEmpty(),
onClick = { onImagesSelected(selectedUris) }
) {
Text("Start Face Detection")
}
}
}
}
@Composable
private fun TipItem(text: String, isGood: Boolean) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
if (isGood) Icons.Default.CheckCircle else Icons.Default.Cancel,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (isGood) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
@Composable
private fun ProgressCard(photoCount: Int) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primaryContainer
photoCount >= 20 -> MaterialTheme.colorScheme.tertiaryContainer
else -> MaterialTheme.colorScheme.surfaceVariant
}
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column {
Text(
text = "$photoCount photos selected",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = when {
photoCount >= 30 -> "Excellent! Maximum diversity"
photoCount >= 25 -> "Great! Very good coverage"
photoCount >= 20 -> "Good! Should work well"
photoCount >= 15 -> "Acceptable - more is better"
else -> "Need ${15 - photoCount} more"
},
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Surface(
shape = RoundedCornerShape(12.dp),
color = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primary
photoCount >= 20 -> MaterialTheme.colorScheme.tertiary
photoCount >= 15 -> MaterialTheme.colorScheme.secondary
else -> MaterialTheme.colorScheme.outline
},
modifier = Modifier.size(56.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = when {
photoCount >= 25 -> ""
photoCount >= 20 -> ""
photoCount >= 15 -> ""
else -> "..."
},
style = MaterialTheme.typography.headlineMedium,
color = Color.White
)
}
}
}
// Progress bar
LinearProgressIndicator(
progress = { (photoCount / 30f).coerceAtMost(1f) },
modifier = Modifier
.fillMaxWidth()
.height(8.dp),
color = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primary
photoCount >= 20 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.secondary
},
trackColor = MaterialTheme.colorScheme.surfaceVariant,
)
// Expected accuracy
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween
) {
Text(
"Expected accuracy:",
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
when {
photoCount >= 30 -> "90-95%"
photoCount >= 25 -> "85-90%"
photoCount >= 20 -> "80-85%"
photoCount >= 15 -> "75-80%"
else -> "< 75%"
},
style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold,
color = when {
photoCount >= 25 -> MaterialTheme.colorScheme.primary
photoCount >= 20 -> MaterialTheme.colorScheme.tertiary
else -> MaterialTheme.colorScheme.secondary
}
)
}
}
}
}

View File

@@ -5,6 +5,7 @@ import android.graphics.Bitmap
import android.net.Uri
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.repository.FaceRecognitionRepository
import com.placeholder.sherpai2.ml.FaceNetModel
import dagger.hilt.android.lifecycle.HiltViewModel
@@ -14,9 +15,6 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* State for image scanning and validation
*/
sealed class ScanningState {
object Idle : ScanningState()
data class Processing(val progress: Int, val total: Int) : ScanningState()
@@ -26,26 +24,26 @@ sealed class ScanningState {
data class Error(val message: String) : ScanningState()
}
/**
* State for face model training/creation
*/
sealed class TrainingState {
object Idle : TrainingState()
data class Processing(val stage: String, val progress: Int, val total: Int) : TrainingState()
data class Success(val personName: String, val personId: String) : TrainingState()
data class Success(
val personName: String,
val personId: String,
val relationship: String?
) : TrainingState()
data class Error(val message: String) : TrainingState()
}
/**
* ViewModel for training face recognition models
*
* WORKFLOW:
* 1. User selects 10+ images → scanAndTagFaces()
* 2. Images validated → Success state with validImagesWithFaces
* 3. User can replace images or pick faces from group photos
* 4. When ready → createFaceModel(personName)
* 5. Creates PersonEntity + FaceModelEntity in database
* Person info captured before photo selection
*/
data class PersonInfo(
val name: String,
val dateOfBirth: Long?,
val relationship: String
)
@HiltViewModel
class TrainViewModel @Inject constructor(
application: Application,
@@ -56,18 +54,16 @@ class TrainViewModel @Inject constructor(
private val sanityChecker = TrainingSanityChecker(application)
private val faceDetectionHelper = FaceDetectionHelper(application)
// Scanning/validation state
private val _uiState = MutableStateFlow<ScanningState>(ScanningState.Idle)
val uiState: StateFlow<ScanningState> = _uiState.asStateFlow()
// Training/model creation state
private val _trainingState = MutableStateFlow<TrainingState>(TrainingState.Idle)
val trainingState: StateFlow<TrainingState> = _trainingState.asStateFlow()
// Keep track of current images for replacements
private var currentImageUris: List<Uri> = emptyList()
// Store person info for later use during training
private var personInfo: PersonInfo? = null
// Keep track of manual face selections (imageUri -> selectedFaceIndex)
private var currentImageUris: List<Uri> = emptyList()
private val manualFaceSelections = mutableMapOf<Uri, ManualFaceSelection>()
data class ManualFaceSelection(
@@ -75,28 +71,15 @@ class TrainViewModel @Inject constructor(
val croppedFaceBitmap: Bitmap
)
// ======================
// FACE MODEL CREATION
// ======================
/**
* Store person info before photo selection
*/
fun setPersonInfo(name: String, dateOfBirth: Long?, relationship: String) {
personInfo = PersonInfo(name, dateOfBirth, relationship)
}
/**
* Create face model from validated training images.
*
* COMPLETE PROCESS:
* 1. Verify we have 10+ validated images
* 2. Call repository to create PersonEntity + FaceModelEntity
* 3. Repository handles: embedding generation, averaging, database save
*
* Call this when user clicks "Continue to Training" after validation passes.
*
* @param personName Name for the new person
*
* EXAMPLE USAGE IN UI:
* if (result.isValid) {
* showNameDialog { name ->
* trainViewModel.createFaceModel(name)
* }
* }
* Create face model with captured person info
*/
fun createFaceModel(personName: String) {
val currentState = _uiState.value
@@ -106,8 +89,10 @@ class TrainViewModel @Inject constructor(
}
val validImages = currentState.sanityCheckResult.validImagesWithFaces
if (validImages.size < 10) {
_trainingState.value = TrainingState.Error("Need at least 10 valid images, have ${validImages.size}")
if (validImages.size < 15) { // Updated minimum
_trainingState.value = TrainingState.Error(
"Need at least 15 valid images, have ${validImages.size}"
)
return
}
@@ -119,13 +104,16 @@ class TrainViewModel @Inject constructor(
total = validImages.size
)
// Repository handles everything:
// - Creates PersonEntity in 'persons' table
// - Generates embeddings from face bitmaps
// - Averages embeddings
// - Creates FaceModelEntity linked to PersonEntity
// Create person with captured info
val person = PersonEntity.create(
name = personName,
dateOfBirth = personInfo?.dateOfBirth,
relationship = personInfo?.relationship
)
// Create person with face model
val personId = faceRecognitionRepository.createPersonWithFaceModel(
personName = personName,
person = person, // Pass full PersonEntity now
validImages = validImages,
onProgress = { current, total ->
_trainingState.value = TrainingState.Processing(
@@ -138,7 +126,8 @@ class TrainViewModel @Inject constructor(
_trainingState.value = TrainingState.Success(
personName = personName,
personId = personId
personId = personId,
relationship = person.relationship
)
} catch (e: Exception) {
@@ -149,40 +138,16 @@ class TrainViewModel @Inject constructor(
}
}
/**
* Reset training state back to idle.
* Call this after handling success/error.
*/
fun resetTrainingState() {
_trainingState.value = TrainingState.Idle
}
// ======================
// IMAGE VALIDATION
// ======================
/**
* Scan and validate images for training.
*
* PROCESS:
* 1. Face detection on all images
* 2. Duplicate checking
* 3. Validation against requirements (10+ images, one face per image)
*
* @param imageUris List of image URIs selected by user
*/
fun scanAndTagFaces(imageUris: List<Uri>) {
currentImageUris = imageUris
manualFaceSelections.clear()
performScan(imageUris)
}
/**
* Replace a single image and re-scan all images.
*
* @param oldUri Image to replace
* @param newUri New image
*/
fun replaceImage(oldUri: Uri, newUri: Uri) {
viewModelScope.launch {
val updatedUris = currentImageUris.toMutableList()
@@ -191,27 +156,15 @@ class TrainViewModel @Inject constructor(
if (index != -1) {
updatedUris[index] = newUri
currentImageUris = updatedUris
// Remove manual selection for old URI if any
manualFaceSelections.remove(oldUri)
// Re-scan all images
performScan(currentImageUris)
}
}
}
/**
* User manually selected a face from a multi-face image.
*
* @param imageUri Image with multiple faces
* @param faceIndex Which face the user selected (0-based)
* @param croppedFaceBitmap Cropped face bitmap
*/
fun selectFaceFromImage(imageUri: Uri, faceIndex: Int, croppedFaceBitmap: Bitmap) {
manualFaceSelections[imageUri] = ManualFaceSelection(faceIndex, croppedFaceBitmap)
// Re-process the results with the manual selection
val currentState = _uiState.value
if (currentState is ScanningState.Success) {
val updatedResult = applyManualSelections(currentState.sanityCheckResult)
@@ -219,25 +172,19 @@ class TrainViewModel @Inject constructor(
}
}
/**
* Perform the actual scanning.
*/
private fun performScan(imageUris: List<Uri>) {
viewModelScope.launch {
try {
_uiState.value = ScanningState.Processing(0, imageUris.size)
// Perform sanity checks
val result = sanityChecker.performSanityChecks(
imageUris = imageUris,
minImagesRequired = 10,
allowMultipleFaces = true, // Allow multiple faces - user can pick
minImagesRequired = 15, // Updated minimum
allowMultipleFaces = true,
duplicateSimilarityThreshold = 0.95
)
// Apply any manual face selections
val finalResult = applyManualSelections(result)
_uiState.value = ScanningState.Success(finalResult)
} catch (e: Exception) {
@@ -248,26 +195,19 @@ class TrainViewModel @Inject constructor(
}
}
/**
* Apply manual face selections to the results.
*/
private fun applyManualSelections(
result: TrainingSanityChecker.SanityCheckResult
): TrainingSanityChecker.SanityCheckResult {
// If no manual selections, return original
if (manualFaceSelections.isEmpty()) {
return result
}
// Update face detection results with manual selections
val updatedFaceResults = result.faceDetectionResults.map { faceResult ->
val manualSelection = manualFaceSelections[faceResult.uri]
if (manualSelection != null) {
// Replace the cropped face with the manually selected one
faceResult.copy(
croppedFaceBitmap = manualSelection.croppedFaceBitmap,
// Treat as single face since user selected one
faceCount = 1
)
} else {
@@ -275,12 +215,11 @@ class TrainViewModel @Inject constructor(
}
}
// Update valid images list
val updatedValidImages = updatedFaceResults
.filter { it.hasFace }
.filter { it.croppedFaceBitmap != null }
.filter { it.errorMessage == null }
.filter { it.faceCount >= 1 } // Now accept if user picked a face
.filter { it.faceCount >= 1 }
.map { result ->
TrainingSanityChecker.ValidTrainingImage(
uri = result.uri,
@@ -289,31 +228,27 @@ class TrainViewModel @Inject constructor(
)
}
// Recalculate validation errors
val updatedErrors = result.validationErrors.toMutableList()
// Remove multiple face errors for images with manual selections
updatedErrors.removeAll { error ->
error is TrainingSanityChecker.ValidationError.MultipleFacesDetected &&
manualFaceSelections.containsKey(error.uri)
}
// Check if we have enough valid images now
if (updatedValidImages.size < 10) {
if (updatedValidImages.size < 15) { // Updated minimum
if (updatedErrors.none { it is TrainingSanityChecker.ValidationError.InsufficientImages }) {
updatedErrors.add(
TrainingSanityChecker.ValidationError.InsufficientImages(
required = 10,
required = 15,
available = updatedValidImages.size
)
)
}
} else {
// Remove insufficient images error if we now have enough
updatedErrors.removeAll { it is TrainingSanityChecker.ValidationError.InsufficientImages }
}
val isValid = updatedErrors.isEmpty() && updatedValidImages.size >= 10
val isValid = updatedErrors.isEmpty() && updatedValidImages.size >= 15
return result.copy(
isValid = isValid,
@@ -323,21 +258,16 @@ class TrainViewModel @Inject constructor(
)
}
/**
* Get formatted error messages.
*/
fun getFormattedErrors(result: TrainingSanityChecker.SanityCheckResult): List<String> {
return sanityChecker.formatValidationErrors(result.validationErrors)
}
/**
* Reset to idle state.
*/
fun reset() {
_uiState.value = ScanningState.Idle
_trainingState.value = TrainingState.Idle
currentImageUris = emptyList()
manualFaceSelections.clear()
personInfo = null
}
override fun onCleared() {
@@ -348,13 +278,7 @@ class TrainViewModel @Inject constructor(
}
}
// ======================
// EXTENSION FUNCTIONS
// ======================
/**
* Extension to copy FaceDetectionResult with modifications.
*/
// Extension functions for copying results
private fun FaceDetectionHelper.FaceDetectionResult.copy(
uri: Uri = this.uri,
hasFace: Boolean = this.hasFace,
@@ -373,9 +297,6 @@ private fun FaceDetectionHelper.FaceDetectionResult.copy(
)
}
/**
* Extension to copy SanityCheckResult with modifications.
*/
private fun TrainingSanityChecker.SanityCheckResult.copy(
isValid: Boolean = this.isValid,
faceDetectionResults: List<FaceDetectionHelper.FaceDetectionResult> = this.faceDetectionResults,

View File

@@ -1,31 +1,516 @@
package com.placeholder.sherpai2.ui.trainingprep
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.animation.AnimatedVisibility
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.material.icons.Icons
import androidx.compose.material.icons.filled.*
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import java.text.SimpleDateFormat
import java.util.*
/**
* Beautiful TrainingScreen with person info capture
*
* Features:
* - Name input
* - Date of birth picker
* - Relationship selector
* - Onboarding cards
* - Beautiful gradient design
* - Clear call to action
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TrainingScreen(
onSelectImages: () -> Unit
onSelectImages: () -> Unit,
modifier: Modifier = Modifier
) {
var showInfoDialog by remember { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
title = { Text("Training") }
title = { Text("Train New Person") },
colors = TopAppBarDefaults.topAppBarColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
)
)
}
) { padding ->
Button(
modifier = Modifier.padding(padding),
onClick = onSelectImages
) { paddingValues ->
Column(
modifier = modifier
.fillMaxSize()
.padding(paddingValues)
.verticalScroll(rememberScrollState())
.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
Text("Select Images")
// Hero section with gradient
HeroCard()
// How it works section
HowItWorksSection()
// Requirements section
RequirementsCard()
Spacer(Modifier.weight(1f))
// Main CTA button
Button(
onClick = { showInfoDialog = true },
modifier = Modifier
.fillMaxWidth()
.height(60.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
),
shape = RoundedCornerShape(16.dp)
) {
Icon(
Icons.Default.PersonAdd,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.width(12.dp))
Text(
"Start Training",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(8.dp))
}
}
// Person info dialog
if (showInfoDialog) {
PersonInfoDialog(
onDismiss = { showInfoDialog = false },
onConfirm = { name, dob, relationship ->
showInfoDialog = false
// TODO: Store this info before photo selection
// For now, just proceed to photo selection
onSelectImages()
}
)
}
}
@Composable
private fun HeroCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.primaryContainer
),
shape = RoundedCornerShape(20.dp)
) {
Box(
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
MaterialTheme.colorScheme.primaryContainer,
MaterialTheme.colorScheme.primaryContainer.copy(alpha = 0.7f)
)
)
)
) {
Column(
modifier = Modifier.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
Surface(
shape = RoundedCornerShape(20.dp),
color = MaterialTheme.colorScheme.primary,
shadowElevation = 8.dp,
modifier = Modifier.size(80.dp)
) {
Box(contentAlignment = Alignment.Center) {
Icon(
Icons.Default.Face,
contentDescription = null,
modifier = Modifier.size(48.dp),
tint = MaterialTheme.colorScheme.onPrimary
)
}
}
Text(
"Face Recognition Training",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Text(
"Train the AI to recognize someone in your photos",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.8f)
)
}
}
}
}
@Composable
private fun HowItWorksSection() {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
Text(
"How It Works",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold
)
StepCard(
number = 1,
icon = Icons.Default.Info,
title = "Enter Person Details",
description = "Name, birthday, and relationship"
)
StepCard(
number = 2,
icon = Icons.Default.PhotoLibrary,
title = "Select Training Photos",
description = "Choose 20-30 photos of the person"
)
StepCard(
number = 3,
icon = Icons.Default.ModelTraining,
title = "AI Learns Their Face",
description = "Takes ~30 seconds to train"
)
StepCard(
number = 4,
icon = Icons.Default.Search,
title = "Auto-Tag Your Library",
description = "Find them in all your photos"
)
}
}
@Composable
private fun StepCard(
number: Int,
icon: androidx.compose.ui.graphics.vector.ImageVector,
title: String,
description: String
) {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
),
shape = RoundedCornerShape(12.dp)
) {
Row(
modifier = Modifier.padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.CenterVertically
) {
// Number badge
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(48.dp)
) {
Box(contentAlignment = Alignment.Center) {
Text(
text = number.toString(),
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimary
)
}
}
Column(modifier = Modifier.weight(1f)) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
icon,
contentDescription = null,
modifier = Modifier.size(20.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
title,
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
}
Spacer(Modifier.height(4.dp))
Text(
description,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
@Composable
private fun RequirementsCard() {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer
),
shape = RoundedCornerShape(16.dp)
) {
Column(
modifier = Modifier.padding(20.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.CheckCircle,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Text(
"What You'll Need",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
RequirementItem("20-30 photos of the person", true)
RequirementItem("Different angles and lighting", true)
RequirementItem("Clear face visibility", true)
RequirementItem("Mix of expressions", true)
RequirementItem("2-3 minutes of your time", true)
}
}
}
@Composable
private fun RequirementItem(text: String, isMet: Boolean) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
if (isMet) Icons.Default.Check else Icons.Default.Close,
contentDescription = null,
modifier = Modifier.size(18.dp),
tint = if (isMet) {
MaterialTheme.colorScheme.primary
} else {
MaterialTheme.colorScheme.error
}
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun PersonInfoDialog(
onDismiss: () -> Unit,
onConfirm: (name: String, dateOfBirth: Long?, relationship: String) -> Unit
) {
var name by remember { mutableStateOf("") }
var dateOfBirth by remember { mutableStateOf<Long?>(null) }
var selectedRelationship by remember { mutableStateOf("Other") }
var showDatePicker by remember { mutableStateOf(false) }
val relationships = listOf(
"Family" to "👨‍👩‍👧‍👦",
"Friend" to "🤝",
"Partner" to "❤️",
"Child" to "👶",
"Parent" to "👪",
"Sibling" to "👫",
"Colleague" to "💼",
"Other" to "👤"
)
AlertDialog(
onDismissRequest = onDismiss,
title = {
Column {
Text("Person Details")
Text(
"Help us organize your photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
},
text = {
Column(
modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// Name field
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name *") },
placeholder = { Text("e.g., John Doe") },
leadingIcon = {
Icon(Icons.Default.Person, contentDescription = null)
},
modifier = Modifier.fillMaxWidth(),
singleLine = true
)
// Date of birth
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.Cake, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text(
if (dateOfBirth != null) {
"Birthday: ${formatDate(dateOfBirth!!)}"
} else {
"Add Birthday (Optional)"
}
)
}
// Relationship selector
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
Text(
"Relationship",
style = MaterialTheme.typography.labelMedium
)
// Relationship chips
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
relationships.take(4).forEach { (rel, emoji) ->
FilterChip(
selected = selectedRelationship == rel,
onClick = { selectedRelationship = rel },
label = { Text("$emoji $rel") }
)
}
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
relationships.drop(4).forEach { (rel, emoji) ->
FilterChip(
selected = selectedRelationship == rel,
onClick = { selectedRelationship = rel },
label = { Text("$emoji $rel") }
)
}
}
}
// Privacy note
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Row(
modifier = Modifier.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
Icon(
Icons.Default.Lock,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.primary
)
Text(
"All data stays on your device",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
},
confirmButton = {
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(name, dateOfBirth, selectedRelationship)
}
},
enabled = name.isNotBlank()
) {
Text("Continue")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
// Date picker dialog
if (showDatePicker) {
DatePickerDialog(
onDismissRequest = { showDatePicker = false },
confirmButton = {
TextButton(
onClick = {
// Get selected date from date picker
// For now, set to current date as placeholder
dateOfBirth = System.currentTimeMillis()
showDatePicker = false
}
) {
Text("OK")
}
},
dismissButton = {
TextButton(onClick = { showDatePicker = false }) {
Text("Cancel")
}
}
) {
// Material3 DatePicker
DatePicker(
state = rememberDatePickerState(),
modifier = Modifier.padding(16.dp)
)
}
}
}
private fun formatDate(timestamp: Long): String {
val formatter = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
return formatter.format(Date(timestamp))
}

View File

@@ -0,0 +1,68 @@
package com.placeholder.sherpai2.util
/**
* Debug feature flags
*
* Toggle these to enable/disable diagnostic features
* Set to false before release builds!
*/
object DebugFlags {
/**
* Enable verbose face recognition logging
*
* When true:
* - Logs every face detection
* - Logs similarity scores
* - Logs matching decisions
* - Shows why images are skipped
*
* Filter Logcat by: "FaceRecognition"
*/
const val ENABLE_FACE_RECOGNITION_LOGGING = true // ← Toggle here
/**
* Show confidence scores in UI
*/
const val SHOW_CONFIDENCE_IN_UI = true // ← Toggle here
/**
* Lower thresholds for better recall (more matches, some false positives)
*/
const val USE_LIBERAL_THRESHOLDS = true // ← Toggle here
}
/**
* Diagnostic logger - only logs when flag is enabled
*/
object DiagnosticLogger {
private const val TAG = "FaceRecognition"
fun d(message: String) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
android.util.Log.d(TAG, message)
}
}
fun i(message: String) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
android.util.Log.i(TAG, message)
}
}
fun w(message: String) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
android.util.Log.w(TAG, message)
}
}
fun e(message: String, throwable: Throwable? = null) {
if (DebugFlags.ENABLE_FACE_RECOGNITION_LOGGING) {
if (throwable != null) {
android.util.Log.e(TAG, message, throwable)
} else {
android.util.Log.e(TAG, message)
}
}
}
}