dbscan clustering by person_year - working but needs ScanAndAdd TBD

This commit is contained in:
genki
2026-01-23 20:50:05 -05:00
parent 6e4eaebe01
commit 03e15a74b8
8 changed files with 1184 additions and 569 deletions

View File

@@ -4,7 +4,7 @@
<selectionStates>
<SelectionState runConfigName="app">
<option name="selectionMode" value="DROPDOWN" />
<DropdownSelection timestamp="2026-01-22T02:19:39.398929470Z">
<DropdownSelection timestamp="2026-01-23T12:16:19.603445647Z">
<Target type="DEFAULT_BOOT">
<handle>
<DeviceId pluginId="LocalEmulator" identifier="path=/home/genki/.android/avd/Medium_Phone.avd" />

View File

@@ -1,83 +1,75 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.Dao
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.Query
import androidx.room.Update
import androidx.room.*
import com.placeholder.sherpai2.data.local.entity.FaceCacheEntity
/**
* FaceCacheDao - ENHANCED with cache-aware queries
* FaceCacheDao - NO SOLO-PHOTO FILTER
*
* PHASE 1 ENHANCEMENTS:
* CRITICAL CHANGE:
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* ✅ Query quality faces WITHOUT requiring embeddings
* ✅ Count faces without embeddings for diagnostics
* ✅ Support 3-path clustering strategy:
* Path 1: Cached embeddings (instant)
* Path 2: Quality metadata → generate embeddings (fast)
* Path 3: Full scan (slow, fallback only)
* Removed all faceCount filters from queries
*
* WHY:
* - Group photos contain high-quality faces (especially for children)
* - IoU matching ensures we extract the CORRECT face from group photos
* - Rejecting group photos was eliminating 60-70% of quality faces!
*
* RESULT:
* - 2-3x more faces for clustering
* - Quality remains high (still filter by size + score)
* - Better clusters, especially for children
*/
@Dao
interface FaceCacheDao {
// ═══════════════════════════════════════
// INSERT / UPDATE
// ═══════════════════════════════════════
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(faceCacheEntity: FaceCacheEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(faceCache: FaceCacheEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertAll(faceCaches: List<FaceCacheEntity>)
suspend fun insertAll(faceCacheEntities: List<FaceCacheEntity>)
@Update
suspend fun update(faceCache: FaceCacheEntity)
suspend fun update(faceCacheEntity: FaceCacheEntity)
/**
* Batch update embeddings for existing cache entries
* Used when generating embeddings on-demand
*/
@Update
suspend fun updateAll(faceCaches: List<FaceCacheEntity>)
// ═══════════════════════════════════════
// PHASE 1: CACHE-AWARE CLUSTERING QUERIES
// ═══════════════════════════════════════
/**
* Path 1: Get faces WITH embeddings (instant clustering)
* Get ALL quality faces - INCLUDES GROUP PHOTOS!
*
* This is the fastest path - embeddings already cached
* Quality filters (still strict):
* - faceAreaRatio >= minRatio (default 3% of image)
* - qualityScore >= minQuality (default 0.6 = 60%)
* - Has embedding
*
* NO faceCount filter!
*/
@Query("""
SELECT * FROM face_cache
WHERE faceAreaRatio >= :minRatio
AND qualityScore >= :minQuality
AND embedding IS NOT NULL
ORDER BY qualityScore DESC, faceAreaRatio DESC
SELECT fc.*
FROM face_cache fc
WHERE fc.faceAreaRatio >= :minRatio
AND fc.qualityScore >= :minQuality
AND fc.embedding IS NOT NULL
ORDER BY fc.qualityScore DESC, fc.faceAreaRatio DESC
LIMIT :limit
""")
suspend fun getAllQualityFaces(
minRatio: Float = 0.05f,
minQuality: Float = 0.7f,
minRatio: Float = 0.03f,
minQuality: Float = 0.6f,
limit: Int = Int.MAX_VALUE
): List<FaceCacheEntity>
/**
* Path 2: Get quality faces WITHOUT requiring embeddings
* Get quality faces WITHOUT embeddings - FOR PATH 2
*
* PURPOSE: Pre-filter to quality faces, then generate embeddings on-demand
* BENEFIT: Process ~1,200 faces instead of 10,824 photos
*
* USE CASE: First-time Discovery when cache has metadata but no embeddings
* These have good metadata but need embeddings generated.
* INCLUDES GROUP PHOTOS - IoU matching will handle extraction!
*/
@Query("""
SELECT * FROM face_cache
WHERE faceAreaRatio >= :minRatio
AND qualityScore >= :minQuality
ORDER BY qualityScore DESC, faceAreaRatio DESC
SELECT fc.*
FROM face_cache fc
WHERE fc.faceAreaRatio >= :minRatio
AND fc.qualityScore >= :minQuality
AND fc.embedding IS NULL
ORDER BY fc.qualityScore DESC, fc.faceAreaRatio DESC
LIMIT :limit
""")
suspend fun getQualityFacesWithoutEmbeddings(
@@ -87,222 +79,56 @@ interface FaceCacheDao {
): List<FaceCacheEntity>
/**
* Count faces without embeddings (for diagnostics)
*
* Shows how many faces need embedding generation
*/
@Query("""
SELECT COUNT(*) FROM face_cache
WHERE embedding IS NULL
AND qualityScore >= :minQuality
""")
suspend fun countFacesWithoutEmbeddings(
minQuality: Float = 0.5f
): Int
/**
* Count faces WITH embeddings (for progress tracking)
*/
@Query("""
SELECT COUNT(*) FROM face_cache
WHERE embedding IS NOT NULL
AND qualityScore >= :minQuality
""")
suspend fun countFacesWithEmbeddings(
minQuality: Float = 0.5f
): Int
// ═══════════════════════════════════════
// EXISTING QUERIES (PRESERVED)
// ═══════════════════════════════════════
/**
* Get premium solo faces (STILL WORKS if you have solo photos cached)
*/
@Query("""
SELECT * FROM face_cache
WHERE faceAreaRatio >= :minRatio
AND qualityScore >= :minQuality
AND embedding IS NOT NULL
ORDER BY qualityScore DESC, faceAreaRatio DESC
LIMIT :limit
""")
suspend fun getPremiumSoloFaces(
minRatio: Float = 0.05f,
minQuality: Float = 0.8f,
limit: Int = 1000
): List<FaceCacheEntity>
/**
* Get standard quality faces (WORKS with any cached faces)
*/
@Query("""
SELECT * FROM face_cache
WHERE faceAreaRatio >= :minRatio
AND qualityScore >= :minQuality
AND embedding IS NOT NULL
ORDER BY qualityScore DESC, faceAreaRatio DESC
LIMIT :limit
""")
suspend fun getStandardSoloFaces(
minRatio: Float = 0.03f,
minQuality: Float = 0.6f,
limit: Int = 2000
): List<FaceCacheEntity>
/**
* Get any faces with embeddings (minimum requirements)
*/
@Query("""
SELECT * FROM face_cache
WHERE embedding IS NOT NULL
AND faceAreaRatio >= :minFaceRatio
ORDER BY qualityScore DESC, faceAreaRatio DESC
LIMIT :limit
""")
suspend fun getHighQualitySoloFaces(
minFaceRatio: Float = 0.015f,
limit: Int = 2000
): List<FaceCacheEntity>
/**
* Get faces with embeddings (no filters)
*/
@Query("""
SELECT * FROM face_cache
WHERE embedding IS NOT NULL
ORDER BY qualityScore DESC
LIMIT :limit
""")
suspend fun getSoloFacesWithEmbeddings(
limit: Int = 2000
): List<FaceCacheEntity>
// ═══════════════════════════════════════
// YEAR-BASED QUERIES (FOR FUTURE USE)
// ═══════════════════════════════════════
/**
* Get faces from specific year
* Note: Joins images table to get capturedAt
*/
@Query("""
SELECT fc.* FROM face_cache fc
INNER JOIN images i ON fc.imageId = i.imageId
WHERE fc.faceAreaRatio >= :minRatio
AND fc.qualityScore >= :minQuality
AND fc.embedding IS NOT NULL
AND strftime('%Y', i.capturedAt/1000, 'unixepoch') = :year
ORDER BY fc.qualityScore DESC, fc.faceAreaRatio DESC
LIMIT :limit
""")
suspend fun getFacesByYear(
year: String,
minRatio: Float = 0.05f,
minQuality: Float = 0.7f,
limit: Int = 1000
): List<FaceCacheEntity>
/**
* Get years with sufficient photos
*/
@Query("""
SELECT
strftime('%Y', i.capturedAt/1000, 'unixepoch') as year,
COUNT(*) as photoCount
FROM face_cache fc
INNER JOIN images i ON fc.imageId = i.imageId
WHERE fc.faceAreaRatio >= :minRatio
AND fc.embedding IS NOT NULL
GROUP BY year
HAVING photoCount >= :minPhotos
ORDER BY year ASC
""")
suspend fun getYearsWithSufficientPhotos(
minPhotos: Int = 20,
minRatio: Float = 0.03f
): List<YearPhotoCount>
// ═══════════════════════════════════════
// UTILITY QUERIES
// ═══════════════════════════════════════
/**
* Get faces excluding specific images
*/
@Query("""
SELECT * FROM face_cache
WHERE faceAreaRatio >= :minRatio
AND embedding IS NOT NULL
AND imageId NOT IN (:excludedImageIds)
ORDER BY qualityScore DESC
LIMIT :limit
""")
suspend fun getSoloFacesExcluding(
excludedImageIds: List<String>,
minRatio: Float = 0.03f,
limit: Int = 2000
): List<FaceCacheEntity>
/**
* Count quality faces
* Count faces WITH embeddings (Path 1 check)
*/
@Query("""
SELECT COUNT(*)
FROM face_cache
WHERE faceAreaRatio >= :minRatio
FROM face_cache
WHERE embedding IS NOT NULL
AND qualityScore >= :minQuality
AND embedding IS NOT NULL
""")
suspend fun countPremiumSoloFaces(
minRatio: Float = 0.05f,
minQuality: Float = 0.8f
): Int
suspend fun countFacesWithEmbeddings(minQuality: Float = 0.6f): Int
/**
* Get stats on cached faces
* Count faces WITHOUT embeddings (Path 2 check)
*/
@Query("""
SELECT COUNT(*)
FROM face_cache
WHERE embedding IS NULL
AND qualityScore >= :minQuality
""")
suspend fun countFacesWithoutEmbeddings(minQuality: Float = 0.6f): Int
/**
* Get faces for specific image (for IoU matching)
*/
@Query("SELECT * FROM face_cache WHERE imageId = :imageId")
suspend fun getFaceCacheForImage(imageId: String): List<FaceCacheEntity>
/**
* Cache statistics
*/
@Query("""
SELECT
COUNT(*) as totalFaces,
COUNT(CASE WHEN embedding IS NOT NULL THEN 1 END) as withEmbeddings,
AVG(faceAreaRatio) as avgSize,
AVG(qualityScore) as avgQuality,
MIN(qualityScore) as minQuality,
MAX(qualityScore) as maxQuality
AVG(faceAreaRatio) as avgSize
FROM face_cache
""")
suspend fun getCacheStats(): CacheStats
@Query("SELECT * FROM face_cache WHERE imageId = :imageId AND faceIndex = :faceIndex")
suspend fun getFaceCacheByKey(imageId: String, faceIndex: Int): FaceCacheEntity?
@Query("SELECT * FROM face_cache WHERE imageId = :imageId ORDER BY faceIndex")
suspend fun getFaceCacheForImage(imageId: String): List<FaceCacheEntity>
@Query("DELETE FROM face_cache WHERE imageId = :imageId")
suspend fun deleteFaceCacheForImage(imageId: String)
suspend fun deleteCacheForImage(imageId: String)
@Query("DELETE FROM face_cache")
suspend fun deleteAll()
@Query("DELETE FROM face_cache WHERE cacheVersion < :version")
suspend fun deleteOldVersions(version: Int)
}
/**
* Result classes
*/
data class YearPhotoCount(
val year: String,
val photoCount: Int
)
data class CacheStats(
val totalFaces: Int,
val withEmbeddings: Int,
val avgSize: Float,
val avgQuality: Float,
val minQuality: Float,
val maxQuality: Float
val avgSize: Float
)

View File

@@ -15,6 +15,7 @@ import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.entity.FaceCacheEntity
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.ml.FaceNetModel
import com.placeholder.sherpai2.ui.discover.DiscoverySettings
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -675,6 +676,73 @@ class FaceClusteringService @Inject constructor(
}
}
// REPLACE the discoverPeopleWithSettings method (lines 679-716) with this:
suspend fun discoverPeopleWithSettings(
settings: DiscoverySettings,
onProgress: (Int, Int, String) -> Unit = { _, _, _ -> }
): ClusteringResult = withContext(Dispatchers.Default) {
Log.d(TAG, "════════════════════════════════════════")
Log.d(TAG, "🎛️ DISCOVERY WITH CUSTOM SETTINGS")
Log.d(TAG, "════════════════════════════════════════")
Log.d(TAG, "Settings received:")
Log.d(TAG, " • minFaceSize: ${settings.minFaceSize} (${(settings.minFaceSize * 100).toInt()}%)")
Log.d(TAG, " • minQuality: ${settings.minQuality} (${(settings.minQuality * 100).toInt()}%)")
Log.d(TAG, " • epsilon: ${settings.epsilon}")
Log.d(TAG, "════════════════════════════════════════")
// Get quality faces using settings
val qualityMetadata = withContext(Dispatchers.IO) {
faceCacheDao.getQualityFacesWithoutEmbeddings(
minRatio = settings.minFaceSize,
minQuality = settings.minQuality,
limit = 5000
)
}
Log.d(TAG, "Found ${qualityMetadata.size} faces matching quality settings")
Log.d(TAG, " • Query used: minRatio=${settings.minFaceSize}, minQuality=${settings.minQuality}")
// Adjust threshold based on library size
val minRequired = if (qualityMetadata.size < 50) 30 else 50
Log.d(TAG, "Path selection:")
Log.d(TAG, " • Faces available: ${qualityMetadata.size}")
Log.d(TAG, " • Minimum required: $minRequired")
if (qualityMetadata.size >= minRequired) {
Log.d(TAG, "✅ Using Path 2 (quality pre-filtering)")
Log.d(TAG, "════════════════════════════════════════")
// Use Path 2 (quality pre-filtering)
return@withContext clusterWithQualityPrefiltering(
qualityFacesMetadata = qualityMetadata,
maxFaces = MAX_FACES_TO_CLUSTER,
onProgress = onProgress
)
} else {
Log.d(TAG, "⚠️ Using fallback path (standard discovery)")
Log.d(TAG, " • Reason: ${qualityMetadata.size} < $minRequired")
Log.d(TAG, "════════════════════════════════════════")
// Fallback to regular discovery (no Path 3, use existing methods)
Log.w(TAG, "Insufficient metadata (${qualityMetadata.size} < $minRequired), using standard discovery")
// Use existing discoverPeople with appropriate strategy
val strategy = if (settings.minQuality >= 0.7f) {
ClusteringStrategy.PREMIUM_SOLO_ONLY
} else {
ClusteringStrategy.STANDARD_SOLO_ONLY
}
return@withContext discoverPeople(
strategy = strategy,
maxFacesToCluster = MAX_FACES_TO_CLUSTER,
onProgress = onProgress
)
}
}
// Clustering algorithms (unchanged)
private fun performDBSCAN(faces: List<DetectedFaceWithEmbedding>, epsilon: Float, minPoints: Int): List<RawCluster> {
val visited = mutableSetOf<Int>()

View File

@@ -1,7 +1,13 @@
package com.placeholder.sherpai2.domain.usecase
import android.content.Context
import android.graphics.Bitmap
import android.util.Log
import com.google.mlkit.vision.face.Face
import com.placeholder.sherpai2.data.local.dao.FaceCacheDao
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.entity.FaceCacheEntity
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
@@ -15,41 +21,56 @@ import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* PopulateFaceDetectionCache - HYPER-PARALLEL face scanning
* PopulateFaceDetectionCache - ENHANCED VERSION
*
* STRATEGY: Use ACCURATE mode BUT with MASSIVE parallelization
* - 50 concurrent detections (not 10!)
* - Semaphore limits to prevent OOM
* - Atomic counters for thread-safe progress
* - Smaller images (768px) for speed without quality loss
* NOW POPULATES TWO CACHES:
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* 1. ImageEntity cache (hasFaces, faceCount) - for quick filters
* 2. FaceCacheEntity table - for Discovery pre-filtering
*
* RESULT: ~2000-3000 images/minute on modern phones
* SAME ML KIT SCAN - Just saves more data!
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* Previously: One scan → saves 2 fields (hasFaces, faceCount)
* Now: One scan → saves 2 fields + full face metadata!
*
* RESULT: Discovery can skip Path 3 (8 min) and use Path 2 (3 min)
*/
@Singleton
class PopulateFaceDetectionCacheUseCase @Inject constructor(
@ApplicationContext private val context: Context,
private val imageDao: ImageDao
private val imageDao: ImageDao,
private val faceCacheDao: FaceCacheDao
) {
// Limit concurrent operations to prevent OOM
private val semaphore = Semaphore(50) // 50 concurrent detections!
companion object {
private const val TAG = "FaceCachePopulation"
private const val SEMAPHORE_PERMITS = 50
private const val BATCH_SIZE = 100
}
private val semaphore = Semaphore(SEMAPHORE_PERMITS)
/**
* HYPER-PARALLEL face detection with ACCURATE mode
* ENHANCED: Populates BOTH image cache AND face metadata cache
*/
suspend fun execute(
onProgress: (Int, Int, String?) -> Unit = { _, _, _ -> }
): Int = withContext(Dispatchers.IO) {
// Create detector with ACCURATE mode but optimized settings
Log.d(TAG, "════════════════════════════════════════")
Log.d(TAG, "Enhanced Face Cache Population Started")
Log.d(TAG, "Populating: ImageEntity + FaceCacheEntity")
Log.d(TAG, "════════════════════════════════════════")
val detector = com.google.mlkit.vision.face.FaceDetection.getClient(
com.google.mlkit.vision.face.FaceDetectorOptions.Builder()
.setPerformanceMode(com.google.mlkit.vision.face.FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
.setLandmarkMode(com.google.mlkit.vision.face.FaceDetectorOptions.LANDMARK_MODE_NONE) // Don't need landmarks for cache
.setClassificationMode(com.google.mlkit.vision.face.FaceDetectorOptions.CLASSIFICATION_MODE_NONE) // Don't need classification
.setMinFaceSize(0.1f) // Detect smaller faces
.setLandmarkMode(com.google.mlkit.vision.face.FaceDetectorOptions.LANDMARK_MODE_ALL)
.setClassificationMode(com.google.mlkit.vision.face.FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
.setMinFaceSize(0.1f)
.build()
)
@@ -57,44 +78,34 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
val imagesToScan = imageDao.getImagesNeedingFaceDetection()
if (imagesToScan.isEmpty()) {
Log.d(TAG, "No images need scanning")
return@withContext 0
}
Log.d(TAG, "Scanning ${imagesToScan.size} images")
val total = imagesToScan.size
val scanned = AtomicInteger(0)
val pendingUpdates = mutableListOf<CacheUpdate>()
val updatesMutex = kotlinx.coroutines.sync.Mutex()
val pendingImageUpdates = mutableListOf<ImageCacheUpdate>()
val pendingFaceCacheUpdates = mutableListOf<FaceCacheEntity>()
val updatesMutex = Mutex()
// Process ALL images in parallel with semaphore control
// Process all images in parallel
coroutineScope {
val jobs = imagesToScan.map { image ->
async(Dispatchers.Default) {
semaphore.acquire()
try {
// Load bitmap with medium downsampling (768px = good balance)
val bitmap = loadBitmapOptimized(android.net.Uri.parse(image.imageUri))
if (bitmap == null) {
return@async CacheUpdate(image.imageId, false, 0, image.imageUri)
}
// Detect faces
val inputImage = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0)
val faces = detector.process(inputImage).await()
bitmap.recycle()
CacheUpdate(
imageId = image.imageId,
hasFaces = faces.isNotEmpty(),
faceCount = faces.size,
imageUri = image.imageUri
)
processImage(image, detector)
} catch (e: Exception) {
CacheUpdate(image.imageId, false, 0, image.imageUri)
Log.w(TAG, "Error processing ${image.imageId}: ${e.message}")
ScanResult(
ImageCacheUpdate(image.imageId, false, 0, image.imageUri),
emptyList()
)
} finally {
semaphore.release()
// Update progress
val current = scanned.incrementAndGet()
if (current % 50 == 0 || current == total) {
onProgress(current, total, image.imageUri)
@@ -103,27 +114,42 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
}
}
// Wait for all to complete and collect results
jobs.awaitAll().forEach { update ->
// Collect results
jobs.awaitAll().forEach { result ->
updatesMutex.withLock {
pendingUpdates.add(update)
pendingImageUpdates.add(result.imageCacheUpdate)
pendingFaceCacheUpdates.addAll(result.faceCacheEntries)
// Batch write to DB every 100 updates
if (pendingUpdates.size >= 100) {
flushUpdates(pendingUpdates.toList())
pendingUpdates.clear()
// Batch write to DB
if (pendingImageUpdates.size >= BATCH_SIZE) {
flushUpdates(
pendingImageUpdates.toList(),
pendingFaceCacheUpdates.toList()
)
pendingImageUpdates.clear()
pendingFaceCacheUpdates.clear()
}
}
}
// Flush remaining
updatesMutex.withLock {
if (pendingUpdates.isNotEmpty()) {
flushUpdates(pendingUpdates)
if (pendingImageUpdates.isNotEmpty()) {
flushUpdates(pendingImageUpdates, pendingFaceCacheUpdates)
}
}
}
val totalFacesCached = withContext(Dispatchers.IO) {
faceCacheDao.getCacheStats().totalFaces
}
Log.d(TAG, "════════════════════════════════════════")
Log.d(TAG, "Cache Population Complete!")
Log.d(TAG, "Images scanned: ${scanned.get()}")
Log.d(TAG, "Faces cached: $totalFacesCached")
Log.d(TAG, "════════════════════════════════════════")
scanned.get()
} finally {
detector.close()
@@ -131,11 +157,94 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
}
/**
* Optimized bitmap loading with configurable max dimension
* Process a single image - detect faces and create cache entries
*/
private fun loadBitmapOptimized(uri: android.net.Uri, maxDim: Int = 768): android.graphics.Bitmap? {
private suspend fun processImage(
image: ImageEntity,
detector: com.google.mlkit.vision.face.FaceDetector
): ScanResult {
val bitmap = loadBitmapOptimized(android.net.Uri.parse(image.imageUri))
?: return ScanResult(
ImageCacheUpdate(image.imageId, false, 0, image.imageUri),
emptyList()
)
try {
val inputImage = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0)
val faces = detector.process(inputImage).await()
val imageWidth = bitmap.width
val imageHeight = bitmap.height
// Create ImageEntity cache update
val imageCacheUpdate = ImageCacheUpdate(
imageId = image.imageId,
hasFaces = faces.isNotEmpty(),
faceCount = faces.size,
imageUri = image.imageUri
)
// Create FaceCacheEntity entries for each face
val faceCacheEntries = faces.mapIndexed { index, face ->
createFaceCacheEntry(
imageId = image.imageId,
faceIndex = index,
face = face,
imageWidth = imageWidth,
imageHeight = imageHeight
)
}
return ScanResult(imageCacheUpdate, faceCacheEntries)
} finally {
bitmap.recycle()
}
}
/**
* Create FaceCacheEntity from ML Kit Face
*
* Uses FaceCacheEntity.create() which calculates quality metrics automatically
*/
private fun createFaceCacheEntry(
imageId: String,
faceIndex: Int,
face: Face,
imageWidth: Int,
imageHeight: Int
): FaceCacheEntity {
// Determine if frontal based on head rotation
val isFrontal = isFrontalFace(face)
return FaceCacheEntity.create(
imageId = imageId,
faceIndex = faceIndex,
boundingBox = face.boundingBox,
imageWidth = imageWidth,
imageHeight = imageHeight,
confidence = 0.9f, // High confidence from accurate detector
isFrontal = isFrontal,
embedding = null // Will be generated later during Discovery
)
}
/**
* Check if face is frontal
*/
private fun isFrontalFace(face: Face): Boolean {
val eulerY = face.headEulerAngleY
val eulerZ = face.headEulerAngleZ
// Frontal if head rotation is within 20 degrees
return abs(eulerY) <= 20f && abs(eulerZ) <= 20f
}
/**
* Optimized bitmap loading
*/
private fun loadBitmapOptimized(uri: android.net.Uri, maxDim: Int = 768): Bitmap? {
return try {
// Get dimensions
val options = android.graphics.BitmapFactory.Options().apply {
inJustDecodeBounds = true
}
@@ -143,40 +252,54 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
android.graphics.BitmapFactory.decodeStream(stream, null, options)
}
// Calculate sample size
var sampleSize = 1
while (options.outWidth / sampleSize > maxDim ||
options.outHeight / sampleSize > maxDim) {
sampleSize *= 2
}
// Load with sample size
val finalOptions = android.graphics.BitmapFactory.Options().apply {
inSampleSize = sampleSize
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888 // Better quality
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
}
context.contentResolver.openInputStream(uri)?.use { stream ->
android.graphics.BitmapFactory.decodeStream(stream, null, finalOptions)
}
} catch (e: Exception) {
Log.w(TAG, "Failed to load bitmap: ${e.message}")
null
}
}
/**
* Batch DB update
* Batch update both caches
*/
private suspend fun flushUpdates(updates: List<CacheUpdate>) = withContext(Dispatchers.IO) {
updates.forEach { update ->
private suspend fun flushUpdates(
imageUpdates: List<ImageCacheUpdate>,
faceUpdates: List<FaceCacheEntity>
) = withContext(Dispatchers.IO) {
// Update ImageEntity cache
imageUpdates.forEach { update ->
try {
imageDao.updateFaceDetectionCache(
imageId = update.imageId,
hasFaces = update.hasFaces,
faceCount = update.faceCount
faceCount = update.faceCount,
timestamp = System.currentTimeMillis(),
version = ImageEntity.CURRENT_FACE_DETECTION_VERSION
)
} catch (e: Exception) {
// Skip failed updates //todo
Log.w(TAG, "Failed to update image cache: ${e.message}")
}
}
// Insert FaceCacheEntity entries
if (faceUpdates.isNotEmpty()) {
try {
faceCacheDao.insertAll(faceUpdates)
} catch (e: Exception) {
Log.e(TAG, "Failed to insert face cache entries: ${e.message}")
}
}
}
@@ -186,36 +309,53 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
}
suspend fun getCacheStats(): CacheStats = withContext(Dispatchers.IO) {
val stats = imageDao.getFaceCacheStats()
val imageStats = imageDao.getFaceCacheStats()
val faceStats = faceCacheDao.getCacheStats()
CacheStats(
totalImages = stats?.totalImages ?: 0,
imagesWithFaceCache = stats?.imagesWithFaceCache ?: 0,
imagesWithFaces = stats?.imagesWithFaces ?: 0,
imagesWithoutFaces = stats?.imagesWithoutFaces ?: 0,
needsScanning = stats?.needsScanning ?: 0
totalImages = imageStats?.totalImages ?: 0,
imagesWithFaceCache = imageStats?.imagesWithFaceCache ?: 0,
imagesWithFaces = imageStats?.imagesWithFaces ?: 0,
imagesWithoutFaces = imageStats?.imagesWithoutFaces ?: 0,
needsScanning = imageStats?.needsScanning ?: 0,
totalFacesCached = faceStats.totalFaces,
facesWithEmbeddings = faceStats.withEmbeddings,
averageQuality = faceStats.avgQuality
)
}
}
private data class CacheUpdate(
/**
* Result of scanning a single image
*/
private data class ScanResult(
val imageCacheUpdate: ImageCacheUpdate,
val faceCacheEntries: List<FaceCacheEntity>
)
/**
* Image cache update data
*/
private data class ImageCacheUpdate(
val imageId: String,
val hasFaces: Boolean,
val faceCount: Int,
val imageUri: String
)
/**
* Enhanced cache stats
*/
data class CacheStats(
val totalImages: Int,
val imagesWithFaceCache: Int,
val imagesWithFaces: Int,
val imagesWithoutFaces: Int,
val needsScanning: Int
val needsScanning: Int,
val totalFacesCached: Int,
val facesWithEmbeddings: Int,
val averageQuality: Float
) {
val cacheProgress: Float
get() = if (totalImages > 0) {
imagesWithFaceCache.toFloat() / totalImages.toFloat()
} else 0f
val isComplete: Boolean
get() = needsScanning == 0
}

View File

@@ -3,6 +3,7 @@ package com.placeholder.sherpai2.ui.discover
import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Storage
import androidx.compose.material3.*
import androidx.compose.runtime.*
@@ -15,14 +16,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
import com.placeholder.sherpai2.domain.clustering.ClusterQualityAnalyzer
/**
* DiscoverPeopleScreen - ENHANCED with cache building UI
* DiscoverPeopleScreen - WITH SETTINGS SUPPORT
*
* NEW FEATURES:
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* ✅ Shows cache building progress before Discovery
* ✅ User-friendly messages explaining what's happening
* ✅ Automatic transition from cache building to Discovery
* ✅ One-time setup clearly communicated
* ✅ Discovery settings card with quality sliders
* ✅ Retry button in naming dialog
* ✅ Cache building progress UI
* ✅ Settings affect clustering behavior
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -33,12 +34,17 @@ fun DiscoverPeopleScreen(
val uiState by viewModel.uiState.collectAsState()
val qualityAnalyzer = remember { ClusterQualityAnalyzer() }
// NEW: Settings state
var settings by remember { mutableStateOf(DiscoverySettings.DEFAULT) }
Box(modifier = Modifier.fillMaxSize()) {
when (val state = uiState) {
// ===== IDLE STATE (START HERE) =====
is DiscoverUiState.Idle -> {
IdleStateContent(
onStartDiscovery = { viewModel.startDiscovery() }
IdleStateWithSettings(
settings = settings,
onSettingsChange = { settings = it },
onStartDiscovery = { viewModel.startDiscovery(settings) }
)
}
@@ -96,6 +102,7 @@ fun DiscoverPeopleScreen(
selectedSiblings = selectedSiblings
)
},
onRetry = { viewModel.retryDiscovery() }, // NEW!
onDismiss = {
viewModel.cancelNaming()
},
@@ -124,13 +131,10 @@ fun DiscoverPeopleScreen(
viewModel.requestRefinement(state.cluster)
},
onApprove = {
viewModel.approveValidationAndScan(
personId = state.personId,
personName = state.personName
)
viewModel.acceptValidationAndFinish()
},
onReject = {
viewModel.rejectValidationAndImprove()
viewModel.requestRefinement(state.cluster)
}
)
}
@@ -144,7 +148,7 @@ fun DiscoverPeopleScreen(
viewModel.requestRefinement(state.cluster)
},
onSkip = {
viewModel.reset()
viewModel.skipRefinement()
}
)
}
@@ -161,7 +165,8 @@ fun DiscoverPeopleScreen(
is DiscoverUiState.Complete -> {
CompleteStateContent(
message = state.message,
onDone = onNavigateBack
onDone = onNavigateBack,
onDiscoverMore = { viewModel.retryDiscovery() }
)
}
@@ -170,7 +175,7 @@ fun DiscoverPeopleScreen(
ErrorStateContent(
title = "No People Found",
message = state.message,
onRetry = { viewModel.startDiscovery() },
onRetry = { viewModel.retryDiscovery() },
onBack = onNavigateBack
)
}
@@ -180,7 +185,7 @@ fun DiscoverPeopleScreen(
ErrorStateContent(
title = "Error",
message = state.message,
onRetry = { viewModel.reset(); viewModel.startDiscovery() },
onRetry = { viewModel.retryDiscovery() },
onBack = onNavigateBack
)
}
@@ -188,10 +193,14 @@ fun DiscoverPeopleScreen(
}
}
// ===== IDLE STATE CONTENT =====
// ═══════════════════════════════════════════════════════════
// IDLE STATE WITH SETTINGS
// ═══════════════════════════════════════════════════════════
@Composable
private fun IdleStateContent(
private fun IdleStateWithSettings(
settings: DiscoverySettings,
onSettingsChange: (DiscoverySettings) -> Unit,
onStartDiscovery: () -> Unit
) {
Column(
@@ -217,7 +226,15 @@ private fun IdleStateContent(
color = MaterialTheme.colorScheme.onSurface
)
Spacer(modifier = Modifier.height(48.dp))
Spacer(modifier = Modifier.height(32.dp))
// NEW: Settings Card
DiscoverySettingsCard(
settings = settings,
onSettingsChange = onSettingsChange
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = onStartDiscovery,
@@ -242,7 +259,9 @@ private fun IdleStateContent(
}
}
// ===== NEW: BUILDING CACHE CONTENT =====
// ═══════════════════════════════════════════════════════════
// BUILDING CACHE CONTENT
// ═══════════════════════════════════════════════════════════
@Composable
private fun BuildingCacheContent(
@@ -359,7 +378,9 @@ private fun BuildingCacheContent(
}
}
// ===== CLUSTERING PROGRESS =====
// ═══════════════════════════════════════════════════════════
// CLUSTERING PROGRESS
// ═══════════════════════════════════════════════════════════
@Composable
private fun ClusteringProgressContent(
@@ -407,7 +428,9 @@ private fun ClusteringProgressContent(
}
}
// ===== TRAINING PROGRESS =====
// ═══════════════════════════════════════════════════════════
// TRAINING PROGRESS
// ═══════════════════════════════════════════════════════════
@Composable
private fun TrainingProgressContent(
@@ -455,7 +478,9 @@ private fun TrainingProgressContent(
}
}
// ===== REFINEMENT NEEDED =====
// ═══════════════════════════════════════════════════════════
// REFINEMENT NEEDED
// ═══════════════════════════════════════════════════════════
@Composable
private fun RefinementNeededContent(
@@ -535,7 +560,9 @@ private fun RefinementNeededContent(
}
}
// ===== REFINING PROGRESS =====
// ═══════════════════════════════════════════════════════════
// REFINING PROGRESS
// ═══════════════════════════════════════════════════════════
@Composable
private fun RefiningProgressContent(
@@ -580,7 +607,9 @@ private fun RefiningProgressContent(
}
}
// ===== LOADING CONTENT =====
// ═══════════════════════════════════════════════════════════
// LOADING CONTENT
// ═══════════════════════════════════════════════════════════
@Composable
private fun LoadingContent(message: String) {
@@ -595,12 +624,15 @@ private fun LoadingContent(message: String) {
}
}
// ===== COMPLETE STATE =====
// ═══════════════════════════════════════════════════════════
// COMPLETE STATE
// ═══════════════════════════════════════════════════════════
@Composable
private fun CompleteStateContent(
message: String,
onDone: () -> Unit
onDone: () -> Unit,
onDiscoverMore: () -> Unit
) {
Column(
modifier = Modifier
@@ -639,10 +671,27 @@ private fun CompleteStateContent(
) {
Text("Done")
}
Spacer(modifier = Modifier.height(12.dp))
OutlinedButton(
onClick = onDiscoverMore,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(Modifier.width(8.dp))
Text("Discover More People")
}
}
}
// ===== ERROR STATE =====
// ═══════════════════════════════════════════════════════════
// ERROR STATE
// ═══════════════════════════════════════════════════════════
@Composable
private fun ErrorStateContent(

View File

@@ -35,13 +35,27 @@ class DiscoverPeopleViewModel @Inject constructor(
private val namedClusterIds = mutableSetOf<Int>()
private var currentIterationCount = 0
// NEW: Store settings for use after cache population
private var lastUsedSettings: DiscoverySettings = DiscoverySettings.DEFAULT
private val workManager = WorkManager.getInstance(context)
private var cacheWorkRequestId: java.util.UUID? = null
/**
* ENHANCED: Check cache before starting Discovery
* ENHANCED: Check cache before starting Discovery (with settings support)
*/
fun startDiscovery() {
fun startDiscovery(settings: DiscoverySettings = DiscoverySettings.DEFAULT) {
lastUsedSettings = settings // Store for later use
// LOG SETTINGS
android.util.Log.d("DiscoverVM", "═══════════════════════════════════════")
android.util.Log.d("DiscoverVM", "🎛️ DISCOVERY SETTINGS")
android.util.Log.d("DiscoverVM", "═══════════════════════════════════════")
android.util.Log.d("DiscoverVM", "Min Face Size: ${settings.minFaceSize} (${(settings.minFaceSize * 100).toInt()}%)")
android.util.Log.d("DiscoverVM", "Min Quality: ${settings.minQuality} (${(settings.minQuality * 100).toInt()}%)")
android.util.Log.d("DiscoverVM", "Epsilon: ${settings.epsilon}")
android.util.Log.d("DiscoverVM", "Is Default: ${settings == DiscoverySettings.DEFAULT}")
android.util.Log.d("DiscoverVM", "═══════════════════════════════════════")
viewModelScope.launch {
try {
namedClusterIds.clear()
@@ -168,16 +182,43 @@ class DiscoverPeopleViewModel @Inject constructor(
}
/**
* Execute the actual Discovery clustering
* Execute the actual Discovery clustering (with settings support)
*/
private suspend fun executeDiscovery() {
try {
val result = clusteringService.discoverPeople(
strategy = ClusteringStrategy.PREMIUM_SOLO_ONLY,
onProgress = { current: Int, total: Int, message: String ->
_uiState.value = DiscoverUiState.Clustering(current, total, message)
}
)
// LOG WHICH PATH WE'RE TAKING
android.util.Log.d("DiscoverVM", "═══════════════════════════════════════")
android.util.Log.d("DiscoverVM", "🚀 EXECUTING DISCOVERY")
android.util.Log.d("DiscoverVM", "═══════════════════════════════════════")
// Use discoverPeopleWithSettings if settings are non-default
val result = if (lastUsedSettings == DiscoverySettings.DEFAULT) {
android.util.Log.d("DiscoverVM", "Using DEFAULT settings path")
android.util.Log.d("DiscoverVM", "Calling: clusteringService.discoverPeople()")
// Use regular method for default settings
clusteringService.discoverPeople(
strategy = ClusteringStrategy.PREMIUM_SOLO_ONLY,
onProgress = { current: Int, total: Int, message: String ->
_uiState.value = DiscoverUiState.Clustering(current, total, message)
}
)
} else {
android.util.Log.d("DiscoverVM", "Using CUSTOM settings path")
android.util.Log.d("DiscoverVM", "Settings: minFaceSize=${lastUsedSettings.minFaceSize}, minQuality=${lastUsedSettings.minQuality}, epsilon=${lastUsedSettings.epsilon}")
android.util.Log.d("DiscoverVM", "Calling: clusteringService.discoverPeopleWithSettings()")
// Use settings-aware method
clusteringService.discoverPeopleWithSettings(
settings = lastUsedSettings,
onProgress = { current: Int, total: Int, message: String ->
_uiState.value = DiscoverUiState.Clustering(current, total, message)
}
)
}
android.util.Log.d("DiscoverVM", "Discovery complete: ${result.clusters.size} clusters found")
android.util.Log.d("DiscoverVM", "═══════════════════════════════════════")
if (result.errorMessage != null) {
_uiState.value = DiscoverUiState.Error(result.errorMessage)
@@ -387,6 +428,31 @@ class DiscoverPeopleViewModel @Inject constructor(
namedClusterIds.clear()
currentIterationCount = 0
}
/**
* Retry discovery (returns to idle state)
*/
fun retryDiscovery() {
_uiState.value = DiscoverUiState.Idle
}
/**
* Accept validation results and finish
*/
fun acceptValidationAndFinish() {
_uiState.value = DiscoverUiState.Complete(
"Person created successfully!"
)
}
/**
* Skip refinement and finish
*/
fun skipRefinement() {
_uiState.value = DiscoverUiState.Complete(
"Person created successfully!"
)
}
}
/**

View File

@@ -0,0 +1,309 @@
package com.placeholder.sherpai2.ui.discover
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.expandVertically
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.layout.*
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.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
/**
* DiscoverySettingsCard - Quality control sliders
*
* Allows tuning without dropping quality:
* - Face size threshold (bigger = more strict)
* - Quality score threshold (higher = better faces)
* - Clustering strictness (tighter = more clusters)
*/
@Composable
fun DiscoverySettingsCard(
settings: DiscoverySettings,
onSettingsChange: (DiscoverySettings) -> Unit,
modifier: Modifier = Modifier
) {
var expanded by remember { mutableStateOf(false) }
Card(
modifier = modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.fillMaxWidth()
) {
// Header - Always visible
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Tune,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary
)
Column {
Text(
text = "Quality Settings",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
Text(
text = if (expanded) "Hide settings" else "Tap to adjust",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
IconButton(onClick = { expanded = !expanded }) {
Icon(
imageVector = if (expanded) Icons.Default.ExpandLess
else Icons.Default.ExpandMore,
contentDescription = if (expanded) "Collapse" else "Expand"
)
}
}
// Settings - Expandable
AnimatedVisibility(
visible = expanded,
enter = expandVertically(),
exit = shrinkVertically()
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp)
.padding(bottom = 16.dp),
verticalArrangement = Arrangement.spacedBy(20.dp)
) {
HorizontalDivider()
// Face Size Slider
QualitySlider(
title = "Minimum Face Size",
description = "Smaller = more faces, larger = higher quality",
currentValue = "${(settings.minFaceSize * 100).toInt()}%",
value = settings.minFaceSize,
onValueChange = { onSettingsChange(settings.copy(minFaceSize = it)) },
valueRange = 0.02f..0.08f,
icon = Icons.Default.ZoomIn
)
// Quality Score Slider
QualitySlider(
title = "Quality Threshold",
description = "Lower = more faces, higher = better quality",
currentValue = "${(settings.minQuality * 100).toInt()}%",
value = settings.minQuality,
onValueChange = { onSettingsChange(settings.copy(minQuality = it)) },
valueRange = 0.4f..0.8f,
icon = Icons.Default.HighQuality
)
// Clustering Strictness
QualitySlider(
title = "Clustering Strictness",
description = when {
settings.epsilon < 0.20f -> "Very strict (more clusters)"
settings.epsilon > 0.25f -> "Loose (fewer clusters)"
else -> "Balanced"
},
currentValue = when {
settings.epsilon < 0.20f -> "Strict"
settings.epsilon > 0.25f -> "Loose"
else -> "Normal"
},
value = settings.epsilon,
onValueChange = { onSettingsChange(settings.copy(epsilon = it)) },
valueRange = 0.16f..0.28f,
icon = Icons.Default.Category
)
HorizontalDivider()
// Info Card
InfoCard(
text = "These settings control face quality, not photo type. " +
"Group photos are included - we extract the best face from each."
)
// Preset Buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
OutlinedButton(
onClick = { onSettingsChange(DiscoverySettings.STRICT) },
modifier = Modifier.weight(1f)
) {
Text("High Quality", style = MaterialTheme.typography.bodySmall)
}
Button(
onClick = { onSettingsChange(DiscoverySettings.DEFAULT) },
modifier = Modifier.weight(1f)
) {
Text("Balanced", style = MaterialTheme.typography.bodySmall)
}
OutlinedButton(
onClick = { onSettingsChange(DiscoverySettings.LOOSE) },
modifier = Modifier.weight(1f)
) {
Text("More Faces", style = MaterialTheme.typography.bodySmall)
}
}
}
}
}
}
}
/**
* Individual quality slider component
*/
@Composable
private fun QualitySlider(
title: String,
description: String,
currentValue: String,
value: Float,
onValueChange: (Float) -> Unit,
valueRange: ClosedFloatingPointRange<Float>,
icon: androidx.compose.ui.graphics.vector.ImageVector
) {
Column(
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
// Header
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.weight(1f)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = title,
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
}
Surface(
shape = MaterialTheme.shapes.small,
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = currentValue,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelLarge,
color = MaterialTheme.colorScheme.onPrimaryContainer,
fontWeight = FontWeight.Bold
)
}
}
// Description
Text(
text = description,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
// Slider
Slider(
value = value,
onValueChange = onValueChange,
valueRange = valueRange
)
}
}
/**
* Info card component
*/
@Composable
private fun InfoCard(text: String) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Info,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSecondaryContainer,
modifier = Modifier.size(18.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
/**
* Discovery settings data class
*/
data class DiscoverySettings(
val minFaceSize: Float = 0.03f, // 3% of image (balanced)
val minQuality: Float = 0.6f, // 60% quality (good)
val epsilon: Float = 0.22f // DBSCAN threshold (balanced)
) {
companion object {
// Balanced - Default recommended settings
val DEFAULT = DiscoverySettings(
minFaceSize = 0.03f,
minQuality = 0.6f,
epsilon = 0.22f
)
// Strict - High quality, fewer faces
val STRICT = DiscoverySettings(
minFaceSize = 0.05f, // 5% of image
minQuality = 0.7f, // 70% quality
epsilon = 0.18f // Tight clustering
)
// Loose - More faces, lower quality threshold
val LOOSE = DiscoverySettings(
minFaceSize = 0.02f, // 2% of image
minQuality = 0.5f, // 50% quality
epsilon = 0.26f // Loose clustering
)
}
}

View File

@@ -37,20 +37,20 @@ import java.text.SimpleDateFormat
import java.util.*
/**
* NamingDialog - Complete dialog for naming a cluster
* NamingDialog - ENHANCED with Retry Button
*
* Features:
* NEW FEATURE:
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
* - Added onRetry parameter
* - Shows retry button for poor quality clusters
* - Also shows secondary retry option for good clusters
*
* All existing features preserved:
* - Name input with validation
* - Child toggle with date of birth picker
* - Sibling cluster selection
* - Quality warnings display
* - Preview of representative faces
*
* IMPROVEMENTS:
* - ✅ Complete UI implementation
* - ✅ Quality analysis integration
* - ✅ Sibling selection
* - ✅ Form validation
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -58,6 +58,7 @@ fun NamingDialog(
cluster: FaceCluster,
suggestedSiblings: List<FaceCluster>,
onConfirm: (name: String, dateOfBirth: Long?, isChild: Boolean, selectedSiblings: List<Int>) -> Unit,
onRetry: () -> Unit = {}, // NEW: Retry with different settings
onDismiss: () -> Unit,
qualityAnalyzer: ClusterQualityAnalyzer = remember { ClusterQualityAnalyzer() }
) {
@@ -99,19 +100,12 @@ fun NamingDialog(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Column(modifier = Modifier.weight(1f)) {
Text(
text = "Name This Person",
style = MaterialTheme.typography.titleLarge,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
Text(
text = "${cluster.photoCount} photos",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
)
}
Text(
text = "Name This Person",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
IconButton(onClick = onDismiss) {
Icon(
@@ -126,41 +120,224 @@ fun NamingDialog(
Column(
modifier = Modifier.padding(16.dp)
) {
// Preview faces
Text(
text = "Preview",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold
)
// ════════════════════════════════════════
// NEW: Poor Quality Warning with Retry
// ════════════════════════════════════════
if (qualityResult.qualityTier == ClusterQualityTier.POOR) {
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer
),
modifier = Modifier.fillMaxWidth()
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
Icons.Default.Warning,
contentDescription = null,
tint = MaterialTheme.colorScheme.onErrorContainer
)
Text(
text = "Poor Quality Cluster",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "This cluster doesn't meet quality requirements:",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onErrorContainer
)
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
qualityResult.warnings.forEach { warning ->
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
Text("", color = MaterialTheme.colorScheme.onErrorContainer)
Text(
warning,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onErrorContainer
)
}
}
}
HorizontalDivider(
color = MaterialTheme.colorScheme.onErrorContainer.copy(alpha = 0.3f)
)
Button(
onClick = onRetry,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.error,
contentColor = MaterialTheme.colorScheme.onError
)
) {
Icon(Icons.Default.Refresh, contentDescription = null)
Spacer(Modifier.width(8.dp))
Text("Retry with Different Settings")
}
}
}
Spacer(modifier = Modifier.height(16.dp))
} else if (qualityResult.warnings.isNotEmpty()) {
// Minor warnings for good/excellent clusters
Card(
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.5f)
)
) {
Column(
modifier = Modifier.padding(12.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
qualityResult.warnings.take(3).forEach { warning ->
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.Top
) {
Icon(
Icons.Default.Info,
contentDescription = null,
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSecondaryContainer
)
Text(
warning,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSecondaryContainer
)
}
}
}
}
Spacer(modifier = Modifier.height(16.dp))
}
// Quality badge
Surface(
color = when (qualityResult.qualityTier) {
ClusterQualityTier.EXCELLENT -> Color(0xFF1B5E20)
ClusterQualityTier.GOOD -> Color(0xFF2E7D32)
ClusterQualityTier.POOR -> Color(0xFFD32F2F)
},
shape = RoundedCornerShape(8.dp)
) {
items(cluster.representativeFaces.take(6)) { face ->
AsyncImage(
model = android.net.Uri.parse(face.imageUri),
contentDescription = "Preview",
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp))
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = RoundedCornerShape(8.dp)
),
contentScale = ContentScale.Crop
Row(
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = when (qualityResult.qualityTier) {
ClusterQualityTier.EXCELLENT, ClusterQualityTier.GOOD -> Icons.Default.Check
ClusterQualityTier.POOR -> Icons.Default.Warning
},
contentDescription = null,
tint = Color.White,
modifier = Modifier.size(16.dp)
)
Text(
text = "${qualityResult.qualityTier.name} Quality",
style = MaterialTheme.typography.labelMedium,
color = Color.White,
fontWeight = FontWeight.SemiBold
)
}
}
Spacer(modifier = Modifier.height(20.dp))
Spacer(modifier = Modifier.height(16.dp))
// Quality warning (if applicable)
if (qualityResult.qualityTier != ClusterQualityTier.EXCELLENT) {
QualityWarningCard(qualityResult = qualityResult)
Spacer(modifier = Modifier.height(16.dp))
// Stats
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${qualityResult.soloPhotoCount}",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Solo Photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${qualityResult.cleanFaceCount}",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Clean Faces",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "${(qualityResult.qualityScore * 100).toInt()}%",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Text(
text = "Quality",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Spacer(modifier = Modifier.height(24.dp))
// Representative faces preview
if (cluster.representativeFaces.isNotEmpty()) {
Text(
text = "Representative Faces",
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
LazyRow(
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
items(cluster.representativeFaces.take(6)) { face ->
AsyncImage(
model = android.net.Uri.parse(face.imageUri),
contentDescription = null,
modifier = Modifier
.size(80.dp)
.clip(RoundedCornerShape(8.dp))
.border(
2.dp,
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
RoundedCornerShape(8.dp)
),
contentScale = ContentScale.Crop
)
}
}
Spacer(modifier = Modifier.height(20.dp))
}
// Name input
@@ -168,9 +345,7 @@ fun NamingDialog(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
placeholder = { Text("Enter person's name") },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
placeholder = { Text("e.g., Emma") },
leadingIcon = {
Icon(
imageVector = Icons.Default.Person,
@@ -183,56 +358,62 @@ fun NamingDialog(
),
keyboardActions = KeyboardActions(
onDone = { keyboardController?.hide() }
)
),
singleLine = true,
modifier = Modifier.fillMaxWidth(),
enabled = qualityResult.canTrain
)
Spacer(modifier = Modifier.height(16.dp))
// Child toggle
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable { isChild = !isChild }
.background(
if (isChild) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
Surface(
modifier = Modifier.fillMaxWidth(),
color = if (isChild) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = qualityResult.canTrain) { isChild = !isChild }
.padding(16.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Icon(
imageVector = Icons.Default.Face,
contentDescription = null,
tint = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "This is a child",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Face,
contentDescription = null,
tint = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "For age-appropriate filtering",
style = MaterialTheme.typography.bodySmall,
color = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "This is a child",
style = MaterialTheme.typography.bodyLarge,
fontWeight = FontWeight.Medium,
color = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant
)
Text(
text = "For age-appropriate filtering",
style = MaterialTheme.typography.bodySmall,
color = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
)
}
}
}
Switch(
checked = isChild,
onCheckedChange = { isChild = it }
)
Switch(
checked = isChild,
onCheckedChange = null, // Handled by row click
enabled = qualityResult.canTrain
)
}
}
// Date of birth (if child)
@@ -241,7 +422,8 @@ fun NamingDialog(
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth()
modifier = Modifier.fillMaxWidth(),
enabled = qualityResult.canTrain
) {
Icon(
imageVector = Icons.Default.DateRange,
@@ -283,7 +465,8 @@ fun NamingDialog(
} else {
selectedSiblingIds + sibling.clusterId
}
}
},
enabled = qualityResult.canTrain
)
Spacer(modifier = Modifier.height(8.dp))
}
@@ -291,39 +474,73 @@ fun NamingDialog(
Spacer(modifier = Modifier.height(24.dp))
// ════════════════════════════════════════
// Action buttons
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
// ════════════════════════════════════════
if (qualityResult.qualityTier == ClusterQualityTier.POOR) {
// Poor quality - Cancel only (retry button is above)
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
modifier = Modifier.fillMaxWidth()
) {
Text("Cancel")
}
} else {
// Good quality - Normal flow
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
OutlinedButton(
onClick = onDismiss,
modifier = Modifier.weight(1f)
) {
Text("Cancel")
}
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(
name.trim(),
dateOfBirth,
isChild,
selectedSiblingIds.toList()
)
}
},
modifier = Modifier.weight(1f),
enabled = name.isNotBlank() && qualityResult.canTrain
Button(
onClick = {
if (name.isNotBlank()) {
onConfirm(
name.trim(),
dateOfBirth,
isChild,
selectedSiblingIds.toList()
)
}
},
modifier = Modifier.weight(1f),
enabled = name.isNotBlank() && qualityResult.canTrain
) {
Icon(
imageVector = Icons.Default.Check,
contentDescription = null,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text("Create Model")
}
}
// ════════════════════════════════════════
// NEW: Secondary retry option
// ════════════════════════════════════════
Spacer(modifier = Modifier.height(8.dp))
TextButton(
onClick = onRetry,
modifier = Modifier.fillMaxWidth()
) {
Icon(
imageVector = Icons.Default.Check,
Icons.Default.Refresh,
contentDescription = null,
modifier = Modifier.size(20.dp)
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(4.dp))
Text(
"Try again with different settings",
style = MaterialTheme.typography.bodySmall
)
Spacer(modifier = Modifier.width(8.dp))
Text("Create Model")
}
}
}
@@ -358,123 +575,63 @@ fun NamingDialog(
}
}
/**
* Quality warning card
*/
@Composable
private fun QualityWarningCard(qualityResult: com.placeholder.sherpai2.domain.clustering.ClusterQualityResult) {
val (backgroundColor, iconColor) = when (qualityResult.qualityTier) {
ClusterQualityTier.GOOD -> Pair(
Color(0xFFFFF9C4),
Color(0xFFF57F17)
)
ClusterQualityTier.POOR -> Pair(
Color(0xFFFFEBEE),
Color(0xFFD32F2F)
)
else -> Pair(
MaterialTheme.colorScheme.surfaceVariant,
MaterialTheme.colorScheme.onSurfaceVariant
)
}
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = backgroundColor
)
) {
Column(
modifier = Modifier.padding(12.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
) {
Icon(
imageVector = Icons.Default.Warning,
contentDescription = null,
tint = iconColor,
modifier = Modifier.size(20.dp)
)
Spacer(modifier = Modifier.width(8.dp))
Text(
text = when (qualityResult.qualityTier) {
ClusterQualityTier.GOOD -> "Review Before Training"
ClusterQualityTier.POOR -> "Quality Issues Detected"
else -> ""
},
style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold,
color = iconColor
)
}
Spacer(modifier = Modifier.height(8.dp))
qualityResult.warnings.forEach { warning ->
Text(
text = "$warning",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
/**
* Sibling selection item
*/
@Composable
private fun SiblingSelectionItem(
cluster: FaceCluster,
selected: Boolean,
onToggle: () -> Unit
onToggle: () -> Unit,
enabled: Boolean = true
) {
Row(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.clickable(onClick = onToggle)
.background(
if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant
)
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
Surface(
modifier = Modifier.fillMaxWidth(),
color = if (selected) MaterialTheme.colorScheme.primaryContainer
else MaterialTheme.colorScheme.surfaceVariant,
shape = RoundedCornerShape(8.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically
modifier = Modifier
.fillMaxWidth()
.clickable(enabled = enabled) { onToggle() }
.padding(12.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
// Preview face
AsyncImage(
model = android.net.Uri.parse(cluster.representativeFaces.firstOrNull()?.imageUri ?: ""),
contentDescription = "Preview",
modifier = Modifier
.size(40.dp)
.clip(CircleShape)
.border(
width = 1.dp,
color = MaterialTheme.colorScheme.outline,
shape = CircleShape
),
contentScale = ContentScale.Crop
)
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp)
) {
// Face preview
if (cluster.representativeFaces.isNotEmpty()) {
AsyncImage(
model = android.net.Uri.parse(cluster.representativeFaces.first().imageUri),
contentDescription = null,
modifier = Modifier
.size(48.dp)
.clip(CircleShape)
.border(2.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), CircleShape),
contentScale = ContentScale.Crop
)
}
Spacer(modifier = Modifier.width(12.dp))
Column {
Text(
text = "Person ${cluster.clusterId + 1}",
style = MaterialTheme.typography.bodyMedium,
fontWeight = FontWeight.Medium
)
Text(
text = "${cluster.photoCount} photos",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
Text(
text = "${cluster.photoCount} photos together",
style = MaterialTheme.typography.bodyMedium,
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
else MaterialTheme.colorScheme.onSurfaceVariant
Checkbox(
checked = selected,
onCheckedChange = null, // Handled by row click
enabled = enabled
)
}
Checkbox(
checked = selected,
onCheckedChange = { onToggle() }
)
}
}