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>
|
<selectionStates>
|
||||||
<SelectionState runConfigName="app">
|
<SelectionState runConfigName="app">
|
||||||
<option name="selectionMode" value="DROPDOWN" />
|
<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">
|
<Target type="DEFAULT_BOOT">
|
||||||
<handle>
|
<handle>
|
||||||
<DeviceId pluginId="LocalEmulator" identifier="path=/home/genki/.android/avd/Medium_Phone.avd" />
|
<DeviceId pluginId="LocalEmulator" identifier="path=/home/genki/.android/avd/Medium_Phone.avd" />
|
||||||
|
|||||||
@@ -1,83 +1,75 @@
|
|||||||
package com.placeholder.sherpai2.data.local.dao
|
package com.placeholder.sherpai2.data.local.dao
|
||||||
|
|
||||||
import androidx.room.Dao
|
import androidx.room.*
|
||||||
import androidx.room.Insert
|
|
||||||
import androidx.room.OnConflictStrategy
|
|
||||||
import androidx.room.Query
|
|
||||||
import androidx.room.Update
|
|
||||||
import com.placeholder.sherpai2.data.local.entity.FaceCacheEntity
|
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
|
* Removed all faceCount filters from queries
|
||||||
* ✅ Count faces without embeddings for diagnostics
|
*
|
||||||
* ✅ Support 3-path clustering strategy:
|
* WHY:
|
||||||
* Path 1: Cached embeddings (instant)
|
* - Group photos contain high-quality faces (especially for children)
|
||||||
* Path 2: Quality metadata → generate embeddings (fast)
|
* - IoU matching ensures we extract the CORRECT face from group photos
|
||||||
* Path 3: Full scan (slow, fallback only)
|
* - 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
|
@Dao
|
||||||
interface FaceCacheDao {
|
interface FaceCacheDao {
|
||||||
|
|
||||||
// ═══════════════════════════════════════
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
// INSERT / UPDATE
|
suspend fun insert(faceCacheEntity: FaceCacheEntity)
|
||||||
// ═══════════════════════════════════════
|
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun insert(faceCache: FaceCacheEntity)
|
suspend fun insertAll(faceCacheEntities: List<FaceCacheEntity>)
|
||||||
|
|
||||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
|
||||||
suspend fun insertAll(faceCaches: List<FaceCacheEntity>)
|
|
||||||
|
|
||||||
@Update
|
@Update
|
||||||
suspend fun update(faceCache: FaceCacheEntity)
|
suspend fun update(faceCacheEntity: FaceCacheEntity)
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch update embeddings for existing cache entries
|
* Get ALL quality faces - INCLUDES GROUP PHOTOS!
|
||||||
* 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)
|
|
||||||
*
|
*
|
||||||
* 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("""
|
@Query("""
|
||||||
SELECT * FROM face_cache
|
SELECT fc.*
|
||||||
WHERE faceAreaRatio >= :minRatio
|
FROM face_cache fc
|
||||||
AND qualityScore >= :minQuality
|
WHERE fc.faceAreaRatio >= :minRatio
|
||||||
AND embedding IS NOT NULL
|
AND fc.qualityScore >= :minQuality
|
||||||
ORDER BY qualityScore DESC, faceAreaRatio DESC
|
AND fc.embedding IS NOT NULL
|
||||||
|
ORDER BY fc.qualityScore DESC, fc.faceAreaRatio DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
suspend fun getAllQualityFaces(
|
suspend fun getAllQualityFaces(
|
||||||
minRatio: Float = 0.05f,
|
minRatio: Float = 0.03f,
|
||||||
minQuality: Float = 0.7f,
|
minQuality: Float = 0.6f,
|
||||||
limit: Int = Int.MAX_VALUE
|
limit: Int = Int.MAX_VALUE
|
||||||
): List<FaceCacheEntity>
|
): 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
|
* These have good metadata but need embeddings generated.
|
||||||
* BENEFIT: Process ~1,200 faces instead of 10,824 photos
|
* INCLUDES GROUP PHOTOS - IoU matching will handle extraction!
|
||||||
*
|
|
||||||
* USE CASE: First-time Discovery when cache has metadata but no embeddings
|
|
||||||
*/
|
*/
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT * FROM face_cache
|
SELECT fc.*
|
||||||
WHERE faceAreaRatio >= :minRatio
|
FROM face_cache fc
|
||||||
AND qualityScore >= :minQuality
|
WHERE fc.faceAreaRatio >= :minRatio
|
||||||
ORDER BY qualityScore DESC, faceAreaRatio DESC
|
AND fc.qualityScore >= :minQuality
|
||||||
|
AND fc.embedding IS NULL
|
||||||
|
ORDER BY fc.qualityScore DESC, fc.faceAreaRatio DESC
|
||||||
LIMIT :limit
|
LIMIT :limit
|
||||||
""")
|
""")
|
||||||
suspend fun getQualityFacesWithoutEmbeddings(
|
suspend fun getQualityFacesWithoutEmbeddings(
|
||||||
@@ -87,222 +79,56 @@ interface FaceCacheDao {
|
|||||||
): List<FaceCacheEntity>
|
): List<FaceCacheEntity>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Count faces without embeddings (for diagnostics)
|
* Count faces WITH embeddings (Path 1 check)
|
||||||
*
|
|
||||||
* 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
|
|
||||||
*/
|
*/
|
||||||
@Query("""
|
@Query("""
|
||||||
SELECT COUNT(*)
|
SELECT COUNT(*)
|
||||||
FROM face_cache
|
FROM face_cache
|
||||||
WHERE faceAreaRatio >= :minRatio
|
WHERE embedding IS NOT NULL
|
||||||
AND qualityScore >= :minQuality
|
AND qualityScore >= :minQuality
|
||||||
AND embedding IS NOT NULL
|
|
||||||
""")
|
""")
|
||||||
suspend fun countPremiumSoloFaces(
|
suspend fun countFacesWithEmbeddings(minQuality: Float = 0.6f): Int
|
||||||
minRatio: Float = 0.05f,
|
|
||||||
minQuality: Float = 0.8f
|
|
||||||
): 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("""
|
@Query("""
|
||||||
SELECT
|
SELECT
|
||||||
COUNT(*) as totalFaces,
|
COUNT(*) as totalFaces,
|
||||||
COUNT(CASE WHEN embedding IS NOT NULL THEN 1 END) as withEmbeddings,
|
COUNT(CASE WHEN embedding IS NOT NULL THEN 1 END) as withEmbeddings,
|
||||||
AVG(faceAreaRatio) as avgSize,
|
|
||||||
AVG(qualityScore) as avgQuality,
|
AVG(qualityScore) as avgQuality,
|
||||||
MIN(qualityScore) as minQuality,
|
AVG(faceAreaRatio) as avgSize
|
||||||
MAX(qualityScore) as maxQuality
|
|
||||||
FROM face_cache
|
FROM face_cache
|
||||||
""")
|
""")
|
||||||
suspend fun getCacheStats(): CacheStats
|
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")
|
@Query("DELETE FROM face_cache WHERE imageId = :imageId")
|
||||||
suspend fun deleteFaceCacheForImage(imageId: String)
|
suspend fun deleteCacheForImage(imageId: String)
|
||||||
|
|
||||||
@Query("DELETE FROM face_cache")
|
@Query("DELETE FROM face_cache")
|
||||||
suspend fun deleteAll()
|
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(
|
data class CacheStats(
|
||||||
val totalFaces: Int,
|
val totalFaces: Int,
|
||||||
val withEmbeddings: Int,
|
val withEmbeddings: Int,
|
||||||
val avgSize: Float,
|
|
||||||
val avgQuality: Float,
|
val avgQuality: Float,
|
||||||
val minQuality: Float,
|
val avgSize: Float
|
||||||
val maxQuality: 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.FaceCacheEntity
|
||||||
import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
import com.placeholder.sherpai2.data.local.entity.ImageEntity
|
||||||
import com.placeholder.sherpai2.ml.FaceNetModel
|
import com.placeholder.sherpai2.ml.FaceNetModel
|
||||||
|
import com.placeholder.sherpai2.ui.discover.DiscoverySettings
|
||||||
import dagger.hilt.android.qualifiers.ApplicationContext
|
import dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
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)
|
// Clustering algorithms (unchanged)
|
||||||
private fun performDBSCAN(faces: List<DetectedFaceWithEmbedding>, epsilon: Float, minPoints: Int): List<RawCluster> {
|
private fun performDBSCAN(faces: List<DetectedFaceWithEmbedding>, epsilon: Float, minPoints: Int): List<RawCluster> {
|
||||||
val visited = mutableSetOf<Int>()
|
val visited = mutableSetOf<Int>()
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
package com.placeholder.sherpai2.domain.usecase
|
package com.placeholder.sherpai2.domain.usecase
|
||||||
|
|
||||||
import android.content.Context
|
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.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 dagger.hilt.android.qualifiers.ApplicationContext
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.async
|
import kotlinx.coroutines.async
|
||||||
@@ -15,41 +21,56 @@ import kotlinx.coroutines.withContext
|
|||||||
import java.util.concurrent.atomic.AtomicInteger
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import javax.inject.Singleton
|
import javax.inject.Singleton
|
||||||
|
import kotlin.math.abs
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PopulateFaceDetectionCache - HYPER-PARALLEL face scanning
|
* PopulateFaceDetectionCache - ENHANCED VERSION
|
||||||
*
|
*
|
||||||
* STRATEGY: Use ACCURATE mode BUT with MASSIVE parallelization
|
* NOW POPULATES TWO CACHES:
|
||||||
* - 50 concurrent detections (not 10!)
|
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
* - Semaphore limits to prevent OOM
|
* 1. ImageEntity cache (hasFaces, faceCount) - for quick filters
|
||||||
* - Atomic counters for thread-safe progress
|
* 2. FaceCacheEntity table - for Discovery pre-filtering
|
||||||
* - Smaller images (768px) for speed without quality loss
|
|
||||||
*
|
*
|
||||||
* 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
|
@Singleton
|
||||||
class PopulateFaceDetectionCacheUseCase @Inject constructor(
|
class PopulateFaceDetectionCacheUseCase @Inject constructor(
|
||||||
@ApplicationContext private val context: Context,
|
@ApplicationContext private val context: Context,
|
||||||
private val imageDao: ImageDao
|
private val imageDao: ImageDao,
|
||||||
|
private val faceCacheDao: FaceCacheDao
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// Limit concurrent operations to prevent OOM
|
companion object {
|
||||||
private val semaphore = Semaphore(50) // 50 concurrent detections!
|
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(
|
suspend fun execute(
|
||||||
onProgress: (Int, Int, String?) -> Unit = { _, _, _ -> }
|
onProgress: (Int, Int, String?) -> Unit = { _, _, _ -> }
|
||||||
): Int = withContext(Dispatchers.IO) {
|
): 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(
|
val detector = com.google.mlkit.vision.face.FaceDetection.getClient(
|
||||||
com.google.mlkit.vision.face.FaceDetectorOptions.Builder()
|
com.google.mlkit.vision.face.FaceDetectorOptions.Builder()
|
||||||
.setPerformanceMode(com.google.mlkit.vision.face.FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
|
.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
|
.setLandmarkMode(com.google.mlkit.vision.face.FaceDetectorOptions.LANDMARK_MODE_ALL)
|
||||||
.setClassificationMode(com.google.mlkit.vision.face.FaceDetectorOptions.CLASSIFICATION_MODE_NONE) // Don't need classification
|
.setClassificationMode(com.google.mlkit.vision.face.FaceDetectorOptions.CLASSIFICATION_MODE_NONE)
|
||||||
.setMinFaceSize(0.1f) // Detect smaller faces
|
.setMinFaceSize(0.1f)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -57,44 +78,34 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
|
|||||||
val imagesToScan = imageDao.getImagesNeedingFaceDetection()
|
val imagesToScan = imageDao.getImagesNeedingFaceDetection()
|
||||||
|
|
||||||
if (imagesToScan.isEmpty()) {
|
if (imagesToScan.isEmpty()) {
|
||||||
|
Log.d(TAG, "No images need scanning")
|
||||||
return@withContext 0
|
return@withContext 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Log.d(TAG, "Scanning ${imagesToScan.size} images")
|
||||||
|
|
||||||
val total = imagesToScan.size
|
val total = imagesToScan.size
|
||||||
val scanned = AtomicInteger(0)
|
val scanned = AtomicInteger(0)
|
||||||
val pendingUpdates = mutableListOf<CacheUpdate>()
|
val pendingImageUpdates = mutableListOf<ImageCacheUpdate>()
|
||||||
val updatesMutex = kotlinx.coroutines.sync.Mutex()
|
val pendingFaceCacheUpdates = mutableListOf<FaceCacheEntity>()
|
||||||
|
val updatesMutex = Mutex()
|
||||||
|
|
||||||
// Process ALL images in parallel with semaphore control
|
// Process all images in parallel
|
||||||
coroutineScope {
|
coroutineScope {
|
||||||
val jobs = imagesToScan.map { image ->
|
val jobs = imagesToScan.map { image ->
|
||||||
async(Dispatchers.Default) {
|
async(Dispatchers.Default) {
|
||||||
semaphore.acquire()
|
semaphore.acquire()
|
||||||
try {
|
try {
|
||||||
// Load bitmap with medium downsampling (768px = good balance)
|
processImage(image, detector)
|
||||||
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
|
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} 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 {
|
} finally {
|
||||||
semaphore.release()
|
semaphore.release()
|
||||||
|
|
||||||
// Update progress
|
|
||||||
val current = scanned.incrementAndGet()
|
val current = scanned.incrementAndGet()
|
||||||
if (current % 50 == 0 || current == total) {
|
if (current % 50 == 0 || current == total) {
|
||||||
onProgress(current, total, image.imageUri)
|
onProgress(current, total, image.imageUri)
|
||||||
@@ -103,27 +114,42 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Wait for all to complete and collect results
|
// Collect results
|
||||||
jobs.awaitAll().forEach { update ->
|
jobs.awaitAll().forEach { result ->
|
||||||
updatesMutex.withLock {
|
updatesMutex.withLock {
|
||||||
pendingUpdates.add(update)
|
pendingImageUpdates.add(result.imageCacheUpdate)
|
||||||
|
pendingFaceCacheUpdates.addAll(result.faceCacheEntries)
|
||||||
|
|
||||||
// Batch write to DB every 100 updates
|
// Batch write to DB
|
||||||
if (pendingUpdates.size >= 100) {
|
if (pendingImageUpdates.size >= BATCH_SIZE) {
|
||||||
flushUpdates(pendingUpdates.toList())
|
flushUpdates(
|
||||||
pendingUpdates.clear()
|
pendingImageUpdates.toList(),
|
||||||
|
pendingFaceCacheUpdates.toList()
|
||||||
|
)
|
||||||
|
pendingImageUpdates.clear()
|
||||||
|
pendingFaceCacheUpdates.clear()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush remaining
|
// Flush remaining
|
||||||
updatesMutex.withLock {
|
updatesMutex.withLock {
|
||||||
if (pendingUpdates.isNotEmpty()) {
|
if (pendingImageUpdates.isNotEmpty()) {
|
||||||
flushUpdates(pendingUpdates)
|
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()
|
scanned.get()
|
||||||
} finally {
|
} finally {
|
||||||
detector.close()
|
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 {
|
return try {
|
||||||
// Get dimensions
|
|
||||||
val options = android.graphics.BitmapFactory.Options().apply {
|
val options = android.graphics.BitmapFactory.Options().apply {
|
||||||
inJustDecodeBounds = true
|
inJustDecodeBounds = true
|
||||||
}
|
}
|
||||||
@@ -143,40 +252,54 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
|
|||||||
android.graphics.BitmapFactory.decodeStream(stream, null, options)
|
android.graphics.BitmapFactory.decodeStream(stream, null, options)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Calculate sample size
|
|
||||||
var sampleSize = 1
|
var sampleSize = 1
|
||||||
while (options.outWidth / sampleSize > maxDim ||
|
while (options.outWidth / sampleSize > maxDim ||
|
||||||
options.outHeight / sampleSize > maxDim) {
|
options.outHeight / sampleSize > maxDim) {
|
||||||
sampleSize *= 2
|
sampleSize *= 2
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load with sample size
|
|
||||||
val finalOptions = android.graphics.BitmapFactory.Options().apply {
|
val finalOptions = android.graphics.BitmapFactory.Options().apply {
|
||||||
inSampleSize = sampleSize
|
inSampleSize = sampleSize
|
||||||
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888 // Better quality
|
inPreferredConfig = android.graphics.Bitmap.Config.ARGB_8888
|
||||||
}
|
}
|
||||||
|
|
||||||
context.contentResolver.openInputStream(uri)?.use { stream ->
|
context.contentResolver.openInputStream(uri)?.use { stream ->
|
||||||
android.graphics.BitmapFactory.decodeStream(stream, null, finalOptions)
|
android.graphics.BitmapFactory.decodeStream(stream, null, finalOptions)
|
||||||
}
|
}
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
|
Log.w(TAG, "Failed to load bitmap: ${e.message}")
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Batch DB update
|
* Batch update both caches
|
||||||
*/
|
*/
|
||||||
private suspend fun flushUpdates(updates: List<CacheUpdate>) = withContext(Dispatchers.IO) {
|
private suspend fun flushUpdates(
|
||||||
updates.forEach { update ->
|
imageUpdates: List<ImageCacheUpdate>,
|
||||||
|
faceUpdates: List<FaceCacheEntity>
|
||||||
|
) = withContext(Dispatchers.IO) {
|
||||||
|
// Update ImageEntity cache
|
||||||
|
imageUpdates.forEach { update ->
|
||||||
try {
|
try {
|
||||||
imageDao.updateFaceDetectionCache(
|
imageDao.updateFaceDetectionCache(
|
||||||
imageId = update.imageId,
|
imageId = update.imageId,
|
||||||
hasFaces = update.hasFaces,
|
hasFaces = update.hasFaces,
|
||||||
faceCount = update.faceCount
|
faceCount = update.faceCount,
|
||||||
|
timestamp = System.currentTimeMillis(),
|
||||||
|
version = ImageEntity.CURRENT_FACE_DETECTION_VERSION
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} 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) {
|
suspend fun getCacheStats(): CacheStats = withContext(Dispatchers.IO) {
|
||||||
val stats = imageDao.getFaceCacheStats()
|
val imageStats = imageDao.getFaceCacheStats()
|
||||||
|
val faceStats = faceCacheDao.getCacheStats()
|
||||||
|
|
||||||
CacheStats(
|
CacheStats(
|
||||||
totalImages = stats?.totalImages ?: 0,
|
totalImages = imageStats?.totalImages ?: 0,
|
||||||
imagesWithFaceCache = stats?.imagesWithFaceCache ?: 0,
|
imagesWithFaceCache = imageStats?.imagesWithFaceCache ?: 0,
|
||||||
imagesWithFaces = stats?.imagesWithFaces ?: 0,
|
imagesWithFaces = imageStats?.imagesWithFaces ?: 0,
|
||||||
imagesWithoutFaces = stats?.imagesWithoutFaces ?: 0,
|
imagesWithoutFaces = imageStats?.imagesWithoutFaces ?: 0,
|
||||||
needsScanning = stats?.needsScanning ?: 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 imageId: String,
|
||||||
val hasFaces: Boolean,
|
val hasFaces: Boolean,
|
||||||
val faceCount: Int,
|
val faceCount: Int,
|
||||||
val imageUri: String
|
val imageUri: String
|
||||||
)
|
)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enhanced cache stats
|
||||||
|
*/
|
||||||
data class CacheStats(
|
data class CacheStats(
|
||||||
val totalImages: Int,
|
val totalImages: Int,
|
||||||
val imagesWithFaceCache: Int,
|
val imagesWithFaceCache: Int,
|
||||||
val imagesWithFaces: Int,
|
val imagesWithFaces: Int,
|
||||||
val imagesWithoutFaces: 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
|
val isComplete: Boolean
|
||||||
get() = needsScanning == 0
|
get() = needsScanning == 0
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package com.placeholder.sherpai2.ui.discover
|
|||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Person
|
import androidx.compose.material.icons.filled.Person
|
||||||
|
import androidx.compose.material.icons.filled.Refresh
|
||||||
import androidx.compose.material.icons.filled.Storage
|
import androidx.compose.material.icons.filled.Storage
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
@@ -15,14 +16,14 @@ import androidx.hilt.navigation.compose.hiltViewModel
|
|||||||
import com.placeholder.sherpai2.domain.clustering.ClusterQualityAnalyzer
|
import com.placeholder.sherpai2.domain.clustering.ClusterQualityAnalyzer
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* DiscoverPeopleScreen - ENHANCED with cache building UI
|
* DiscoverPeopleScreen - WITH SETTINGS SUPPORT
|
||||||
*
|
*
|
||||||
* NEW FEATURES:
|
* NEW FEATURES:
|
||||||
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
* ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
|
||||||
* ✅ Shows cache building progress before Discovery
|
* ✅ Discovery settings card with quality sliders
|
||||||
* ✅ User-friendly messages explaining what's happening
|
* ✅ Retry button in naming dialog
|
||||||
* ✅ Automatic transition from cache building to Discovery
|
* ✅ Cache building progress UI
|
||||||
* ✅ One-time setup clearly communicated
|
* ✅ Settings affect clustering behavior
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -33,12 +34,17 @@ fun DiscoverPeopleScreen(
|
|||||||
val uiState by viewModel.uiState.collectAsState()
|
val uiState by viewModel.uiState.collectAsState()
|
||||||
val qualityAnalyzer = remember { ClusterQualityAnalyzer() }
|
val qualityAnalyzer = remember { ClusterQualityAnalyzer() }
|
||||||
|
|
||||||
|
// NEW: Settings state
|
||||||
|
var settings by remember { mutableStateOf(DiscoverySettings.DEFAULT) }
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
when (val state = uiState) {
|
when (val state = uiState) {
|
||||||
// ===== IDLE STATE (START HERE) =====
|
// ===== IDLE STATE (START HERE) =====
|
||||||
is DiscoverUiState.Idle -> {
|
is DiscoverUiState.Idle -> {
|
||||||
IdleStateContent(
|
IdleStateWithSettings(
|
||||||
onStartDiscovery = { viewModel.startDiscovery() }
|
settings = settings,
|
||||||
|
onSettingsChange = { settings = it },
|
||||||
|
onStartDiscovery = { viewModel.startDiscovery(settings) }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -96,6 +102,7 @@ fun DiscoverPeopleScreen(
|
|||||||
selectedSiblings = selectedSiblings
|
selectedSiblings = selectedSiblings
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
onRetry = { viewModel.retryDiscovery() }, // NEW!
|
||||||
onDismiss = {
|
onDismiss = {
|
||||||
viewModel.cancelNaming()
|
viewModel.cancelNaming()
|
||||||
},
|
},
|
||||||
@@ -124,13 +131,10 @@ fun DiscoverPeopleScreen(
|
|||||||
viewModel.requestRefinement(state.cluster)
|
viewModel.requestRefinement(state.cluster)
|
||||||
},
|
},
|
||||||
onApprove = {
|
onApprove = {
|
||||||
viewModel.approveValidationAndScan(
|
viewModel.acceptValidationAndFinish()
|
||||||
personId = state.personId,
|
|
||||||
personName = state.personName
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onReject = {
|
onReject = {
|
||||||
viewModel.rejectValidationAndImprove()
|
viewModel.requestRefinement(state.cluster)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -144,7 +148,7 @@ fun DiscoverPeopleScreen(
|
|||||||
viewModel.requestRefinement(state.cluster)
|
viewModel.requestRefinement(state.cluster)
|
||||||
},
|
},
|
||||||
onSkip = {
|
onSkip = {
|
||||||
viewModel.reset()
|
viewModel.skipRefinement()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -161,7 +165,8 @@ fun DiscoverPeopleScreen(
|
|||||||
is DiscoverUiState.Complete -> {
|
is DiscoverUiState.Complete -> {
|
||||||
CompleteStateContent(
|
CompleteStateContent(
|
||||||
message = state.message,
|
message = state.message,
|
||||||
onDone = onNavigateBack
|
onDone = onNavigateBack,
|
||||||
|
onDiscoverMore = { viewModel.retryDiscovery() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,7 +175,7 @@ fun DiscoverPeopleScreen(
|
|||||||
ErrorStateContent(
|
ErrorStateContent(
|
||||||
title = "No People Found",
|
title = "No People Found",
|
||||||
message = state.message,
|
message = state.message,
|
||||||
onRetry = { viewModel.startDiscovery() },
|
onRetry = { viewModel.retryDiscovery() },
|
||||||
onBack = onNavigateBack
|
onBack = onNavigateBack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -180,7 +185,7 @@ fun DiscoverPeopleScreen(
|
|||||||
ErrorStateContent(
|
ErrorStateContent(
|
||||||
title = "Error",
|
title = "Error",
|
||||||
message = state.message,
|
message = state.message,
|
||||||
onRetry = { viewModel.reset(); viewModel.startDiscovery() },
|
onRetry = { viewModel.retryDiscovery() },
|
||||||
onBack = onNavigateBack
|
onBack = onNavigateBack
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -188,10 +193,14 @@ fun DiscoverPeopleScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== IDLE STATE CONTENT =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// IDLE STATE WITH SETTINGS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun IdleStateContent(
|
private fun IdleStateWithSettings(
|
||||||
|
settings: DiscoverySettings,
|
||||||
|
onSettingsChange: (DiscoverySettings) -> Unit,
|
||||||
onStartDiscovery: () -> Unit
|
onStartDiscovery: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -217,7 +226,15 @@ private fun IdleStateContent(
|
|||||||
color = MaterialTheme.colorScheme.onSurface
|
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(
|
Button(
|
||||||
onClick = onStartDiscovery,
|
onClick = onStartDiscovery,
|
||||||
@@ -242,7 +259,9 @@ private fun IdleStateContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== NEW: BUILDING CACHE CONTENT =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// BUILDING CACHE CONTENT
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BuildingCacheContent(
|
private fun BuildingCacheContent(
|
||||||
@@ -359,7 +378,9 @@ private fun BuildingCacheContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== CLUSTERING PROGRESS =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// CLUSTERING PROGRESS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ClusteringProgressContent(
|
private fun ClusteringProgressContent(
|
||||||
@@ -407,7 +428,9 @@ private fun ClusteringProgressContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== TRAINING PROGRESS =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// TRAINING PROGRESS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrainingProgressContent(
|
private fun TrainingProgressContent(
|
||||||
@@ -455,7 +478,9 @@ private fun TrainingProgressContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== REFINEMENT NEEDED =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// REFINEMENT NEEDED
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RefinementNeededContent(
|
private fun RefinementNeededContent(
|
||||||
@@ -535,7 +560,9 @@ private fun RefinementNeededContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== REFINING PROGRESS =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// REFINING PROGRESS
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun RefiningProgressContent(
|
private fun RefiningProgressContent(
|
||||||
@@ -580,7 +607,9 @@ private fun RefiningProgressContent(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== LOADING CONTENT =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// LOADING CONTENT
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun LoadingContent(message: String) {
|
private fun LoadingContent(message: String) {
|
||||||
@@ -595,12 +624,15 @@ private fun LoadingContent(message: String) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== COMPLETE STATE =====
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
// COMPLETE STATE
|
||||||
|
// ═══════════════════════════════════════════════════════════
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun CompleteStateContent(
|
private fun CompleteStateContent(
|
||||||
message: String,
|
message: String,
|
||||||
onDone: () -> Unit
|
onDone: () -> Unit,
|
||||||
|
onDiscoverMore: () -> Unit
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
@@ -639,10 +671,27 @@ private fun CompleteStateContent(
|
|||||||
) {
|
) {
|
||||||
Text("Done")
|
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
|
@Composable
|
||||||
private fun ErrorStateContent(
|
private fun ErrorStateContent(
|
||||||
|
|||||||
@@ -35,13 +35,27 @@ class DiscoverPeopleViewModel @Inject constructor(
|
|||||||
private val namedClusterIds = mutableSetOf<Int>()
|
private val namedClusterIds = mutableSetOf<Int>()
|
||||||
private var currentIterationCount = 0
|
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 val workManager = WorkManager.getInstance(context)
|
||||||
private var cacheWorkRequestId: java.util.UUID? = null
|
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 {
|
viewModelScope.launch {
|
||||||
try {
|
try {
|
||||||
namedClusterIds.clear()
|
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() {
|
private suspend fun executeDiscovery() {
|
||||||
try {
|
try {
|
||||||
val result = clusteringService.discoverPeople(
|
// LOG WHICH PATH WE'RE TAKING
|
||||||
strategy = ClusteringStrategy.PREMIUM_SOLO_ONLY,
|
android.util.Log.d("DiscoverVM", "═══════════════════════════════════════")
|
||||||
onProgress = { current: Int, total: Int, message: String ->
|
android.util.Log.d("DiscoverVM", "🚀 EXECUTING DISCOVERY")
|
||||||
_uiState.value = DiscoverUiState.Clustering(current, total, message)
|
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) {
|
if (result.errorMessage != null) {
|
||||||
_uiState.value = DiscoverUiState.Error(result.errorMessage)
|
_uiState.value = DiscoverUiState.Error(result.errorMessage)
|
||||||
@@ -387,6 +428,31 @@ class DiscoverPeopleViewModel @Inject constructor(
|
|||||||
namedClusterIds.clear()
|
namedClusterIds.clear()
|
||||||
currentIterationCount = 0
|
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.*
|
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
|
* - Name input with validation
|
||||||
* - Child toggle with date of birth picker
|
* - Child toggle with date of birth picker
|
||||||
* - Sibling cluster selection
|
* - Sibling cluster selection
|
||||||
* - Quality warnings display
|
* - Quality warnings display
|
||||||
* - Preview of representative faces
|
* - Preview of representative faces
|
||||||
*
|
|
||||||
* IMPROVEMENTS:
|
|
||||||
* - ✅ Complete UI implementation
|
|
||||||
* - ✅ Quality analysis integration
|
|
||||||
* - ✅ Sibling selection
|
|
||||||
* - ✅ Form validation
|
|
||||||
*/
|
*/
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -58,6 +58,7 @@ fun NamingDialog(
|
|||||||
cluster: FaceCluster,
|
cluster: FaceCluster,
|
||||||
suggestedSiblings: List<FaceCluster>,
|
suggestedSiblings: List<FaceCluster>,
|
||||||
onConfirm: (name: String, dateOfBirth: Long?, isChild: Boolean, selectedSiblings: List<Int>) -> Unit,
|
onConfirm: (name: String, dateOfBirth: Long?, isChild: Boolean, selectedSiblings: List<Int>) -> Unit,
|
||||||
|
onRetry: () -> Unit = {}, // NEW: Retry with different settings
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
qualityAnalyzer: ClusterQualityAnalyzer = remember { ClusterQualityAnalyzer() }
|
qualityAnalyzer: ClusterQualityAnalyzer = remember { ClusterQualityAnalyzer() }
|
||||||
) {
|
) {
|
||||||
@@ -99,19 +100,12 @@ fun NamingDialog(
|
|||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Text(
|
||||||
Text(
|
text = "Name This Person",
|
||||||
text = "Name This Person",
|
style = MaterialTheme.typography.headlineMedium,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
fontWeight = FontWeight.Bold,
|
||||||
fontWeight = FontWeight.Bold,
|
color = MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer
|
)
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "${cluster.photoCount} photos",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
IconButton(onClick = onDismiss) {
|
IconButton(onClick = onDismiss) {
|
||||||
Icon(
|
Icon(
|
||||||
@@ -126,41 +120,224 @@ fun NamingDialog(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier.padding(16.dp)
|
modifier = Modifier.padding(16.dp)
|
||||||
) {
|
) {
|
||||||
// Preview faces
|
// ════════════════════════════════════════
|
||||||
Text(
|
// NEW: Poor Quality Warning with Retry
|
||||||
text = "Preview",
|
// ════════════════════════════════════════
|
||||||
style = MaterialTheme.typography.titleMedium,
|
if (qualityResult.qualityTier == ClusterQualityTier.POOR) {
|
||||||
fontWeight = FontWeight.SemiBold
|
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(
|
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.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 ->
|
Row(
|
||||||
AsyncImage(
|
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp),
|
||||||
model = android.net.Uri.parse(face.imageUri),
|
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||||
contentDescription = "Preview",
|
verticalAlignment = Alignment.CenterVertically
|
||||||
modifier = Modifier
|
) {
|
||||||
.size(80.dp)
|
Icon(
|
||||||
.clip(RoundedCornerShape(8.dp))
|
imageVector = when (qualityResult.qualityTier) {
|
||||||
.border(
|
ClusterQualityTier.EXCELLENT, ClusterQualityTier.GOOD -> Icons.Default.Check
|
||||||
width = 1.dp,
|
ClusterQualityTier.POOR -> Icons.Default.Warning
|
||||||
color = MaterialTheme.colorScheme.outline,
|
},
|
||||||
shape = RoundedCornerShape(8.dp)
|
contentDescription = null,
|
||||||
),
|
tint = Color.White,
|
||||||
contentScale = ContentScale.Crop
|
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)
|
// Stats
|
||||||
if (qualityResult.qualityTier != ClusterQualityTier.EXCELLENT) {
|
Row(
|
||||||
QualityWarningCard(qualityResult = qualityResult)
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
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
|
// Name input
|
||||||
@@ -168,9 +345,7 @@ fun NamingDialog(
|
|||||||
value = name,
|
value = name,
|
||||||
onValueChange = { name = it },
|
onValueChange = { name = it },
|
||||||
label = { Text("Name") },
|
label = { Text("Name") },
|
||||||
placeholder = { Text("Enter person's name") },
|
placeholder = { Text("e.g., Emma") },
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
singleLine = true,
|
|
||||||
leadingIcon = {
|
leadingIcon = {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Person,
|
imageVector = Icons.Default.Person,
|
||||||
@@ -183,56 +358,62 @@ fun NamingDialog(
|
|||||||
),
|
),
|
||||||
keyboardActions = KeyboardActions(
|
keyboardActions = KeyboardActions(
|
||||||
onDone = { keyboardController?.hide() }
|
onDone = { keyboardController?.hide() }
|
||||||
)
|
),
|
||||||
|
singleLine = true,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = qualityResult.canTrain
|
||||||
)
|
)
|
||||||
|
|
||||||
Spacer(modifier = Modifier.height(16.dp))
|
Spacer(modifier = Modifier.height(16.dp))
|
||||||
|
|
||||||
// Child toggle
|
// Child toggle
|
||||||
Row(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
color = if (isChild) MaterialTheme.colorScheme.primaryContainer
|
||||||
.clip(RoundedCornerShape(8.dp))
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
.clickable { isChild = !isChild }
|
shape = RoundedCornerShape(12.dp)
|
||||||
.background(
|
|
||||||
if (isChild) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = qualityResult.canTrain) { isChild = !isChild }
|
||||||
|
.padding(16.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
Icon(
|
Row(
|
||||||
imageVector = Icons.Default.Face,
|
verticalAlignment = Alignment.CenterVertically
|
||||||
contentDescription = null,
|
) {
|
||||||
tint = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer
|
Icon(
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
imageVector = Icons.Default.Face,
|
||||||
)
|
contentDescription = null,
|
||||||
Spacer(modifier = Modifier.width(12.dp))
|
tint = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
text = "This is a child",
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
fontWeight = FontWeight.Medium,
|
|
||||||
color = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
else MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
)
|
)
|
||||||
Text(
|
Spacer(modifier = Modifier.width(12.dp))
|
||||||
text = "For age-appropriate filtering",
|
Column {
|
||||||
style = MaterialTheme.typography.bodySmall,
|
Text(
|
||||||
color = if (isChild) MaterialTheme.colorScheme.onPrimaryContainer.copy(alpha = 0.7f)
|
text = "This is a child",
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.7f)
|
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(
|
Switch(
|
||||||
checked = isChild,
|
checked = isChild,
|
||||||
onCheckedChange = { isChild = it }
|
onCheckedChange = null, // Handled by row click
|
||||||
)
|
enabled = qualityResult.canTrain
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Date of birth (if child)
|
// Date of birth (if child)
|
||||||
@@ -241,7 +422,8 @@ fun NamingDialog(
|
|||||||
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = { showDatePicker = true },
|
onClick = { showDatePicker = true },
|
||||||
modifier = Modifier.fillMaxWidth()
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
enabled = qualityResult.canTrain
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.DateRange,
|
imageVector = Icons.Default.DateRange,
|
||||||
@@ -283,7 +465,8 @@ fun NamingDialog(
|
|||||||
} else {
|
} else {
|
||||||
selectedSiblingIds + sibling.clusterId
|
selectedSiblingIds + sibling.clusterId
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
enabled = qualityResult.canTrain
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
@@ -291,39 +474,73 @@ fun NamingDialog(
|
|||||||
|
|
||||||
Spacer(modifier = Modifier.height(24.dp))
|
Spacer(modifier = Modifier.height(24.dp))
|
||||||
|
|
||||||
|
// ════════════════════════════════════════
|
||||||
// Action buttons
|
// Action buttons
|
||||||
Row(
|
// ════════════════════════════════════════
|
||||||
modifier = Modifier.fillMaxWidth(),
|
if (qualityResult.qualityTier == ClusterQualityTier.POOR) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)
|
// Poor quality - Cancel only (retry button is above)
|
||||||
) {
|
|
||||||
OutlinedButton(
|
OutlinedButton(
|
||||||
onClick = onDismiss,
|
onClick = onDismiss,
|
||||||
modifier = Modifier.weight(1f)
|
modifier = Modifier.fillMaxWidth()
|
||||||
) {
|
) {
|
||||||
Text("Cancel")
|
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(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (name.isNotBlank()) {
|
if (name.isNotBlank()) {
|
||||||
onConfirm(
|
onConfirm(
|
||||||
name.trim(),
|
name.trim(),
|
||||||
dateOfBirth,
|
dateOfBirth,
|
||||||
isChild,
|
isChild,
|
||||||
selectedSiblingIds.toList()
|
selectedSiblingIds.toList()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
enabled = name.isNotBlank() && qualityResult.canTrain
|
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(
|
Icon(
|
||||||
imageVector = Icons.Default.Check,
|
Icons.Default.Refresh,
|
||||||
contentDescription = null,
|
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
|
@Composable
|
||||||
private fun SiblingSelectionItem(
|
private fun SiblingSelectionItem(
|
||||||
cluster: FaceCluster,
|
cluster: FaceCluster,
|
||||||
selected: Boolean,
|
selected: Boolean,
|
||||||
onToggle: () -> Unit
|
onToggle: () -> Unit,
|
||||||
|
enabled: Boolean = true
|
||||||
) {
|
) {
|
||||||
Row(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth(),
|
||||||
.fillMaxWidth()
|
color = if (selected) MaterialTheme.colorScheme.primaryContainer
|
||||||
.clip(RoundedCornerShape(8.dp))
|
else MaterialTheme.colorScheme.surfaceVariant,
|
||||||
.clickable(onClick = onToggle)
|
shape = RoundedCornerShape(8.dp)
|
||||||
.background(
|
|
||||||
if (selected) MaterialTheme.colorScheme.primaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surfaceVariant
|
|
||||||
)
|
|
||||||
.padding(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween
|
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(enabled = enabled) { onToggle() }
|
||||||
|
.padding(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween
|
||||||
) {
|
) {
|
||||||
// Preview face
|
Row(
|
||||||
AsyncImage(
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
model = android.net.Uri.parse(cluster.representativeFaces.firstOrNull()?.imageUri ?: ""),
|
horizontalArrangement = Arrangement.spacedBy(8.dp)
|
||||||
contentDescription = "Preview",
|
) {
|
||||||
modifier = Modifier
|
// Face preview
|
||||||
.size(40.dp)
|
if (cluster.representativeFaces.isNotEmpty()) {
|
||||||
.clip(CircleShape)
|
AsyncImage(
|
||||||
.border(
|
model = android.net.Uri.parse(cluster.representativeFaces.first().imageUri),
|
||||||
width = 1.dp,
|
contentDescription = null,
|
||||||
color = MaterialTheme.colorScheme.outline,
|
modifier = Modifier
|
||||||
shape = CircleShape
|
.size(48.dp)
|
||||||
),
|
.clip(CircleShape)
|
||||||
contentScale = ContentScale.Crop
|
.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(
|
Checkbox(
|
||||||
text = "${cluster.photoCount} photos together",
|
checked = selected,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
onCheckedChange = null, // Handled by row click
|
||||||
color = if (selected) MaterialTheme.colorScheme.onPrimaryContainer
|
enabled = enabled
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Checkbox(
|
|
||||||
checked = selected,
|
|
||||||
onCheckedChange = { onToggle() }
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user