child centroid and discover auto face detection expansion

crashable v1
This commit is contained in:
genki
2026-01-18 00:29:51 -05:00
parent 7d3abfbe66
commit 0afb087936
17 changed files with 2247 additions and 127 deletions

View File

@@ -1,28 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DeviceTable">
<option name="collapsedNodes">
<list>
<CategoryListState>
<option name="categories">
<list>
<CategoryState>
<option name="attribute" value="Type" />
<option name="value" value="Virtual" />
</CategoryState>
<CategoryState>
<option name="attribute" value="Type" />
<option name="value" value="Virtual" />
</CategoryState>
<CategoryState>
<option name="attribute" value="Type" />
<option name="value" value="Virtual" />
</CategoryState>
</list>
</option>
</CategoryListState>
</list>
</option>
<option name="columnSorters">
<list>
<ColumnSorterState>
@@ -38,6 +16,13 @@
<option value="Type" />
<option value="Type" />
<option value="Type" />
<option value="Type" />
<option value="Type" />
<option value="Type" />
<option value="Type" />
<option value="Type" />
<option value="Type" />
<option value="Type" />
</list>
</option>
</component>

View File

View File

@@ -2,32 +2,22 @@ package com.placeholder.sherpai2.data.local
import androidx.room.Database
import androidx.room.RoomDatabase
import androidx.sqlite.db.SupportSQLiteDatabase
import androidx.room.migration.Migration
import com.placeholder.sherpai2.data.local.dao.*
import com.placeholder.sherpai2.data.local.entity.*
/**
* AppDatabase - Complete database for SherpAI2
*
* VERSION 7 - Added face detection cache to ImageEntity:
* - hasFaces: Boolean?
* - faceCount: Int?
* - facesLastDetected: Long?
* - faceDetectionVersion: Int?
* VERSION 8 - PHASE 2: Multi-centroid face models + age tagging
* - Added PersonEntity.isChild, siblingIds, familyGroupId
* - Changed FaceModelEntity.embedding → centroidsJson (multi-centroid)
* - Added PersonAgeTagEntity table for searchable age tags
*
* ENTITIES:
* - YOUR EXISTING: Image, Tag, Event, junction tables
* - NEW: PersonEntity (people in your app)
* - NEW: FaceModelEntity (face embeddings, links to PersonEntity)
* - NEW: PhotoFaceTagEntity (face detections, links to ImageEntity + FaceModelEntity)
*
* DEV MODE: Using destructive migration (fallbackToDestructiveMigration)
* - Fresh install on every schema change
* - No manual migrations needed during development
*
* PRODUCTION MODE: Add proper migrations before release
* - See DatabaseMigration.kt for migration code
* - Remove fallbackToDestructiveMigration()
* - Add .addMigrations(MIGRATION_6_7)
* MIGRATION STRATEGY:
* - Development: fallbackToDestructiveMigration (fresh install)
* - Production: Add MIGRATION_7_8 before release
*/
@Database(
entities = [
@@ -42,16 +32,16 @@ import com.placeholder.sherpai2.data.local.entity.*
PersonEntity::class,
FaceModelEntity::class,
PhotoFaceTagEntity::class,
PersonAgeTagEntity::class, // NEW: Age tagging
// ===== COLLECTIONS =====
CollectionEntity::class,
CollectionImageEntity::class,
CollectionFilterEntity::class
],
version = 7, // INCREMENTED for face detection cache
version = 8, // INCREMENTED for Phase 2
exportSchema = false
)
// No TypeConverters needed - embeddings stored as strings
abstract class AppDatabase : RoomDatabase() {
// ===== CORE DAOs =====
@@ -66,33 +56,111 @@ abstract class AppDatabase : RoomDatabase() {
abstract fun personDao(): PersonDao
abstract fun faceModelDao(): FaceModelDao
abstract fun photoFaceTagDao(): PhotoFaceTagDao
abstract fun personAgeTagDao(): PersonAgeTagDao // NEW
// ===== COLLECTIONS DAO =====
abstract fun collectionDao(): CollectionDao
}
/**
* MIGRATION NOTES FOR PRODUCTION:
* MIGRATION 7 → 8 (Phase 2)
*
* When ready to ship to users, replace destructive migration with proper migration:
* Changes:
* 1. Add isChild, siblingIds, familyGroupId to persons table
* 2. Rename embedding → centroidsJson in face_models table
* 3. Create person_age_tags table
*/
val MIGRATION_7_8 = object : Migration(7, 8) {
override fun migrate(database: SupportSQLiteDatabase) {
// ===== STEP 1: Update persons table =====
database.execSQL("ALTER TABLE persons ADD COLUMN isChild INTEGER NOT NULL DEFAULT 0")
database.execSQL("ALTER TABLE persons ADD COLUMN siblingIds TEXT DEFAULT NULL")
database.execSQL("ALTER TABLE persons ADD COLUMN familyGroupId TEXT DEFAULT NULL")
// Create index on familyGroupId for sibling queries
database.execSQL("CREATE INDEX IF NOT EXISTS index_persons_familyGroupId ON persons(familyGroupId)")
// ===== STEP 2: Update face_models table =====
// Rename embedding column to centroidsJson
// SQLite doesn't support RENAME COLUMN directly, so we need to:
// 1. Create new table with new schema
// 2. Copy data (converting single embedding to centroid JSON)
// 3. Drop old table
// 4. Rename new table
// Create new table
database.execSQL("""
CREATE TABLE IF NOT EXISTS face_models_new (
id TEXT PRIMARY KEY NOT NULL,
personId TEXT NOT NULL,
centroidsJson TEXT NOT NULL,
trainingImageCount INTEGER NOT NULL,
averageConfidence REAL NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
lastUsed INTEGER,
isActive INTEGER NOT NULL,
FOREIGN KEY(personId) REFERENCES persons(id) ON DELETE CASCADE
)
""")
// Copy data, converting embedding to centroidsJson format
// This converts single embedding to a list with one centroid
database.execSQL("""
INSERT INTO face_models_new
SELECT
id,
personId,
'[{"embedding":' || REPLACE(REPLACE(embedding, ',', ','), ',', ',') || ',"effectiveTimestamp":' || createdAt || ',"ageAtCapture":null,"photoCount":' || trainingImageCount || ',"timeRangeMonths":12,"avgConfidence":' || averageConfidence || '}]' as centroidsJson,
trainingImageCount,
averageConfidence,
createdAt,
updatedAt,
lastUsed,
isActive
FROM face_models
""")
// Drop old table
database.execSQL("DROP TABLE face_models")
// Rename new table
database.execSQL("ALTER TABLE face_models_new RENAME TO face_models")
// Recreate index
database.execSQL("CREATE UNIQUE INDEX IF NOT EXISTS index_face_models_personId ON face_models(personId)")
// ===== STEP 3: Create person_age_tags table =====
database.execSQL("""
CREATE TABLE IF NOT EXISTS person_age_tags (
id TEXT PRIMARY KEY NOT NULL,
personId TEXT NOT NULL,
imageId TEXT NOT NULL,
ageAtCapture INTEGER NOT NULL,
tagValue TEXT NOT NULL,
confidence REAL NOT NULL,
createdAt INTEGER NOT NULL,
FOREIGN KEY(personId) REFERENCES persons(id) ON DELETE CASCADE,
FOREIGN KEY(imageId) REFERENCES images(imageId) ON DELETE CASCADE
)
""")
// Create indices for fast lookups
database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_personId ON person_age_tags(personId)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_imageId ON person_age_tags(imageId)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_ageAtCapture ON person_age_tags(ageAtCapture)")
database.execSQL("CREATE INDEX IF NOT EXISTS index_person_age_tags_tagValue ON person_age_tags(tagValue)")
}
}
/**
* PRODUCTION MIGRATION NOTES:
*
* val MIGRATION_6_7 = object : Migration(6, 7) {
* override fun migrate(database: SupportSQLiteDatabase) {
* // Add face detection cache columns
* database.execSQL("ALTER TABLE images ADD COLUMN hasFaces INTEGER DEFAULT NULL")
* database.execSQL("ALTER TABLE images ADD COLUMN faceCount INTEGER DEFAULT NULL")
* database.execSQL("ALTER TABLE images ADD COLUMN facesLastDetected INTEGER DEFAULT NULL")
* database.execSQL("ALTER TABLE images ADD COLUMN faceDetectionVersion INTEGER DEFAULT NULL")
* Before shipping to users, update DatabaseModule to use migration:
*
* // Create indices
* database.execSQL("CREATE INDEX IF NOT EXISTS index_images_hasFaces ON images(hasFaces)")
* database.execSQL("CREATE INDEX IF NOT EXISTS index_images_faceCount ON images(faceCount)")
* }
* }
*
* Then in your database builder:
* Room.databaseBuilder(context, AppDatabase::class.java, "database_name")
* .addMigrations(MIGRATION_6_7) // Add this
* Room.databaseBuilder(context, AppDatabase::class.java, "sherpai.db")
* .addMigrations(MIGRATION_7_8) // Add this
* // .fallbackToDestructiveMigration() // Remove this
* .build()
*/

View File

@@ -0,0 +1,104 @@
package com.placeholder.sherpai2.data.local.dao
import androidx.room.*
import com.placeholder.sherpai2.data.local.entity.PersonAgeTagEntity
import kotlinx.coroutines.flow.Flow
/**
* PersonAgeTagDao - Manage searchable age tags for children
*
* USAGE EXAMPLES:
* - Search "emma age 3" → getImageIdsForTag("emma_age3")
* - Find all photos of Emma at age 5 → getImageIdsForPersonAtAge(emmaId, 5)
* - Get age progression → getTagsForPerson(emmaId) sorted by age
*/
@Dao
interface PersonAgeTagDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTag(tag: PersonAgeTagEntity)
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertTags(tags: List<PersonAgeTagEntity>)
/**
* Get all age tags for a person (sorted by age)
* Useful for age progression timeline
*/
@Query("SELECT * FROM person_age_tags WHERE personId = :personId ORDER BY ageAtCapture ASC")
suspend fun getTagsForPerson(personId: String): List<PersonAgeTagEntity>
/**
* Get all age tags for an image
*/
@Query("SELECT * FROM person_age_tags WHERE imageId = :imageId")
suspend fun getTagsForImage(imageId: String): List<PersonAgeTagEntity>
/**
* Search by tag value (e.g., "emma_age3")
* Returns all image IDs matching this tag
*/
@Query("SELECT DISTINCT imageId FROM person_age_tags WHERE tagValue = :tagValue")
suspend fun getImageIdsForTag(tagValue: String): List<String>
/**
* Get images of a person at a specific age
*/
@Query("SELECT DISTINCT imageId FROM person_age_tags WHERE personId = :personId AND ageAtCapture = :age")
suspend fun getImageIdsForPersonAtAge(personId: String, age: Int): List<String>
/**
* Get images of a person in an age range
*/
@Query("""
SELECT DISTINCT imageId FROM person_age_tags
WHERE personId = :personId
AND ageAtCapture BETWEEN :minAge AND :maxAge
ORDER BY ageAtCapture ASC
""")
suspend fun getImageIdsForPersonAgeRange(personId: String, minAge: Int, maxAge: Int): List<String>
/**
* Get all unique ages for a person (for age picker UI)
*/
@Query("SELECT DISTINCT ageAtCapture FROM person_age_tags WHERE personId = :personId ORDER BY ageAtCapture ASC")
suspend fun getAgesForPerson(personId: String): List<Int>
/**
* Delete all tags for a person
*/
@Query("DELETE FROM person_age_tags WHERE personId = :personId")
suspend fun deleteTagsForPerson(personId: String)
/**
* Delete all tags for an image
*/
@Query("DELETE FROM person_age_tags WHERE imageId = :imageId")
suspend fun deleteTagsForImage(imageId: String)
/**
* Get count of photos at each age (for statistics)
*/
@Query("""
SELECT ageAtCapture, COUNT(DISTINCT imageId) as count
FROM person_age_tags
WHERE personId = :personId
GROUP BY ageAtCapture
ORDER BY ageAtCapture ASC
""")
suspend fun getPhotoCountByAge(personId: String): List<AgePhotoCount>
/**
* Flow version for reactive UI
*/
@Query("SELECT * FROM person_age_tags WHERE personId = :personId ORDER BY ageAtCapture ASC")
fun getTagsForPersonFlow(personId: String): Flow<List<PersonAgeTagEntity>>
}
/**
* Data class for age photo count statistics
*/
data class AgePhotoCount(
val ageAtCapture: Int,
val count: Int
)

