dbscan clustering by person_year - working but needs ScanAndAdd TBD
This commit is contained in:
2
.idea/deploymentTargetSelector.xml
generated
2
.idea/deploymentTargetSelector.xml
generated
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
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
|
||||
)
|
||||
@@ -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>()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
// 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!"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
style = MaterialTheme.typography.headlineMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||
)
|
||||
Text(
|
||||
text = "${cluster.photoCount} photos",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
||||
)
|
||||
}
|
||||
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
@@ -126,12 +120,200 @@ fun NamingDialog(
|
||||
Column(
|
||||
modifier = Modifier.padding(16.dp)
|
||||
) {
|
||||
// Preview faces
|
||||
// ════════════════════════════════════════
|
||||
// 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 = "Preview",
|
||||
text = "Poor Quality Cluster",
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
}
|
||||
|
||||
Text(
|
||||
text = "This cluster doesn't meet quality requirements:",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onErrorContainer
|
||||
)
|
||||
|
||||
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)
|
||||
) {
|
||||
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(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))
|
||||
|
||||
@@ -141,14 +323,14 @@ fun NamingDialog(
|
||||
items(cluster.representativeFaces.take(6)) { face ->
|
||||
AsyncImage(
|
||||
model = android.net.Uri.parse(face.imageUri),
|
||||
contentDescription = "Preview",
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(80.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
2.dp,
|
||||
MaterialTheme.colorScheme.outline.copy(alpha = 0.2f),
|
||||
RoundedCornerShape(8.dp)
|
||||
),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
@@ -156,11 +338,6 @@ fun NamingDialog(
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.height(20.dp))
|
||||
|
||||
// Quality warning (if applicable)
|
||||
if (qualityResult.qualityTier != ClusterQualityTier.EXCELLENT) {
|
||||
QualityWarningCard(qualityResult = qualityResult)
|
||||
Spacer(modifier = Modifier.height(16.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,21 +358,25 @@ fun NamingDialog(
|
||||
),
|
||||
keyboardActions = KeyboardActions(
|
||||
onDone = { keyboardController?.hide() }
|
||||
)
|
||||
),
|
||||
singleLine = true,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = qualityResult.canTrain
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
|
||||
// Child toggle
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = if (isChild) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(12.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable { isChild = !isChild }
|
||||
.background(
|
||||
if (isChild) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.clickable(enabled = qualityResult.canTrain) { isChild = !isChild }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
@@ -231,9 +410,11 @@ fun NamingDialog(
|
||||
|
||||
Switch(
|
||||
checked = isChild,
|
||||
onCheckedChange = { isChild = it }
|
||||
onCheckedChange = null, // Handled by row click
|
||||
enabled = qualityResult.canTrain
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Date of birth (if child)
|
||||
if (isChild) {
|
||||
@@ -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,7 +474,19 @@ fun NamingDialog(
|
||||
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// Action buttons
|
||||
// ════════════════════════════════════════
|
||||
if (qualityResult.qualityTier == ClusterQualityTier.POOR) {
|
||||
// Poor quality - Cancel only (retry button is above)
|
||||
OutlinedButton(
|
||||
onClick = onDismiss,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Text("Cancel")
|
||||
}
|
||||
} else {
|
||||
// Good quality - Normal flow
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
||||
@@ -326,6 +521,28 @@ fun NamingDialog(
|
||||
Text("Create Model")
|
||||
}
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════
|
||||
// NEW: Secondary retry option
|
||||
// ════════════════════════════════════════
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
TextButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Icon(
|
||||
Icons.Default.Refresh,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
Spacer(Modifier.width(4.dp))
|
||||
Text(
|
||||
"Try again with different settings",
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant,
|
||||
shape = RoundedCornerShape(8.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
.background(
|
||||
if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.clickable(enabled = enabled) { onToggle() }
|
||||
.padding(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||
) {
|
||||
// Preview face
|
||||
// Face preview
|
||||
if (cluster.representativeFaces.isNotEmpty()) {
|
||||
AsyncImage(
|
||||
model = android.net.Uri.parse(cluster.representativeFaces.firstOrNull()?.imageUri ?: ""),
|
||||
contentDescription = "Preview",
|
||||
model = android.net.Uri.parse(cluster.representativeFaces.first().imageUri),
|
||||
contentDescription = null,
|
||||
modifier = Modifier
|
||||
.size(40.dp)
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.border(
|
||||
width = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
shape = CircleShape
|
||||
),
|
||||
.border(2.dp, MaterialTheme.colorScheme.outline.copy(alpha = 0.2f), CircleShape),
|
||||
contentScale = ContentScale.Crop
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.width(12.dp))
|
||||
|
||||
Column {
|
||||
Text(
|
||||
text = "${cluster.photoCount} photos together",
|
||||
text = "Person ${cluster.clusterId + 1}",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
|
||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||
fontWeight = FontWeight.Medium
|
||||
)
|
||||
Text(
|
||||
text = "${cluster.photoCount} photos",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Checkbox(
|
||||
checked = selected,
|
||||
onCheckedChange = { onToggle() }
|
||||
onCheckedChange = null, // Handled by row click
|
||||
enabled = enabled
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user