Compare commits
1 Commits
faceripper
...
0afb087936
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0afb087936 |
29
.idea/deviceManager.xml
generated
29
.idea/deviceManager.xml
generated
@@ -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>
|
||||
|
||||
@@ -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()
|
||||
*/
|
||||
@@ -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
|
||||
)
|
||||
@@ -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,
|
||||
@@ -229,3 +383,73 @@ data class PhotoFaceTagEntity(
|
||||
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()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,7 +83,12 @@ 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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -176,7 +176,7 @@ class PopulateFaceDetectionCacheUseCase @Inject constructor(
|
||||
faceCount = update.faceCount
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
// Skip failed updates
|
||||
// Skip failed updates //todo
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -53,25 +53,25 @@ sealed class AppDestinations(
|
||||
// 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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 ->
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user