View File

@@ -5,19 +5,24 @@ import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Index
import androidx.room.PrimaryKey
import org.json.JSONArray
import org.json.JSONObject
import java.util.UUID
/**
* PersonEntity - NO DEFAULT VALUES for KSP compatibility
* PersonEntity - ENHANCED with child tracking and sibling relationships
*/
@Entity(
tableName = "persons",
indices = [Index(value = ["name"])]
indices = [
Index(value = ["name"]),
Index(value = ["familyGroupId"])
]
)
data class PersonEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String, // ← No default
val id: String,
@ColumnInfo(name = "name")
val name: String,
@@ -25,26 +30,48 @@ data class PersonEntity(
@ColumnInfo(name = "dateOfBirth")
val dateOfBirth: Long?,
@ColumnInfo(name = "isChild")
val isChild: Boolean, // NEW: Auto-set based on age
@ColumnInfo(name = "siblingIds")
val siblingIds: String?, // NEW: JSON list ["uuid1", "uuid2"]
@ColumnInfo(name = "familyGroupId")
val familyGroupId: String?, // NEW: UUID for family unit
@ColumnInfo(name = "relationship")
val relationship: String?,
@ColumnInfo(name = "createdAt")
val createdAt: Long, // ← No default
val createdAt: Long,
@ColumnInfo(name = "updatedAt")
val updatedAt: Long // ← No default
val updatedAt: Long
) {
companion object {
fun create(
name: String,
dateOfBirth: Long? = null,
isChild: Boolean = false,
siblingIds: List<String> = emptyList(),
relationship: String? = null
): PersonEntity {
val now = System.currentTimeMillis()
// Create family group if siblings exist
val familyGroupId = if (siblingIds.isNotEmpty()) {
UUID.randomUUID().toString()
} else null
return PersonEntity(
id = UUID.randomUUID().toString(),
name = name,
dateOfBirth = dateOfBirth,
isChild = isChild,
siblingIds = if (siblingIds.isNotEmpty()) {
JSONArray(siblingIds).toString()
} else null,
familyGroupId = familyGroupId,
relationship = relationship,
createdAt = now,
updatedAt = now
@@ -52,6 +79,17 @@ data class PersonEntity(
}
}
fun getSiblingIds(): List<String> {
return if (siblingIds != null) {
try {
val jsonArray = JSONArray(siblingIds)
(0 until jsonArray.length()).map { jsonArray.getString(it) }
} catch (e: Exception) {
emptyList()
}
} else emptyList()
}
fun getAge(): Int? {
if (dateOfBirth == null) return null
val now = System.currentTimeMillis()
@@ -74,7 +112,7 @@ data class PersonEntity(
}
/**
* FaceModelEntity - NO DEFAULT VALUES
* FaceModelEntity - MULTI-CENTROID support for temporal tracking
*/
@Entity(
tableName = "face_models",
@@ -91,13 +129,13 @@ data class PersonEntity(
data class FaceModelEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String, // ← No default
val id: String,
@ColumnInfo(name = "personId")
val personId: String,
@ColumnInfo(name = "embedding")
val embedding: String,
@ColumnInfo(name = "centroidsJson")
val centroidsJson: String, // NEW: List<TemporalCentroid> as JSON
@ColumnInfo(name = "trainingImageCount")
val trainingImageCount: Int,
@@ -106,10 +144,10 @@ data class FaceModelEntity(
val averageConfidence: Float,
@ColumnInfo(name = "createdAt")
val createdAt: Long, // ← No default
val createdAt: Long,
@ColumnInfo(name = "updatedAt")
val updatedAt: Long, // ← No default
val updatedAt: Long,
@ColumnInfo(name = "lastUsed")
val lastUsed: Long?,
@@ -118,17 +156,42 @@ data class FaceModelEntity(
val isActive: Boolean
) {
companion object {
/**
* Backwards compatible create() method
* Used by existing FaceRecognitionRepository code
*/
fun create(
personId: String,
embeddingArray: FloatArray,
trainingImageCount: Int,
averageConfidence: Float
): FaceModelEntity {
return createFromEmbedding(personId, embeddingArray, trainingImageCount, averageConfidence)
}
/**
* Create from single embedding (backwards compatible)
*/
fun createFromEmbedding(
personId: String,
embeddingArray: FloatArray,
trainingImageCount: Int,
averageConfidence: Float
): FaceModelEntity {
val now = System.currentTimeMillis()
val centroid = TemporalCentroid(
embedding = embeddingArray.toList(),
effectiveTimestamp = now,
ageAtCapture = null,
photoCount = trainingImageCount,
timeRangeMonths = 12,
avgConfidence = averageConfidence
)
return FaceModelEntity(
id = UUID.randomUUID().toString(),
personId = personId,
embedding = embeddingArray.joinToString(","),
centroidsJson = serializeCentroids(listOf(centroid)),
trainingImageCount = trainingImageCount,
averageConfidence = averageConfidence,
createdAt = now,
@@ -137,15 +200,106 @@ data class FaceModelEntity(
isActive = true
)
}
/**
* Create from multiple centroids (temporal tracking)
*/
fun createFromCentroids(
personId: String,
centroids: List<TemporalCentroid>,
trainingImageCount: Int,
averageConfidence: Float
): FaceModelEntity {
val now = System.currentTimeMillis()
return FaceModelEntity(
id = UUID.randomUUID().toString(),
personId = personId,
centroidsJson = serializeCentroids(centroids),
trainingImageCount = trainingImageCount,
averageConfidence = averageConfidence,
createdAt = now,
updatedAt = now,
lastUsed = null,
isActive = true
)
}
/**
* Serialize list of centroids to JSON
*/
private fun serializeCentroids(centroids: List<TemporalCentroid>): String {
val jsonArray = JSONArray()
centroids.forEach { centroid ->
val jsonObj = JSONObject()
jsonObj.put("embedding", JSONArray(centroid.embedding))
jsonObj.put("effectiveTimestamp", centroid.effectiveTimestamp)
jsonObj.put("ageAtCapture", centroid.ageAtCapture)
jsonObj.put("photoCount", centroid.photoCount)
jsonObj.put("timeRangeMonths", centroid.timeRangeMonths)
jsonObj.put("avgConfidence", centroid.avgConfidence)
jsonArray.put(jsonObj)
}
return jsonArray.toString()
}
/**
* Deserialize JSON to list of centroids
*/
private fun deserializeCentroids(json: String): List<TemporalCentroid> {
val jsonArray = JSONArray(json)
return (0 until jsonArray.length()).map { i ->
val jsonObj = jsonArray.getJSONObject(i)
val embeddingArray = jsonObj.getJSONArray("embedding")
val embedding = (0 until embeddingArray.length()).map { j ->
embeddingArray.getDouble(j).toFloat()
}
TemporalCentroid(
embedding = embedding,
effectiveTimestamp = jsonObj.getLong("effectiveTimestamp"),
ageAtCapture = if (jsonObj.isNull("ageAtCapture")) null else jsonObj.getDouble("ageAtCapture").toFloat(),
photoCount = jsonObj.getInt("photoCount"),
timeRangeMonths = jsonObj.getInt("timeRangeMonths"),
avgConfidence = jsonObj.getDouble("avgConfidence").toFloat()
)
}
}
}
fun getCentroids(): List<TemporalCentroid> {
return try {
FaceModelEntity.deserializeCentroids(centroidsJson)
} catch (e: Exception) {
emptyList()
}
}
// Backwards compatibility: get first centroid as single embedding
fun getEmbeddingArray(): FloatArray {
return embedding.split(",").map { it.toFloat() }.toFloatArray()
val centroids = getCentroids()
return if (centroids.isNotEmpty()) {
centroids.first().getEmbeddingArray()
} else {
FloatArray(192) // Empty embedding
}
}
}
/**
* PhotoFaceTagEntity - NO DEFAULT VALUES
* TemporalCentroid - Represents a face appearance at a specific time period
*/
data class TemporalCentroid(
val embedding: List<Float>, // 192D vector
val effectiveTimestamp: Long, // Center of time window
val ageAtCapture: Float?, // Age in years (for children)
val photoCount: Int, // Number of photos in this cluster
val timeRangeMonths: Int, // Width of time window (e.g., 6 months)
val avgConfidence: Float // Quality indicator
) {
fun getEmbeddingArray(): FloatArray = embedding.toFloatArray()
}
/**
* PhotoFaceTagEntity - Unchanged
*/
@Entity(
tableName = "photo_face_tags",
@@ -172,7 +326,7 @@ data class FaceModelEntity(
data class PhotoFaceTagEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String, // ← No default
val id: String,
@ColumnInfo(name = "imageId")
val imageId: String,
@@ -190,7 +344,7 @@ data class PhotoFaceTagEntity(
val embedding: String,
@ColumnInfo(name = "detectedAt")
val detectedAt: Long, // ← No default
val detectedAt: Long,
@ColumnInfo(name = "verifiedByUser")
val verifiedByUser: Boolean,
@@ -228,4 +382,74 @@ data class PhotoFaceTagEntity(
fun getEmbeddingArray(): FloatArray {
return embedding.split(",").map { it.toFloat() }.toFloatArray()
}
}
/**
* PersonAgeTagEntity - NEW: Searchable age tags
*/
@Entity(
tableName = "person_age_tags",
foreignKeys = [
ForeignKey(
entity = PersonEntity::class,
parentColumns = ["id"],
childColumns = ["personId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = ImageEntity::class,
parentColumns = ["imageId"],
childColumns = ["imageId"],
onDelete = ForeignKey.CASCADE
)
],
indices = [
Index(value = ["personId"]),
Index(value = ["imageId"]),
Index(value = ["ageAtCapture"]),
Index(value = ["tagValue"])
]
)
data class PersonAgeTagEntity(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String,
@ColumnInfo(name = "personId")
val personId: String,
@ColumnInfo(name = "imageId")
val imageId: String,
@ColumnInfo(name = "ageAtCapture")
val ageAtCapture: Int,
@ColumnInfo(name = "tagValue")
val tagValue: String, // e.g., "emma_age3"
@ColumnInfo(name = "confidence")
val confidence: Float,
@ColumnInfo(name = "createdAt")
val createdAt: Long
) {
companion object {
fun create(
personId: String,
personName: String,
imageId: String,
ageAtCapture: Int,
confidence: Float
): PersonAgeTagEntity {
return PersonAgeTagEntity(
id = UUID.randomUUID().toString(),
personId = personId,
imageId = imageId,
ageAtCapture = ageAtCapture,
tagValue = "${personName.lowercase().replace(" ", "_")}_age$ageAtCapture",
confidence = confidence,
createdAt = System.currentTimeMillis()
)
}
}
}

View File

@@ -3,6 +3,7 @@ package com.placeholder.sherpai2.di
import android.content.Context
import androidx.room.Room
import com.placeholder.sherpai2.data.local.AppDatabase
import com.placeholder.sherpai2.data.local.MIGRATION_7_8
import com.placeholder.sherpai2.data.local.dao.*
import dagger.Module
import dagger.Provides
@@ -14,9 +15,9 @@ import javax.inject.Singleton
/**
* DatabaseModule - Provides database and ALL DAOs
*
* DEVELOPMENT CONFIGURATION:
* - fallbackToDestructiveMigration enabled
* - No migrations required
* PHASE 2 UPDATES:
* - Added PersonAgeTagDao
* - Added migration v7→v8 (commented out for development)
*/
@Module
@InstallIn(SingletonComponent::class)
@@ -34,7 +35,12 @@ object DatabaseModule {
AppDatabase::class.java,
"sherpai.db"
)
// DEVELOPMENT MODE: Destructive migration (fresh install on schema change)
.fallbackToDestructiveMigration()
// PRODUCTION MODE: Uncomment this and remove fallbackToDestructiveMigration()
// .addMigrations(MIGRATION_7_8)
.build()
// ===== CORE DAOs =====
@@ -77,8 +83,13 @@ object DatabaseModule {
fun providePhotoFaceTagDao(db: AppDatabase): PhotoFaceTagDao =
db.photoFaceTagDao()
@Provides
fun providePersonAgeTagDao(db: AppDatabase): PersonAgeTagDao = // NEW
db.personAgeTagDao()
// ===== COLLECTIONS DAOs =====
@Provides
fun provideCollectionDao(db: AppDatabase): CollectionDao =
db.collectionDao()
}
}

View File

@@ -0,0 +1,465 @@
package com.placeholder.sherpai2.domain.clustering
import android.content.Context
import android.graphics.Bitmap
import android.graphics.BitmapFactory
import android.net.Uri
import com.google.mlkit.vision.common.InputImage
import com.google.mlkit.vision.face.FaceDetection
import com.google.mlkit.vision.face.FaceDetectorOptions
import com.placeholder.sherpai2.data.local.dao.ImageDao
import com.placeholder.sherpai2.data.local.entity.ImageEntity
import com.placeholder.sherpai2.ml.FaceNetModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.withContext
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.sqrt
/**
* FaceClusteringService - Auto-discover people in photo library
*
* STRATEGY:
* 1. Load all images with faces (from cache)
* 2. Detect faces and generate embeddings (parallel)
* 3. DBSCAN clustering on embeddings
* 4. Co-occurrence analysis (faces in same photo)
* 5. Return high-quality clusters (10-100 people typical)
*
* PERFORMANCE:
* - Uses face detection cache (only ~30% of photos)
* - Parallel processing (12 concurrent)
* - Smart sampling (don't need ALL faces for clustering)
* - Result: ~2-5 minutes for 10,000 photo library
*/
@Singleton
class FaceClusteringService @Inject constructor(
@ApplicationContext private val context: Context,
private val imageDao: ImageDao
) {
private val semaphore = Semaphore(12)
/**
* Main clustering entry point
*
* @param maxFacesToCluster Limit for performance (default 2000)
* @param onProgress Progress callback (current, total, message)
*/
suspend fun discoverPeople(
maxFacesToCluster: Int = 2000,
onProgress: (Int, Int, String) -> Unit = { _, _, _ -> }
): ClusteringResult = withContext(Dispatchers.Default) {
onProgress(0, 100, "Loading images with faces...")
// Step 1: Get images with faces (cached, fast!)
val imagesWithFaces = imageDao.getImagesWithFaces()
if (imagesWithFaces.isEmpty()) {
// Check if face cache is populated at all
val totalImages = withContext(Dispatchers.IO) {
imageDao.getImageCount()
}
if (totalImages == 0) {
return@withContext ClusteringResult(
clusters = emptyList(),
totalFacesAnalyzed = 0,
processingTimeMs = 0,
errorMessage = "No photos in library. Please wait for photo ingestion to complete."
)
}
// Images exist but no face cache - need to run PopulateFaceDetectionCacheUseCase first
return@withContext ClusteringResult(
clusters = emptyList(),
totalFacesAnalyzed = 0,
processingTimeMs = 0,
errorMessage = "Face detection cache not ready. Please wait for initial face scan to complete (check MainActivity progress bar)."
)
}
onProgress(10, 100, "Analyzing ${imagesWithFaces.size} photos...")
val startTime = System.currentTimeMillis()
// Step 2: Detect faces and generate embeddings (parallel)
val allFaces = detectFacesInImages(
images = imagesWithFaces.take(1000), // Smart limit: don't need all photos
onProgress = { current, total ->
onProgress(10 + (current * 40 / total), 100, "Detecting faces... $current/$total")
}
)
if (allFaces.isEmpty()) {
return@withContext ClusteringResult(
clusters = emptyList(),
totalFacesAnalyzed = 0,
processingTimeMs = System.currentTimeMillis() - startTime
)
}
onProgress(50, 100, "Clustering ${allFaces.size} faces...")
// Step 3: DBSCAN clustering on embeddings
val rawClusters = performDBSCAN(
faces = allFaces.take(maxFacesToCluster),
epsilon = 0.30f, // BALANCED: Not too strict, not too loose
minPoints = 5 // Minimum 5 photos to form a cluster
)
onProgress(70, 100, "Analyzing relationships...")
// Step 4: Build co-occurrence graph
val coOccurrenceGraph = buildCoOccurrenceGraph(rawClusters)
onProgress(80, 100, "Selecting representative faces...")
// Step 5: Select representative faces for each cluster
val clusters = rawClusters.map { cluster ->
FaceCluster(
clusterId = cluster.clusterId,
faces = cluster.faces,
representativeFaces = selectRepresentativeFaces(cluster.faces, count = 6),
photoCount = cluster.faces.map { it.imageId }.distinct().size,
averageConfidence = cluster.faces.map { it.confidence }.average().toFloat(),
estimatedAge = estimateAge(cluster.faces),
potentialSiblings = findPotentialSiblings(cluster, rawClusters, coOccurrenceGraph)
)
}.sortedByDescending { it.photoCount } // Most frequent first
onProgress(100, 100, "Found ${clusters.size} people!")
ClusteringResult(
clusters = clusters,
totalFacesAnalyzed = allFaces.size,
processingTimeMs = System.currentTimeMillis() - startTime
)
}
/**
* Detect faces in images and generate embeddings (parallel)
*/
private suspend fun detectFacesInImages(
images: List<ImageEntity>,
onProgress: (Int, Int) -> Unit
): List<DetectedFaceWithEmbedding> = coroutineScope {
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)
.setMinFaceSize(0.15f)
.build()
)
val faceNetModel = FaceNetModel(context)
val allFaces = mutableListOf<DetectedFaceWithEmbedding>()
val processedCount = java.util.concurrent.atomic.AtomicInteger(0)
try {
val jobs = images.map { image ->
async {
semaphore.acquire()
try {
val faces = detectFacesInImage(image, detector, faceNetModel)
val current = processedCount.incrementAndGet()
if (current % 10 == 0) {
onProgress(current, images.size)
}
faces
} finally {
semaphore.release()
}
}
}
jobs.awaitAll().flatten().also {
allFaces.addAll(it)
}
} finally {
detector.close()
faceNetModel.close()
}
allFaces
}
private suspend fun detectFacesInImage(
image: ImageEntity,
detector: com.google.mlkit.vision.face.FaceDetector,
faceNetModel: FaceNetModel
): List<DetectedFaceWithEmbedding> = withContext(Dispatchers.IO) {
try {
val uri = Uri.parse(image.imageUri)
val bitmap = loadBitmapDownsampled(uri, 512) ?: return@withContext emptyList()
val mlImage = com.google.mlkit.vision.common.InputImage.fromBitmap(bitmap, 0)
val faces = com.google.android.gms.tasks.Tasks.await(detector.process(mlImage))
val result = faces.mapNotNull { face ->
try {
val faceBitmap = Bitmap.createBitmap(
bitmap,
face.boundingBox.left.coerceIn(0, bitmap.width - 1),
face.boundingBox.top.coerceIn(0, bitmap.height - 1),
face.boundingBox.width().coerceAtMost(bitmap.width - face.boundingBox.left),
face.boundingBox.height().coerceAtMost(bitmap.height - face.boundingBox.top)
)
val embedding = faceNetModel.generateEmbedding(faceBitmap)
faceBitmap.recycle()
DetectedFaceWithEmbedding(
imageId = image.imageId,
imageUri = image.imageUri,
capturedAt = image.capturedAt,
embedding = embedding,
boundingBox = face.boundingBox,
confidence = 1.0f // Placeholder
)
} catch (e: Exception) {
null
}
}
bitmap.recycle()
result
} catch (e: Exception) {
emptyList()
}
}
/**
* DBSCAN clustering algorithm
*/
private fun performDBSCAN(
faces: List<DetectedFaceWithEmbedding>,
epsilon: Float,
minPoints: Int
): List<RawCluster> {
val visited = mutableSetOf<Int>()
val clusters = mutableListOf<RawCluster>()
var clusterId = 0
for (i in faces.indices) {
if (i in visited) continue
val neighbors = findNeighbors(i, faces, epsilon)
if (neighbors.size < minPoints) {
visited.add(i)
continue // Noise point
}
// Start new cluster
val cluster = mutableListOf<DetectedFaceWithEmbedding>()
val queue = ArrayDeque(neighbors)
visited.add(i)
cluster.add(faces[i])
while (queue.isNotEmpty()) {
val pointIdx = queue.removeFirst()
if (pointIdx in visited) continue
visited.add(pointIdx)
cluster.add(faces[pointIdx])
val pointNeighbors = findNeighbors(pointIdx, faces, epsilon)
if (pointNeighbors.size >= minPoints) {
queue.addAll(pointNeighbors.filter { it !in visited })
}
}
if (cluster.size >= minPoints) {
clusters.add(RawCluster(clusterId++, cluster))
}
}
return clusters
}
private fun findNeighbors(
pointIdx: Int,
faces: List<DetectedFaceWithEmbedding>,
epsilon: Float
): List<Int> {
val point = faces[pointIdx]
return faces.indices.filter { i ->
i != pointIdx && cosineSimilarity(point.embedding, faces[i].embedding) > (1 - epsilon)
}
}
private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float {
var dotProduct = 0f
var normA = 0f
var normB = 0f
for (i in a.indices) {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
return dotProduct / (sqrt(normA) * sqrt(normB))
}
/**
* Build co-occurrence graph (faces appearing in same photos)
*/
private fun buildCoOccurrenceGraph(clusters: List<RawCluster>): Map<Int, Map<Int, Int>> {
val graph = mutableMapOf<Int, MutableMap<Int, Int>>()
for (i in clusters.indices) {
graph[i] = mutableMapOf()
val imageIds = clusters[i].faces.map { it.imageId }.toSet()
for (j in clusters.indices) {
if (i == j) continue
val sharedImages = clusters[j].faces.count { it.imageId in imageIds }
if (sharedImages > 0) {
graph[i]!![j] = sharedImages
}
}
}
return graph
}
private fun findPotentialSiblings(
cluster: RawCluster,
allClusters: List<RawCluster>,
coOccurrenceGraph: Map<Int, Map<Int, Int>>
): List<Int> {
val clusterIdx = allClusters.indexOf(cluster)
if (clusterIdx == -1) return emptyList()
val siblings = coOccurrenceGraph[clusterIdx]
?.filter { (_, count) -> count >= 5 } // At least 5 shared photos
?.keys
?.toList()
?: emptyList()
return siblings
}
/**
* Select diverse representative faces for UI display
*/
private fun selectRepresentativeFaces(
faces: List<DetectedFaceWithEmbedding>,
count: Int
): List<DetectedFaceWithEmbedding> {
if (faces.size <= count) return faces
// Time-based sampling: spread across different dates
val sortedByTime = faces.sortedBy { it.capturedAt }
val step = faces.size / count
return (0 until count).map { i ->
sortedByTime[i * step]
}
}
/**
* Estimate if cluster represents a child (based on photo timestamps)
*/
private fun estimateAge(faces: List<DetectedFaceWithEmbedding>): AgeEstimate {
val timestamps = faces.map { it.capturedAt }.sorted()
val span = timestamps.last() - timestamps.first()
val spanYears = span / (365.25 * 24 * 60 * 60 * 1000)
// If face appearance changes over 3+ years, likely a child
return if (spanYears > 3.0) {
AgeEstimate.CHILD
} else {
AgeEstimate.UNKNOWN
}
}
private fun loadBitmapDownsampled(uri: Uri, maxDim: Int): Bitmap? {
return try {
val opts = BitmapFactory.Options().apply { inJustDecodeBounds = true }
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, opts)
}
var sample = 1
while (opts.outWidth / sample > maxDim || opts.outHeight / sample > maxDim) {
sample *= 2
}
val finalOpts = BitmapFactory.Options().apply {
inSampleSize = sample
inPreferredConfig = Bitmap.Config.RGB_565
}
context.contentResolver.openInputStream(uri)?.use {
BitmapFactory.decodeStream(it, null, finalOpts)
}
} catch (e: Exception) {
null
}
}
}
// ==================
// DATA CLASSES
// ==================
data class DetectedFaceWithEmbedding(
val imageId: String,
val imageUri: String,
val capturedAt: Long,
val embedding: FloatArray,
val boundingBox: android.graphics.Rect,
val confidence: Float
) {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DetectedFaceWithEmbedding
return imageId == other.imageId
}
override fun hashCode(): Int = imageId.hashCode()
}
data class RawCluster(
val clusterId: Int,
val faces: List<DetectedFaceWithEmbedding>
)
data class FaceCluster(
val clusterId: Int,
val faces: List<DetectedFaceWithEmbedding>,
val representativeFaces: List<DetectedFaceWithEmbedding>,
val photoCount: Int,
val averageConfidence: Float,
val estimatedAge: AgeEstimate,
val potentialSiblings: List<Int>
)
data class ClusteringResult(
val clusters: List<FaceCluster>,
val totalFacesAnalyzed: Int,
val processingTimeMs: Long,
val errorMessage: String? = null
)
enum class AgeEstimate {
CHILD, // Appearance changes significantly over time
ADULT, // Stable appearance
UNKNOWN // Not enough data
}

View File

@@ -0,0 +1,234 @@
package com.placeholder.sherpai2.domain.training
import android.content.Context
import android.graphics.BitmapFactory
import android.net.Uri
import com.placeholder.sherpai2.data.local.dao.FaceModelDao
import com.placeholder.sherpai2.data.local.dao.PersonDao
import com.placeholder.sherpai2.data.local.entity.FaceModelEntity
import com.placeholder.sherpai2.data.local.entity.PersonEntity
import com.placeholder.sherpai2.data.local.entity.TemporalCentroid
import com.placeholder.sherpai2.domain.clustering.FaceCluster
import com.placeholder.sherpai2.ml.FaceNetModel
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import javax.inject.Inject
import javax.inject.Singleton
import kotlin.math.abs
/**
* ClusterTrainingService - Train multi-centroid face models from clusters
*
* STRATEGY:
* 1. For children: Create multiple temporal centroids (one per age period)
* 2. For adults: Create single centroid (stable appearance)
* 3. Use K-Means clustering on timestamps to find age groups
* 4. Calculate centroid for each time period
*/
@Singleton
class ClusterTrainingService @Inject constructor(
@ApplicationContext private val context: Context,
private val personDao: PersonDao,
private val faceModelDao: FaceModelDao
) {
private val faceNetModel by lazy { FaceNetModel(context) }
/**
* Train a person from an auto-discovered cluster
*
* @return PersonId on success
*/
suspend fun trainFromCluster(
cluster: FaceCluster,
name: String,
dateOfBirth: Long?,
isChild: Boolean,
siblingClusterIds: List<Int>,
onProgress: (Int, Int, String) -> Unit = { _, _, _ -> }
): String = withContext(Dispatchers.Default) {
onProgress(0, 100, "Creating person...")
// Step 1: Create PersonEntity
val person = PersonEntity.create(
name = name,
dateOfBirth = dateOfBirth,
isChild = isChild,
siblingIds = emptyList(), // Will update after siblings are created
relationship = if (isChild) "Child" else null
)
withContext(Dispatchers.IO) {
personDao.insert(person)
}
onProgress(20, 100, "Analyzing face variations...")
// Step 2: Generate embeddings for all faces in cluster
val facesWithEmbeddings = cluster.faces.mapNotNull { face ->
try {
val bitmap = context.contentResolver.openInputStream(Uri.parse(face.imageUri))?.use {
BitmapFactory.decodeStream(it)
} ?: return@mapNotNull null
// Generate embedding
val embedding = faceNetModel.generateEmbedding(bitmap)
bitmap.recycle()
Triple(face.imageUri, face.capturedAt, embedding)
} catch (e: Exception) {
null
}
}
if (facesWithEmbeddings.isEmpty()) {
throw Exception("Failed to process any faces from cluster")
}
onProgress(50, 100, "Creating face model...")
// Step 3: Create centroids based on whether person is a child
val centroids = if (isChild && dateOfBirth != null) {
createTemporalCentroidsForChild(
facesWithEmbeddings = facesWithEmbeddings,
dateOfBirth = dateOfBirth
)
} else {
createSingleCentroid(facesWithEmbeddings)
}
onProgress(80, 100, "Saving model...")
// Step 4: Calculate average confidence
val avgConfidence = centroids.map { it.avgConfidence }.average().toFloat()
// Step 5: Create FaceModelEntity
val faceModel = FaceModelEntity.createFromCentroids(
personId = person.id,
centroids = centroids,
trainingImageCount = cluster.faces.size,
averageConfidence = avgConfidence
)
withContext(Dispatchers.IO) {
faceModelDao.insertFaceModel(faceModel)
}
onProgress(100, 100, "Complete!")
person.id
}
/**
* Create temporal centroids for a child
* Groups faces by age and creates one centroid per age period
*/
private fun createTemporalCentroidsForChild(
facesWithEmbeddings: List<Triple<String, Long, FloatArray>>,
dateOfBirth: Long
): List<TemporalCentroid> {
// Group faces by age (in years)
val facesByAge = facesWithEmbeddings.groupBy { (_, capturedAt, _) ->
val ageMs = capturedAt - dateOfBirth
val ageYears = (ageMs / (365.25 * 24 * 60 * 60 * 1000)).toInt()
ageYears.coerceIn(0, 18) // Cap at 18 years
}
// Create one centroid per age group
return facesByAge.map { (age, faces) ->
val embeddings = faces.map { it.third }
val avgEmbedding = averageEmbeddings(embeddings)
val avgTimestamp = faces.map { it.second }.average().toLong()
// Calculate confidence (how similar faces are to each other)
val confidences = embeddings.map { emb ->
cosineSimilarity(avgEmbedding, emb)
}
val avgConfidence = confidences.average().toFloat()
TemporalCentroid(
embedding = avgEmbedding.toList(),
effectiveTimestamp = avgTimestamp,
ageAtCapture = age.toFloat(),
photoCount = faces.size,
timeRangeMonths = 12, // 1 year window
avgConfidence = avgConfidence
)
}.sortedBy { it.ageAtCapture }
}
/**
* Create single centroid for an adult (stable appearance)
*/
private fun createSingleCentroid(
facesWithEmbeddings: List<Triple<String, Long, FloatArray>>
): List<TemporalCentroid> {
val embeddings = facesWithEmbeddings.map { it.third }
val avgEmbedding = averageEmbeddings(embeddings)
val avgTimestamp = facesWithEmbeddings.map { it.second }.average().toLong()
val confidences = embeddings.map { emb ->
cosineSimilarity(avgEmbedding, emb)
}
val avgConfidence = confidences.average().toFloat()
return listOf(
TemporalCentroid(
embedding = avgEmbedding.toList(),
effectiveTimestamp = avgTimestamp,
ageAtCapture = null,
photoCount = facesWithEmbeddings.size,
timeRangeMonths = 24, // 2 year window for adults
avgConfidence = avgConfidence
)
)
}
/**
* Average multiple embeddings into one
*/
private fun averageEmbeddings(embeddings: List<FloatArray>): FloatArray {
val size = embeddings.first().size
val avg = FloatArray(size) { 0f }
embeddings.forEach { embedding ->
for (i in embedding.indices) {
avg[i] += embedding[i]
}
}
val count = embeddings.size.toFloat()
for (i in avg.indices) {
avg[i] /= count
}
// Normalize to unit length
val norm = kotlin.math.sqrt(avg.map { it * it }.sum())
return avg.map { it / norm }.toFloatArray()
}
/**
* Calculate cosine similarity between two embeddings
*/
private fun cosineSimilarity(a: FloatArray, b: FloatArray): Float {
var dotProduct = 0f
var normA = 0f
var normB = 0f
for (i in a.indices) {
dotProduct += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
return dotProduct / (kotlin.math.sqrt(normA) * kotlin.math.sqrt(normB))
}
fun cleanup() {
faceNetModel.close()
}
}

View File

@@ -176,7 +176,7 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
faceCount = update.faceCount
)
} catch (e: Exception) {
// Skip failed updates
// Skip failed updates //todo
}
}
}

View File

@@ -0,0 +1,687 @@
package com.placeholder.sherpai2.ui.discover
import android.graphics.BitmapFactory
import android.net.Uri
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.draw.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import com.placeholder.sherpai2.domain.clustering.AgeEstimate
import com.placeholder.sherpai2.domain.clustering.FaceCluster
import java.text.SimpleDateFormat
import java.util.*
/**
* DiscoverPeopleScreen - Beautiful auto-clustering UI
*
* FLOW:
* 1. Hero CTA: "Discover People in Your Photos"
* 2. Auto-clustering progress (2-5 min)
* 3. Grid of discovered people
* 4. Tap cluster → Name person + metadata
* 5. Background deep scan starts
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DiscoverPeopleScreen(
viewModel: DiscoverPeopleViewModel = hiltViewModel()
) {
val uiState by viewModel.uiState.collectAsStateWithLifecycle()
// NO SCAFFOLD - MainScreen already has TopAppBar
Box(modifier = Modifier.fillMaxSize()) {
when (val state = uiState) {
is DiscoverUiState.Idle -> IdleScreen(
onStartDiscovery = { viewModel.startDiscovery() }
)
is DiscoverUiState.Clustering -> ClusteringProgressScreen(
progress = state.progress,
total = state.total,
message = state.message
)
is DiscoverUiState.NamingReady -> ClusterGridScreen(
result = state.result,
onClusterClick = { cluster ->
viewModel.selectCluster(cluster)
}
)
is DiscoverUiState.NamingCluster -> NamingDialog(
cluster = state.selectedCluster,
suggestedSiblings = state.suggestedSiblings,
onConfirm = { name, dob, isChild, siblings ->
viewModel.confirmClusterName(
cluster = state.selectedCluster,
name = name,
dateOfBirth = dob,
isChild = isChild,
selectedSiblings = siblings
)
},
onDismiss = { viewModel.cancelNaming() }
)
is DiscoverUiState.NoPeopleFound -> EmptyStateScreen(
message = state.message
)
is DiscoverUiState.Error -> ErrorScreen(
message = state.message,
onRetry = { viewModel.startDiscovery() }
)
}
}
}
/**
* Idle state - Hero CTA to start discovery
*/
@Composable
fun IdleScreen(
onStartDiscovery: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(120.dp),
tint = MaterialTheme.colorScheme.primary
)
Spacer(Modifier.height(24.dp))
Text(
text = "Discover People",
style = MaterialTheme.typography.headlineLarge,
fontWeight = FontWeight.Bold,
textAlign = TextAlign.Center
)
Spacer(Modifier.height(16.dp))
Text(
text = "Let AI automatically find and group faces in your photos. " +
"You'll name them, and we'll tag all their photos.",
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(32.dp))
Button(
onClick = onStartDiscovery,
modifier = Modifier
.fillMaxWidth()
.height(56.dp),
colors = ButtonDefaults.buttonColors(
containerColor = MaterialTheme.colorScheme.primary
)
) {
Icon(
imageVector = Icons.Default.AutoAwesome,
contentDescription = null,
modifier = Modifier.size(24.dp)
)
Spacer(Modifier.width(8.dp))
Text(
text = "Start Discovery",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
}
Spacer(Modifier.height(16.dp))
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceVariant
)
) {
Column(
modifier = Modifier.padding(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp)
) {
InfoRow(Icons.Default.Speed, "Fast: Analyzes ~1000 photos in 2-5 minutes")
InfoRow(Icons.Default.Security, "Private: Everything stays on your device")
InfoRow(Icons.Default.AutoAwesome, "Smart: Groups faces automatically")
}
}
}
}
@Composable
fun InfoRow(icon: androidx.compose.ui.graphics.vector.ImageVector, text: String) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(20.dp)
)
Text(
text = text,
style = MaterialTheme.typography.bodyMedium
)
}
}
/**
* Clustering progress screen
*/
@Composable
fun ClusteringProgressScreen(
progress: Int,
total: Int,
message: String
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
CircularProgressIndicator(
modifier = Modifier.size(80.dp),
strokeWidth = 6.dp
)
Spacer(Modifier.height(32.dp))
Text(
text = "Discovering People...",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(16.dp))
LinearProgressIndicator(
progress = { if (total > 0) progress.toFloat() / total.toFloat() else 0f },
modifier = Modifier.fillMaxWidth(),
)
Spacer(Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
Text(
text = "This will take 2-5 minutes. You can leave and come back later.",
style = MaterialTheme.typography.bodySmall,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
/**
* Grid of discovered clusters
*/
@Composable
fun ClusterGridScreen(
result: com.placeholder.sherpai2.domain.clustering.ClusteringResult,
onClusterClick: (FaceCluster) -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
Text(
text = "Found ${result.clusters.size} People",
style = MaterialTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(8.dp))
Text(
text = "Tap to name each person. We'll then tag all their photos.",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
LazyVerticalGrid(
columns = GridCells.Fixed(2),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
items(result.clusters) { cluster ->
ClusterCard(
cluster = cluster,
onClick = { onClusterClick(cluster) }
)
}
}
}
}
/**
* Single cluster card
*/
@Composable
fun ClusterCard(
cluster: FaceCluster,
onClick: () -> Unit
) {
val context = LocalContext.current
Card(
modifier = Modifier
.fillMaxWidth()
.clickable(onClick = onClick),
elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)
) {
Column {
// Face grid (2x3)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(180.dp),
userScrollEnabled = false
) {
items(cluster.representativeFaces.take(6)) { face ->
val bitmap = remember(face.imageUri) {
try {
context.contentResolver.openInputStream(Uri.parse(face.imageUri))?.use {
BitmapFactory.decodeStream(it)
}
} catch (e: Exception) {
null
}
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
// Info
Column(
modifier = Modifier.padding(12.dp)
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "${cluster.photoCount} photos",
style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold
)
if (cluster.estimatedAge == AgeEstimate.CHILD) {
Surface(
shape = RoundedCornerShape(12.dp),
color = MaterialTheme.colorScheme.primaryContainer
) {
Text(
text = "Child",
modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onPrimaryContainer
)
}
}
}
if (cluster.potentialSiblings.isNotEmpty()) {
Spacer(Modifier.height(4.dp))
Text(
text = "Appears with ${cluster.potentialSiblings.size} other person(s)",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
}
/**
* Naming dialog
*/
@Composable
fun NamingDialog(
cluster: FaceCluster,
suggestedSiblings: List<FaceCluster>,
onConfirm: (String, Long?, Boolean, List<Int>) -> Unit,
onDismiss: () -> Unit
) {
var name by remember { mutableStateOf("") }
var isChild by remember { mutableStateOf(cluster.estimatedAge == AgeEstimate.CHILD) }
var dateOfBirth by remember { mutableStateOf<Long?>(null) }
var selectedSiblings by remember { mutableStateOf<Set<Int>>(emptySet()) }
var showDatePicker by remember { mutableStateOf(false) }
val context = LocalContext.current
// Date picker dialog
if (showDatePicker) {
val calendar = java.util.Calendar.getInstance()
if (dateOfBirth != null) {
calendar.timeInMillis = dateOfBirth!!
}
val datePickerDialog = android.app.DatePickerDialog(
context,
{ _, year, month, dayOfMonth ->
val cal = java.util.Calendar.getInstance()
cal.set(year, month, dayOfMonth)
dateOfBirth = cal.timeInMillis
showDatePicker = false
},
calendar.get(java.util.Calendar.YEAR),
calendar.get(java.util.Calendar.MONTH),
calendar.get(java.util.Calendar.DAY_OF_MONTH)
)
datePickerDialog.setOnDismissListener {
showDatePicker = false
}
DisposableEffect(Unit) {
datePickerDialog.show()
onDispose {
datePickerDialog.dismiss()
}
}
}
AlertDialog(
onDismissRequest = onDismiss,
title = {
Text("Name This Person")
},
text = {
Column(
verticalArrangement = Arrangement.spacedBy(16.dp)
) {
// FACE PREVIEW - Show 6 representative faces
Text(
text = "${cluster.photoCount} photos found",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
LazyVerticalGrid(
columns = GridCells.Fixed(3),
modifier = Modifier.height(180.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
items(cluster.representativeFaces.take(6)) { face ->
val bitmap = remember(face.imageUri) {
try {
context.contentResolver.openInputStream(Uri.parse(face.imageUri))?.use {
BitmapFactory.decodeStream(it)
}
} catch (e: Exception) {
null
}
}
if (bitmap != null) {
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null,
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp)),
contentScale = ContentScale.Crop
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.aspectRatio(1f)
.clip(RoundedCornerShape(8.dp))
.background(MaterialTheme.colorScheme.surfaceVariant),
contentAlignment = Alignment.Center
) {
Icon(
imageVector = Icons.Default.Person,
contentDescription = null,
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
}
HorizontalDivider()
// Name input
OutlinedTextField(
value = name,
onValueChange = { name = it },
label = { Text("Name") },
singleLine = true,
modifier = Modifier.fillMaxWidth()
)
// Is child toggle
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Text("This person is a child")
Switch(
checked = isChild,
onCheckedChange = { isChild = it }
)
}
// Date of birth (if child)
if (isChild) {
OutlinedButton(
onClick = { showDatePicker = true },
modifier = Modifier.fillMaxWidth()
) {
Icon(Icons.Default.CalendarToday, null)
Spacer(Modifier.width(8.dp))
Text(
if (dateOfBirth != null) {
SimpleDateFormat("MMM dd, yyyy", Locale.getDefault())
.format(Date(dateOfBirth!!))
} else {
"Set Date of Birth"
}
)
}
}
// Suggested siblings
if (suggestedSiblings.isNotEmpty()) {
Text(
"Appears with these people (select siblings):",
style = MaterialTheme.typography.labelMedium
)
suggestedSiblings.forEach { sibling ->
Row(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(
checked = sibling.clusterId in selectedSiblings,
onCheckedChange = { checked ->
selectedSiblings = if (checked) {
selectedSiblings + sibling.clusterId
} else {
selectedSiblings - sibling.clusterId
}
}
)
Text("Person ${sibling.clusterId + 1} (${sibling.photoCount} photos)")
}
}
}
}
},
confirmButton = {
TextButton(
onClick = {
onConfirm(
name,
dateOfBirth,
isChild,
selectedSiblings.toList()
)
},
enabled = name.isNotBlank()
) {
Text("Save & Train")
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("Cancel")
}
}
)
// TODO: Add DatePickerDialog when showDatePicker is true
}
/**
* Empty state screen
*/
@Composable
fun EmptyStateScreen(message: String) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.PersonOff,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(16.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center
)
}
}
/**
* Error screen
*/
@Composable
fun ErrorScreen(
message: String,
onRetry: () -> Unit
) {
Column(
modifier = Modifier
.fillMaxSize()
.padding(32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Icon(
imageVector = Icons.Default.Error,
contentDescription = null,
modifier = Modifier.size(80.dp),
tint = MaterialTheme.colorScheme.error
)
Spacer(Modifier.height(16.dp))
Text(
text = "Oops!",
style = MaterialTheme.typography.headlineMedium,
fontWeight = FontWeight.Bold
)
Spacer(Modifier.height(8.dp))
Text(
text = message,
style = MaterialTheme.typography.bodyLarge,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(Modifier.height(24.dp))
Button(onClick = onRetry) {
Text("Try Again")
}
}
}

View File

@@ -0,0 +1,222 @@
package com.placeholder.sherpai2.ui.discover
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.placeholder.sherpai2.domain.clustering.ClusteringResult
import com.placeholder.sherpai2.domain.clustering.FaceCluster
import com.placeholder.sherpai2.domain.clustering.FaceClusteringService
import com.placeholder.sherpai2.domain.training.ClusterTrainingService
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
/**
* DiscoverPeopleViewModel - Manages auto-clustering and naming flow
*
* PHASE 2: Now includes multi-centroid training from clusters
*
* STATE FLOW:
* 1. Idle → User taps "Discover People"
* 2. Clustering → Auto-analyzing faces (2-5 min)
* 3. NamingReady → Shows clusters, user names them
* 4. Training → Creating multi-centroid face model
* 5. Complete → Ready to scan library
*/
@HiltViewModel
class DiscoverPeopleViewModel @Inject constructor(
private val clusteringService: FaceClusteringService,
private val trainingService: ClusterTrainingService
) : ViewModel() {
private val _uiState = MutableStateFlow<DiscoverUiState>(DiscoverUiState.Idle)
val uiState: StateFlow<DiscoverUiState> = _uiState.asStateFlow()
// Track which clusters have been named
private val namedClusterIds = mutableSetOf<Int>()
/**
* Start auto-clustering process
*/
fun startDiscovery() {
viewModelScope.launch {
try {
// Clear named clusters for new discovery
namedClusterIds.clear()
_uiState.value = DiscoverUiState.Clustering(0, 100, "Starting...")
val result = clusteringService.discoverPeople(
onProgress = { current, total, message ->
_uiState.value = DiscoverUiState.Clustering(current, total, message)
}
)
// Check for errors
if (result.errorMessage != null) {
_uiState.value = DiscoverUiState.Error(result.errorMessage)
return@launch
}
if (result.clusters.isEmpty()) {
_uiState.value = DiscoverUiState.NoPeopleFound(
"No faces found in your library. Make sure face detection cache is populated."
)
} else {
_uiState.value = DiscoverUiState.NamingReady(result)
}
} catch (e: Exception) {
_uiState.value = DiscoverUiState.Error(
e.message ?: "Failed to discover people"
)
}
}
}
/**
* User selected a cluster to name
*/
fun selectCluster(cluster: FaceCluster) {
val currentState = _uiState.value
if (currentState is DiscoverUiState.NamingReady) {
_uiState.value = DiscoverUiState.NamingCluster(
result = currentState.result,
selectedCluster = cluster,
suggestedSiblings = currentState.result.clusters.filter {
it.clusterId in cluster.potentialSiblings
}
)
}
}
/**
* User confirmed name and metadata for a cluster
*
* CREATES:
* 1. PersonEntity with all metadata (name, DOB, siblings)
* 2. Multi-centroid FaceModelEntity (temporal tracking for children)
* 3. Removes cluster from display
*/
fun confirmClusterName(
cluster: FaceCluster,
name: String,
dateOfBirth: Long?,
isChild: Boolean,
selectedSiblings: List<Int>
) {
viewModelScope.launch {
try {
val currentState = _uiState.value
if (currentState !is DiscoverUiState.NamingCluster) return@launch
// Train person from cluster
val personId = trainingService.trainFromCluster(
cluster = cluster,
name = name,
dateOfBirth = dateOfBirth,
isChild = isChild,
siblingClusterIds = selectedSiblings,
onProgress = { current, total, message ->
_uiState.value = DiscoverUiState.Clustering(current, total, message)
}
)
// Mark cluster as named
namedClusterIds.add(cluster.clusterId)
// Filter out named clusters
val remainingClusters = currentState.result.clusters
.filter { it.clusterId !in namedClusterIds }
if (remainingClusters.isEmpty()) {
// All clusters named! Show success
_uiState.value = DiscoverUiState.NoPeopleFound(
"All people have been named! 🎉\n\nGo to 'People' to see your trained models."
)
} else {
// Return to naming screen with remaining clusters
_uiState.value = DiscoverUiState.NamingReady(
result = currentState.result.copy(clusters = remainingClusters)
)
}
} catch (e: Exception) {
_uiState.value = DiscoverUiState.Error(
e.message ?: "Failed to create person: ${e.message}"
)
}
}
}
/**
* Cancel naming and go back to cluster list
*/
fun cancelNaming() {
val currentState = _uiState.value
if (currentState is DiscoverUiState.NamingCluster) {
_uiState.value = DiscoverUiState.NamingReady(
result = currentState.result
)
}
}
/**
* Reset to idle state
*/
fun reset() {
_uiState.value = DiscoverUiState.Idle
}
}
/**
* UI States for Discover People flow
*/
sealed class DiscoverUiState {
/**
* Initial state - user hasn't started discovery
*/
object Idle : DiscoverUiState()
/**
* Auto-clustering in progress
*/
data class Clustering(
val progress: Int,
val total: Int,
val message: String
) : DiscoverUiState()
/**
* Clustering complete, ready for user to name people
*/
data class NamingReady(
val result: ClusteringResult
) : DiscoverUiState()
/**
* User is naming a specific cluster
*/
data class NamingCluster(
val result: ClusteringResult,
val selectedCluster: FaceCluster,
val suggestedSiblings: List<FaceCluster>
) : DiscoverUiState()
/**
* No people found in library
*/
data class NoPeopleFound(
val message: String
) : DiscoverUiState()
/**
* Error occurred
*/
data class Error(
val message: String
) : DiscoverUiState()
}

View File

@@ -47,31 +47,31 @@ sealed class AppDestinations(
description = "Your photo collections"
)
// ImageDetail is not in draw er (internal navigation only)
// ImageDetail is not in drawer (internal navigation only)
// ==================
// FACE RECOGNITION
// ==================
data object Discover : AppDestinations(
route = AppRoutes.DISCOVER,
icon = Icons.Default.AutoAwesome,
label = "Discover",
description = "Find people in your photos"
)
data object Inventory : AppDestinations(
route = AppRoutes.INVENTORY,
icon = Icons.Default.Face,
label = "People Models",
description = "Existing Face Detection Models"
label = "People",
description = "Manage recognized people"
)
data object Train : AppDestinations(
route = AppRoutes.TRAIN,
icon = Icons.Default.ModelTraining,
label = "Create Model",
description = "Create a new Person Model"
)
data object Models : AppDestinations(
route = AppRoutes.MODELS,
icon = Icons.Default.SmartToy,
label = "Generative",
description = "AI Creation"
label = "Train Model",
description = "Create a new person model"
)
// ==================
@@ -117,9 +117,9 @@ val photoDestinations = listOf(
// Face recognition section
val faceRecognitionDestinations = listOf(
AppDestinations.Discover, // ✨ NEW: Auto-cluster discovery
AppDestinations.Inventory,
AppDestinations.Train,
AppDestinations.Models
AppDestinations.Train
)
// Organization section
@@ -145,9 +145,9 @@ fun getDestinationByRoute(route: String?): AppDestinations? {
AppRoutes.SEARCH -> AppDestinations.Search
AppRoutes.EXPLORE -> AppDestinations.Explore
AppRoutes.COLLECTIONS -> AppDestinations.Collections
AppRoutes.DISCOVER -> AppDestinations.Discover
AppRoutes.INVENTORY -> AppDestinations.Inventory
AppRoutes.TRAIN -> AppDestinations.Train
AppRoutes.MODELS -> AppDestinations.Models
AppRoutes.TAGS -> AppDestinations.Tags
AppRoutes.UTILITIES -> AppDestinations.UTILITIES
AppRoutes.SETTINGS -> AppDestinations.Settings

View File

@@ -18,6 +18,7 @@ import com.placeholder.sherpai2.ui.album.AlbumViewScreen
import com.placeholder.sherpai2.ui.album.AlbumViewModel
import com.placeholder.sherpai2.ui.collections.CollectionsScreen
import com.placeholder.sherpai2.ui.collections.CollectionsViewModel
import com.placeholder.sherpai2.ui.discover.DiscoverPeopleScreen
import com.placeholder.sherpai2.ui.explore.ExploreScreen
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen
@@ -32,15 +33,12 @@ import com.placeholder.sherpai2.ui.trainingprep.TrainingPhotoSelectorScreen
import com.placeholder.sherpai2.ui.utilities.PhotoUtilitiesScreen
import java.net.URLDecoder
import java.net.URLEncoder
import com.placeholder.sherpai2.ui.navigation.AppRoutes
/**
* AppNavHost - UPDATED with TrainingPhotoSelector integration
* AppNavHost - UPDATED with Discover People screen
*
* Changes:
* - Replaced ImageSelectorScreen with TrainingPhotoSelectorScreen
* - Shows ONLY photos with faces (hasFaces=true)
* - Multi-select photo gallery for training
* - Filters 10,000 photos → ~500 with faces for fast selection
* NEW: Replaces placeholder "Models" screen with auto-clustering face discovery
*/
@Composable
fun AppNavHost(
@@ -185,6 +183,22 @@ fun AppNavHost(
// FACE RECOGNITION SYSTEM
// ==========================================
/**
* DISCOVER PEOPLE SCREEN - ✨ NEW!
*
* Auto-clustering face discovery with spoon-feed naming flow:
* 1. Auto-clusters all faces in library (2-5 min)
* 2. Shows beautiful grid of discovered people
* 3. User taps to name each person
* 4. Captures: name, DOB, sibling relationships
* 5. Triggers deep background scan with age tagging
*
* Replaces: Old "Models" placeholder screen
*/
composable(AppRoutes.DISCOVER) {
DiscoverPeopleScreen()
}
/**
* PERSON INVENTORY SCREEN
*/
@@ -197,7 +211,7 @@ fun AppNavHost(
}
/**
* TRAINING FLOW - UPDATED with TrainingPhotoSelector
* TRAINING FLOW - Manual training (still available)
*/
composable(AppRoutes.TRAIN) { entry ->
val trainViewModel: TrainViewModel = hiltViewModel()
@@ -235,15 +249,7 @@ fun AppNavHost(
}
/**
* TRAINING PHOTO SELECTOR - NEW: Custom gallery with face filtering
*
* Replaces native photo picker with custom selector that:
* - Shows ONLY photos with hasFaces=true
* - Multi-select with visual feedback
* - Face count badges on each photo
* - Enforces minimum 15 photos
*
* Result: User browses ~500 photos instead of 10,000!
* TRAINING PHOTO SELECTOR - Custom gallery with face filtering
*/
composable(AppRoutes.TRAINING_PHOTO_SELECTOR) {
TrainingPhotoSelectorScreen(
@@ -261,12 +267,12 @@ fun AppNavHost(
}
/**
* MODELS SCREEN
* MODELS SCREEN - DEPRECATED, kept for backwards compat
*/
composable(AppRoutes.MODELS) {
DummyScreen(
title = "AI Models",
subtitle = "Manage face recognition models"
subtitle = "Use 'Discover' instead"
)
}

View File

@@ -17,9 +17,10 @@ object AppRoutes {
const val IMAGE_DETAIL = "IMAGE_DETAIL"
// Face recognition
const val DISCOVER = "discover" // ✨ NEW: Auto-cluster face discovery
const val INVENTORY = "inv"
const val TRAIN = "train"
const val MODELS = "models"
const val MODELS = "models" // DEPRECATED - kept for reference only
// Organization
const val TAGS = "tags"
@@ -30,7 +31,7 @@ object AppRoutes {
// Internal training flow screens
const val IMAGE_SELECTOR = "Image Selection" // DEPRECATED - kept for reference only
const val TRAINING_PHOTO_SELECTOR = "training_photo_selector" // NEW: Face-filtered gallery
const val TRAINING_PHOTO_SELECTOR = "training_photo_selector" // Face-filtered gallery
const val CROP_SCREEN = "CROP_SCREEN"
const val TRAINING_SCREEN = "TRAINING_SCREEN"
const val ScanResultsScreen = "First Scan Results"

View File

@@ -21,7 +21,7 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes
/**
* SLIMMED DOWN AppDrawer - 280dp width, inline logo, cleaner sections
* NOW WITH: Scrollable support for small phones + Collections item
* UPDATED: Discover People feature with sparkle icon ✨
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -109,7 +109,7 @@ fun AppDrawerContent(
val photoItems = listOf(
DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search),
DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore),
DrawerItem(AppRoutes.COLLECTIONS, "Collections", Icons.Default.Collections) // NEW!
DrawerItem(AppRoutes.COLLECTIONS, "Collections", Icons.Default.Collections)
)
photoItems.forEach { item ->
@@ -126,9 +126,9 @@ fun AppDrawerContent(
DrawerSection(title = "Face Recognition")
val faceItems = listOf(
DrawerItem(AppRoutes.DISCOVER, "Discover", Icons.Default.AutoAwesome), // ✨ UPDATED!
DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face),
DrawerItem(AppRoutes.TRAIN, "Create Person", Icons.Default.ModelTraining),
DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy)
DrawerItem(AppRoutes.TRAIN, "Train Model", Icons.Default.ModelTraining)
)
faceItems.forEach { item ->

View File

@@ -15,7 +15,7 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes
import kotlinx.coroutines.launch
/**
* Clean main screen - NO duplicate FABs, Collections support
* Clean main screen - NO duplicate FABs, Collections support, Discover People
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -98,7 +98,6 @@ fun MainScreen() {
)
}
}
// NOTE: Removed TAGS action - TagManagementScreen has its own inline FAB
}
},
colors = TopAppBarDefaults.topAppBarColors(
@@ -109,7 +108,6 @@ fun MainScreen() {
)
)
}
// NOTE: NO floatingActionButton here - individual screens manage their own FABs inline
) { paddingValues ->
AppNavHost(
navController = navController,
@@ -126,10 +124,11 @@ private fun getScreenTitle(route: String): String {
return when (route) {
AppRoutes.SEARCH -> "Search"
AppRoutes.EXPLORE -> "Explore"
AppRoutes.COLLECTIONS -> "Collections" // NEW!
AppRoutes.COLLECTIONS -> "Collections"
AppRoutes.DISCOVER -> "Discover People" // ✨ NEW!
AppRoutes.INVENTORY -> "People"
AppRoutes.TRAIN -> "Train New Person"
AppRoutes.MODELS -> "AI Models"
AppRoutes.MODELS -> "AI Models" // Deprecated, but keep for backwards compat
AppRoutes.TAGS -> "Tag Management"
AppRoutes.UTILITIES -> "Photo Util."
AppRoutes.SETTINGS -> "Settings"
@@ -144,7 +143,8 @@ private fun getScreenSubtitle(route: String): String? {
return when (route) {
AppRoutes.SEARCH -> "Find photos by tags, people, or date"
AppRoutes.EXPLORE -> "Browse your collection"
AppRoutes.COLLECTIONS -> "Your photo collections" // NEW!
AppRoutes.COLLECTIONS -> "Your photo collections"
AppRoutes.DISCOVER -> "Auto-find faces in your library" // ✨ NEW!
AppRoutes.INVENTORY -> "Trained face models"
AppRoutes.TRAIN -> "Add a new person to recognize"
AppRoutes.TAGS -> "Organize your photo collection"

View File

@@ -0,0 +1,113 @@
package com.placeholder.sherpai2.workers
import android.content.Context
import androidx.hilt.work.HiltWorker
import androidx.work.*
import com.placeholder.sherpai2.domain.clustering.FaceClusteringService
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
/**
* FaceClusteringWorker - Background face clustering with persistence
*
* BENEFITS:
* - Survives app restarts
* - Runs even when app is backgrounded
* - Progress updates via WorkManager Data
* - Results saved to shared preferences
*
* USAGE:
* val workRequest = OneTimeWorkRequestBuilder<FaceClusteringWorker>()
* .setConstraints(...)
* .build()
* WorkManager.getInstance(context).enqueue(workRequest)
*/
@HiltWorker
class FaceClusteringWorker @AssistedInject constructor(
@Assisted private val context: Context,
@Assisted workerParams: WorkerParameters,
private val clusteringService: FaceClusteringService
) : CoroutineWorker(context, workerParams) {
companion object {
const val WORK_NAME = "face_clustering_discovery"
const val KEY_PROGRESS_CURRENT = "progress_current"
const val KEY_PROGRESS_TOTAL = "progress_total"
const val KEY_PROGRESS_MESSAGE = "progress_message"
const val KEY_CLUSTER_COUNT = "cluster_count"
const val KEY_FACE_COUNT = "face_count"
const val KEY_RESULT_JSON = "result_json"
}
override suspend fun doWork(): Result = withContext(Dispatchers.Default) {
try {
// Check if we should stop (work cancelled)
if (isStopped) {
return@withContext Result.failure()
}
withContext(Dispatchers.Main) {
setProgress(
workDataOf(
KEY_PROGRESS_CURRENT to 0,
KEY_PROGRESS_TOTAL to 100,
KEY_PROGRESS_MESSAGE to "Starting discovery..."
)
)
}
// Run clustering
val result = clusteringService.discoverPeople(
onProgress = { current, total, message ->
if (!isStopped) {
kotlinx.coroutines.runBlocking {
withContext(Dispatchers.Main) {
setProgress(
workDataOf(
KEY_PROGRESS_CURRENT to current,
KEY_PROGRESS_TOTAL to total,
KEY_PROGRESS_MESSAGE to message
)
)
}
}
}
}
)
// Save result to SharedPreferences for ViewModel to read
val prefs = context.getSharedPreferences("face_clustering", Context.MODE_PRIVATE)
prefs.edit().apply {
putInt(KEY_CLUSTER_COUNT, result.clusters.size)
putInt(KEY_FACE_COUNT, result.totalFacesAnalyzed)
putLong("timestamp", System.currentTimeMillis())
// Don't serialize full result - too complex without proper setup
// Phase 2 will handle proper result persistence
apply()
}
// Success!
Result.success(
workDataOf(
KEY_CLUSTER_COUNT to result.clusters.size,
KEY_FACE_COUNT to result.totalFacesAnalyzed
)
)
} catch (e: Exception) {
// Save error state
val prefs = context.getSharedPreferences("face_clustering", Context.MODE_PRIVATE)
prefs.edit().apply {
putString("error", e.message ?: "Unknown error")
putLong("timestamp", System.currentTimeMillis())
apply()
}
Result.failure(
workDataOf("error" to (e.message ?: "Unknown error"))
)
}
}
}