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"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="DeviceTable">
|
<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">
|
<option name="columnSorters">
|
||||||
<list>
|
<list>
|
||||||
<ColumnSorterState>
|
<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" />
|
||||||
|
<option value="Type" />
|
||||||
|
<option value="Type" />
|
||||||
|
<option value="Type" />
|
||||||
</list>
|
</list>
|
||||||
</option>
|
</option>
|
||||||
</component>
|
</component>
|
||||||
|
|||||||
@@ -2,32 +2,22 @@ package com.placeholder.sherpai2.data.local
|
|||||||
|
|
||||||
import androidx.room.Database
|
import androidx.room.Database
|
||||||
import androidx.room.RoomDatabase
|
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.dao.*
|
||||||
import com.placeholder.sherpai2.data.local.entity.*
|
import com.placeholder.sherpai2.data.local.entity.*
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppDatabase - Complete database for SherpAI2
|
* AppDatabase - Complete database for SherpAI2
|
||||||
*
|
*
|
||||||
* VERSION 7 - Added face detection cache to ImageEntity:
|
* VERSION 8 - PHASE 2: Multi-centroid face models + age tagging
|
||||||
* - hasFaces: Boolean?
|
* - Added PersonEntity.isChild, siblingIds, familyGroupId
|
||||||
* - faceCount: Int?
|
* - Changed FaceModelEntity.embedding → centroidsJson (multi-centroid)
|
||||||
* - facesLastDetected: Long?
|
* - Added PersonAgeTagEntity table for searchable age tags
|
||||||
* - faceDetectionVersion: Int?
|
|
||||||
*
|
*
|
||||||
* ENTITIES:
|
* MIGRATION STRATEGY:
|
||||||
* - YOUR EXISTING: Image, Tag, Event, junction tables
|
* - Development: fallbackToDestructiveMigration (fresh install)
|
||||||
* - NEW: PersonEntity (people in your app)
|
* - Production: Add MIGRATION_7_8 before release
|
||||||
* - 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)
|
|
||||||
*/
|
*/
|
||||||
@Database(
|
@Database(
|
||||||
entities = [
|
entities = [
|
||||||
@@ -42,16 +32,16 @@ import com.placeholder.sherpai2.data.local.entity.*
|
|||||||
PersonEntity::class,
|
PersonEntity::class,
|
||||||
FaceModelEntity::class,
|
FaceModelEntity::class,
|
||||||
PhotoFaceTagEntity::class,
|
PhotoFaceTagEntity::class,
|
||||||
|
PersonAgeTagEntity::class, // NEW: Age tagging
|
||||||
|
|
||||||
// ===== COLLECTIONS =====
|
// ===== COLLECTIONS =====
|
||||||
CollectionEntity::class,
|
CollectionEntity::class,
|
||||||
CollectionImageEntity::class,
|
CollectionImageEntity::class,
|
||||||
CollectionFilterEntity::class
|
CollectionFilterEntity::class
|
||||||
],
|
],
|
||||||
version = 7, // INCREMENTED for face detection cache
|
version = 8, // INCREMENTED for Phase 2
|
||||||
exportSchema = false
|
exportSchema = false
|
||||||
)
|
)
|
||||||
// No TypeConverters needed - embeddings stored as strings
|
|
||||||
abstract class AppDatabase : RoomDatabase() {
|
abstract class AppDatabase : RoomDatabase() {
|
||||||
|
|
||||||
// ===== CORE DAOs =====
|
// ===== CORE DAOs =====
|
||||||
@@ -66,33 +56,111 @@ abstract class AppDatabase : RoomDatabase() {
|
|||||||
abstract fun personDao(): PersonDao
|
abstract fun personDao(): PersonDao
|
||||||
abstract fun faceModelDao(): FaceModelDao
|
abstract fun faceModelDao(): FaceModelDao
|
||||||
abstract fun photoFaceTagDao(): PhotoFaceTagDao
|
abstract fun photoFaceTagDao(): PhotoFaceTagDao
|
||||||
|
abstract fun personAgeTagDao(): PersonAgeTagDao // NEW
|
||||||
|
|
||||||
// ===== COLLECTIONS DAO =====
|
// ===== COLLECTIONS DAO =====
|
||||||
abstract fun collectionDao(): CollectionDao
|
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) {
|
* Before shipping to users, update DatabaseModule to use migration:
|
||||||
* 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")
|
|
||||||
*
|
*
|
||||||
* // Create indices
|
* Room.databaseBuilder(context, AppDatabase::class.java, "sherpai.db")
|
||||||
* database.execSQL("CREATE INDEX IF NOT EXISTS index_images_hasFaces ON images(hasFaces)")
|
* .addMigrations(MIGRATION_7_8) // Add this
|
||||||
* 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
|
|
||||||
* // .fallbackToDestructiveMigration() // Remove this
|
* // .fallbackToDestructiveMigration() // Remove this
|
||||||
* .build()
|
* .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.ForeignKey
|
||||||
import androidx.room.Index
|
import androidx.room.Index
|
||||||
import androidx.room.PrimaryKey
|
import androidx.room.PrimaryKey
|
||||||
|
import org.json.JSONArray
|
||||||
|
import org.json.JSONObject
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* PersonEntity - NO DEFAULT VALUES for KSP compatibility
|
* PersonEntity - ENHANCED with child tracking and sibling relationships
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "persons",
|
tableName = "persons",
|
||||||
indices = [Index(value = ["name"])]
|
indices = [
|
||||||
|
Index(value = ["name"]),
|
||||||
|
Index(value = ["familyGroupId"])
|
||||||
|
]
|
||||||
)
|
)
|
||||||
data class PersonEntity(
|
data class PersonEntity(
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
@ColumnInfo(name = "id")
|
@ColumnInfo(name = "id")
|
||||||
val id: String, // ← No default
|
val id: String,
|
||||||
|
|
||||||
@ColumnInfo(name = "name")
|
@ColumnInfo(name = "name")
|
||||||
val name: String,
|
val name: String,
|
||||||
@@ -25,26 +30,48 @@ data class PersonEntity(
|
|||||||
@ColumnInfo(name = "dateOfBirth")
|
@ColumnInfo(name = "dateOfBirth")
|
||||||
val dateOfBirth: Long?,
|
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")
|
@ColumnInfo(name = "relationship")
|
||||||
val relationship: String?,
|
val relationship: String?,
|
||||||
|
|
||||||
@ColumnInfo(name = "createdAt")
|
@ColumnInfo(name = "createdAt")
|
||||||
val createdAt: Long, // ← No default
|
val createdAt: Long,
|
||||||
|
|
||||||
@ColumnInfo(name = "updatedAt")
|
@ColumnInfo(name = "updatedAt")
|
||||||
val updatedAt: Long // ← No default
|
val updatedAt: Long
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
fun create(
|
fun create(
|
||||||
name: String,
|
name: String,
|
||||||
dateOfBirth: Long? = null,
|
dateOfBirth: Long? = null,
|
||||||
|
isChild: Boolean = false,
|
||||||
|
siblingIds: List<String> = emptyList(),
|
||||||
relationship: String? = null
|
relationship: String? = null
|
||||||
): PersonEntity {
|
): PersonEntity {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
|
||||||
|
// Create family group if siblings exist
|
||||||
|
val familyGroupId = if (siblingIds.isNotEmpty()) {
|
||||||
|
UUID.randomUUID().toString()
|
||||||
|
} else null
|
||||||
|
|
||||||
return PersonEntity(
|
return PersonEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
name = name,
|
name = name,
|
||||||
dateOfBirth = dateOfBirth,
|
dateOfBirth = dateOfBirth,
|
||||||
|
isChild = isChild,
|
||||||
|
siblingIds = if (siblingIds.isNotEmpty()) {
|
||||||
|
JSONArray(siblingIds).toString()
|
||||||
|
} else null,
|
||||||
|
familyGroupId = familyGroupId,
|
||||||
relationship = relationship,
|
relationship = relationship,
|
||||||
createdAt = now,
|
createdAt = now,
|
||||||
updatedAt = 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? {
|
fun getAge(): Int? {
|
||||||
if (dateOfBirth == null) return null
|
if (dateOfBirth == null) return null
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
@@ -74,7 +112,7 @@ data class PersonEntity(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* FaceModelEntity - NO DEFAULT VALUES
|
* FaceModelEntity - MULTI-CENTROID support for temporal tracking
|
||||||
*/
|
*/
|
||||||
@Entity(
|
@Entity(
|
||||||
tableName = "face_models",
|
tableName = "face_models",
|
||||||
@@ -91,13 +129,13 @@ data class PersonEntity(
|
|||||||
data class FaceModelEntity(
|
data class FaceModelEntity(
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
@ColumnInfo(name = "id")
|
@ColumnInfo(name = "id")
|
||||||
val id: String, // ← No default
|
val id: String,
|
||||||
|
|
||||||
@ColumnInfo(name = "personId")
|
@ColumnInfo(name = "personId")
|
||||||
val personId: String,
|
val personId: String,
|
||||||
|
|
||||||
@ColumnInfo(name = "embedding")
|
@ColumnInfo(name = "centroidsJson")
|
||||||
val embedding: String,
|
val centroidsJson: String, // NEW: List<TemporalCentroid> as JSON
|
||||||
|
|
||||||
@ColumnInfo(name = "trainingImageCount")
|
@ColumnInfo(name = "trainingImageCount")
|
||||||
val trainingImageCount: Int,
|
val trainingImageCount: Int,
|
||||||
@@ -106,10 +144,10 @@ data class FaceModelEntity(
|
|||||||
val averageConfidence: Float,
|
val averageConfidence: Float,
|
||||||
|
|
||||||
@ColumnInfo(name = "createdAt")
|
@ColumnInfo(name = "createdAt")
|
||||||
val createdAt: Long, // ← No default
|
val createdAt: Long,
|
||||||
|
|
||||||
@ColumnInfo(name = "updatedAt")
|
@ColumnInfo(name = "updatedAt")
|
||||||
val updatedAt: Long, // ← No default
|
val updatedAt: Long,
|
||||||
|
|
||||||
@ColumnInfo(name = "lastUsed")
|
@ColumnInfo(name = "lastUsed")
|
||||||
val lastUsed: Long?,
|
val lastUsed: Long?,
|
||||||
@@ -118,17 +156,42 @@ data class FaceModelEntity(
|
|||||||
val isActive: Boolean
|
val isActive: Boolean
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
|
/**
|
||||||
|
* Backwards compatible create() method
|
||||||
|
* Used by existing FaceRecognitionRepository code
|
||||||
|
*/
|
||||||
fun create(
|
fun create(
|
||||||
personId: String,
|
personId: String,
|
||||||
embeddingArray: FloatArray,
|
embeddingArray: FloatArray,
|
||||||
trainingImageCount: Int,
|
trainingImageCount: Int,
|
||||||
averageConfidence: Float
|
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 {
|
): FaceModelEntity {
|
||||||
val now = System.currentTimeMillis()
|
val now = System.currentTimeMillis()
|
||||||
|
val centroid = TemporalCentroid(
|
||||||
|
embedding = embeddingArray.toList(),
|
||||||
|
effectiveTimestamp = now,
|
||||||
|
ageAtCapture = null,
|
||||||
|
photoCount = trainingImageCount,
|
||||||
|
timeRangeMonths = 12,
|
||||||
|
avgConfidence = averageConfidence
|
||||||
|
)
|
||||||
|
|
||||||
return FaceModelEntity(
|
return FaceModelEntity(
|
||||||
id = UUID.randomUUID().toString(),
|
id = UUID.randomUUID().toString(),
|
||||||
personId = personId,
|
personId = personId,
|
||||||
embedding = embeddingArray.joinToString(","),
|
centroidsJson = serializeCentroids(listOf(centroid)),
|
||||||
trainingImageCount = trainingImageCount,
|
trainingImageCount = trainingImageCount,
|
||||||
averageConfidence = averageConfidence,
|
averageConfidence = averageConfidence,
|
||||||
createdAt = now,
|
createdAt = now,
|
||||||
@@ -137,15 +200,106 @@ data class FaceModelEntity(
|
|||||||
isActive = true
|
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 {
|
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(
|
@Entity(
|
||||||
tableName = "photo_face_tags",
|
tableName = "photo_face_tags",
|
||||||
@@ -172,7 +326,7 @@ data class FaceModelEntity(
|
|||||||
data class PhotoFaceTagEntity(
|
data class PhotoFaceTagEntity(
|
||||||
@PrimaryKey
|
@PrimaryKey
|
||||||
@ColumnInfo(name = "id")
|
@ColumnInfo(name = "id")
|
||||||
val id: String, // ← No default
|
val id: String,
|
||||||
|
|
||||||
@ColumnInfo(name = "imageId")
|
@ColumnInfo(name = "imageId")
|
||||||
val imageId: String,
|
val imageId: String,
|
||||||
@@ -190,7 +344,7 @@ data class PhotoFaceTagEntity(
|
|||||||
val embedding: String,
|
val embedding: String,
|
||||||
|
|
||||||
@ColumnInfo(name = "detectedAt")
|
@ColumnInfo(name = "detectedAt")
|
||||||
val detectedAt: Long, // ← No default
|
val detectedAt: Long,
|
||||||
|
|
||||||
@ColumnInfo(name = "verifiedByUser")
|
@ColumnInfo(name = "verifiedByUser")
|
||||||
val verifiedByUser: Boolean,
|
val verifiedByUser: Boolean,
|
||||||
@@ -229,3 +383,73 @@ data class PhotoFaceTagEntity(
|
|||||||
return embedding.split(",").map { it.toFloat() }.toFloatArray()
|
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 android.content.Context
|
||||||
import androidx.room.Room
|
import androidx.room.Room
|
||||||
import com.placeholder.sherpai2.data.local.AppDatabase
|
import com.placeholder.sherpai2.data.local.AppDatabase
|
||||||
|
import com.placeholder.sherpai2.data.local.MIGRATION_7_8
|
||||||
import com.placeholder.sherpai2.data.local.dao.*
|
import com.placeholder.sherpai2.data.local.dao.*
|
||||||
import dagger.Module
|
import dagger.Module
|
||||||
import dagger.Provides
|
import dagger.Provides
|
||||||
@@ -14,9 +15,9 @@ import javax.inject.Singleton
|
|||||||
/**
|
/**
|
||||||
* DatabaseModule - Provides database and ALL DAOs
|
* DatabaseModule - Provides database and ALL DAOs
|
||||||
*
|
*
|
||||||
* DEVELOPMENT CONFIGURATION:
|
* PHASE 2 UPDATES:
|
||||||
* - fallbackToDestructiveMigration enabled
|
* - Added PersonAgeTagDao
|
||||||
* - No migrations required
|
* - Added migration v7→v8 (commented out for development)
|
||||||
*/
|
*/
|
||||||
@Module
|
@Module
|
||||||
@InstallIn(SingletonComponent::class)
|
@InstallIn(SingletonComponent::class)
|
||||||
@@ -34,7 +35,12 @@ object DatabaseModule {
|
|||||||
AppDatabase::class.java,
|
AppDatabase::class.java,
|
||||||
"sherpai.db"
|
"sherpai.db"
|
||||||
)
|
)
|
||||||
|
// DEVELOPMENT MODE: Destructive migration (fresh install on schema change)
|
||||||
.fallbackToDestructiveMigration()
|
.fallbackToDestructiveMigration()
|
||||||
|
|
||||||
|
// PRODUCTION MODE: Uncomment this and remove fallbackToDestructiveMigration()
|
||||||
|
// .addMigrations(MIGRATION_7_8)
|
||||||
|
|
||||||
.build()
|
.build()
|
||||||
|
|
||||||
// ===== CORE DAOs =====
|
// ===== CORE DAOs =====
|
||||||
@@ -77,7 +83,12 @@ object DatabaseModule {
|
|||||||
fun providePhotoFaceTagDao(db: AppDatabase): PhotoFaceTagDao =
|
fun providePhotoFaceTagDao(db: AppDatabase): PhotoFaceTagDao =
|
||||||
db.photoFaceTagDao()
|
db.photoFaceTagDao()
|
||||||
|
|
||||||
|
@Provides
|
||||||
|
fun providePersonAgeTagDao(db: AppDatabase): PersonAgeTagDao = // NEW
|
||||||
|
db.personAgeTagDao()
|
||||||
|
|
||||||
// ===== COLLECTIONS DAOs =====
|
// ===== COLLECTIONS DAOs =====
|
||||||
|
|
||||||
@Provides
|
@Provides
|
||||||
fun provideCollectionDao(db: AppDatabase): CollectionDao =
|
fun provideCollectionDao(db: AppDatabase): CollectionDao =
|
||||||
db.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
|
faceCount = update.faceCount
|
||||||
)
|
)
|
||||||
} catch (e: Exception) {
|
} 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
|
// 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(
|
data object Inventory : AppDestinations(
|
||||||
route = AppRoutes.INVENTORY,
|
route = AppRoutes.INVENTORY,
|
||||||
icon = Icons.Default.Face,
|
icon = Icons.Default.Face,
|
||||||
label = "People Models",
|
label = "People",
|
||||||
description = "Existing Face Detection Models"
|
description = "Manage recognized people"
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Train : AppDestinations(
|
data object Train : AppDestinations(
|
||||||
route = AppRoutes.TRAIN,
|
route = AppRoutes.TRAIN,
|
||||||
icon = Icons.Default.ModelTraining,
|
icon = Icons.Default.ModelTraining,
|
||||||
label = "Create Model",
|
label = "Train Model",
|
||||||
description = "Create a new Person Model"
|
description = "Create a new person model"
|
||||||
)
|
|
||||||
|
|
||||||
data object Models : AppDestinations(
|
|
||||||
route = AppRoutes.MODELS,
|
|
||||||
icon = Icons.Default.SmartToy,
|
|
||||||
label = "Generative",
|
|
||||||
description = "AI Creation"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// ==================
|
// ==================
|
||||||
@@ -117,9 +117,9 @@ val photoDestinations = listOf(
|
|||||||
|
|
||||||
// Face recognition section
|
// Face recognition section
|
||||||
val faceRecognitionDestinations = listOf(
|
val faceRecognitionDestinations = listOf(
|
||||||
|
AppDestinations.Discover, // ✨ NEW: Auto-cluster discovery
|
||||||
AppDestinations.Inventory,
|
AppDestinations.Inventory,
|
||||||
AppDestinations.Train,
|
AppDestinations.Train
|
||||||
AppDestinations.Models
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Organization section
|
// Organization section
|
||||||
@@ -145,9 +145,9 @@ fun getDestinationByRoute(route: String?): AppDestinations? {
|
|||||||
AppRoutes.SEARCH -> AppDestinations.Search
|
AppRoutes.SEARCH -> AppDestinations.Search
|
||||||
AppRoutes.EXPLORE -> AppDestinations.Explore
|
AppRoutes.EXPLORE -> AppDestinations.Explore
|
||||||
AppRoutes.COLLECTIONS -> AppDestinations.Collections
|
AppRoutes.COLLECTIONS -> AppDestinations.Collections
|
||||||
|
AppRoutes.DISCOVER -> AppDestinations.Discover
|
||||||
AppRoutes.INVENTORY -> AppDestinations.Inventory
|
AppRoutes.INVENTORY -> AppDestinations.Inventory
|
||||||
AppRoutes.TRAIN -> AppDestinations.Train
|
AppRoutes.TRAIN -> AppDestinations.Train
|
||||||
AppRoutes.MODELS -> AppDestinations.Models
|
|
||||||
AppRoutes.TAGS -> AppDestinations.Tags
|
AppRoutes.TAGS -> AppDestinations.Tags
|
||||||
AppRoutes.UTILITIES -> AppDestinations.UTILITIES
|
AppRoutes.UTILITIES -> AppDestinations.UTILITIES
|
||||||
AppRoutes.SETTINGS -> AppDestinations.Settings
|
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.album.AlbumViewModel
|
||||||
import com.placeholder.sherpai2.ui.collections.CollectionsScreen
|
import com.placeholder.sherpai2.ui.collections.CollectionsScreen
|
||||||
import com.placeholder.sherpai2.ui.collections.CollectionsViewModel
|
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.explore.ExploreScreen
|
||||||
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
|
import com.placeholder.sherpai2.ui.imagedetail.ImageDetailScreen
|
||||||
import com.placeholder.sherpai2.ui.modelinventory.PersonInventoryScreen
|
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 com.placeholder.sherpai2.ui.utilities.PhotoUtilitiesScreen
|
||||||
import java.net.URLDecoder
|
import java.net.URLDecoder
|
||||||
import java.net.URLEncoder
|
import java.net.URLEncoder
|
||||||
|
import com.placeholder.sherpai2.ui.navigation.AppRoutes
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AppNavHost - UPDATED with TrainingPhotoSelector integration
|
* AppNavHost - UPDATED with Discover People screen
|
||||||
*
|
*
|
||||||
* Changes:
|
* NEW: Replaces placeholder "Models" screen with auto-clustering face discovery
|
||||||
* - 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
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun AppNavHost(
|
fun AppNavHost(
|
||||||
@@ -185,6 +183,22 @@ fun AppNavHost(
|
|||||||
// FACE RECOGNITION SYSTEM
|
// 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
|
* PERSON INVENTORY SCREEN
|
||||||
*/
|
*/
|
||||||
@@ -197,7 +211,7 @@ fun AppNavHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TRAINING FLOW - UPDATED with TrainingPhotoSelector
|
* TRAINING FLOW - Manual training (still available)
|
||||||
*/
|
*/
|
||||||
composable(AppRoutes.TRAIN) { entry ->
|
composable(AppRoutes.TRAIN) { entry ->
|
||||||
val trainViewModel: TrainViewModel = hiltViewModel()
|
val trainViewModel: TrainViewModel = hiltViewModel()
|
||||||
@@ -235,15 +249,7 @@ fun AppNavHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* TRAINING PHOTO SELECTOR - NEW: Custom gallery with face filtering
|
* TRAINING PHOTO SELECTOR - 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!
|
|
||||||
*/
|
*/
|
||||||
composable(AppRoutes.TRAINING_PHOTO_SELECTOR) {
|
composable(AppRoutes.TRAINING_PHOTO_SELECTOR) {
|
||||||
TrainingPhotoSelectorScreen(
|
TrainingPhotoSelectorScreen(
|
||||||
@@ -261,12 +267,12 @@ fun AppNavHost(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* MODELS SCREEN
|
* MODELS SCREEN - DEPRECATED, kept for backwards compat
|
||||||
*/
|
*/
|
||||||
composable(AppRoutes.MODELS) {
|
composable(AppRoutes.MODELS) {
|
||||||
DummyScreen(
|
DummyScreen(
|
||||||
title = "AI Models",
|
title = "AI Models",
|
||||||
subtitle = "Manage face recognition models"
|
subtitle = "Use 'Discover' instead"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,9 +17,10 @@ object AppRoutes {
|
|||||||
const val IMAGE_DETAIL = "IMAGE_DETAIL"
|
const val IMAGE_DETAIL = "IMAGE_DETAIL"
|
||||||
|
|
||||||
// Face recognition
|
// Face recognition
|
||||||
|
const val DISCOVER = "discover" // ✨ NEW: Auto-cluster face discovery
|
||||||
const val INVENTORY = "inv"
|
const val INVENTORY = "inv"
|
||||||
const val TRAIN = "train"
|
const val TRAIN = "train"
|
||||||
const val MODELS = "models"
|
const val MODELS = "models" // DEPRECATED - kept for reference only
|
||||||
|
|
||||||
// Organization
|
// Organization
|
||||||
const val TAGS = "tags"
|
const val TAGS = "tags"
|
||||||
@@ -30,7 +31,7 @@ object AppRoutes {
|
|||||||
|
|
||||||
// Internal training flow screens
|
// Internal training flow screens
|
||||||
const val IMAGE_SELECTOR = "Image Selection" // DEPRECATED - kept for reference only
|
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 CROP_SCREEN = "CROP_SCREEN"
|
||||||
const val TRAINING_SCREEN = "TRAINING_SCREEN"
|
const val TRAINING_SCREEN = "TRAINING_SCREEN"
|
||||||
const val ScanResultsScreen = "First Scan Results"
|
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
|
* 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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -109,7 +109,7 @@ fun AppDrawerContent(
|
|||||||
val photoItems = listOf(
|
val photoItems = listOf(
|
||||||
DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search),
|
DrawerItem(AppRoutes.SEARCH, "Search", Icons.Default.Search),
|
||||||
DrawerItem(AppRoutes.EXPLORE, "Explore", Icons.Default.Explore),
|
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 ->
|
photoItems.forEach { item ->
|
||||||
@@ -126,9 +126,9 @@ fun AppDrawerContent(
|
|||||||
DrawerSection(title = "Face Recognition")
|
DrawerSection(title = "Face Recognition")
|
||||||
|
|
||||||
val faceItems = listOf(
|
val faceItems = listOf(
|
||||||
|
DrawerItem(AppRoutes.DISCOVER, "Discover", Icons.Default.AutoAwesome), // ✨ UPDATED!
|
||||||
DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face),
|
DrawerItem(AppRoutes.INVENTORY, "People", Icons.Default.Face),
|
||||||
DrawerItem(AppRoutes.TRAIN, "Create Person", Icons.Default.ModelTraining),
|
DrawerItem(AppRoutes.TRAIN, "Train Model", Icons.Default.ModelTraining)
|
||||||
DrawerItem(AppRoutes.MODELS, "Models", Icons.Default.SmartToy)
|
|
||||||
)
|
)
|
||||||
|
|
||||||
faceItems.forEach { item ->
|
faceItems.forEach { item ->
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import com.placeholder.sherpai2.ui.navigation.AppRoutes
|
|||||||
import kotlinx.coroutines.launch
|
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)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
@@ -98,7 +98,6 @@ fun MainScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// NOTE: Removed TAGS action - TagManagementScreen has its own inline FAB
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
colors = TopAppBarDefaults.topAppBarColors(
|
colors = TopAppBarDefaults.topAppBarColors(
|
||||||
@@ -109,7 +108,6 @@ fun MainScreen() {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
// NOTE: NO floatingActionButton here - individual screens manage their own FABs inline
|
|
||||||
) { paddingValues ->
|
) { paddingValues ->
|
||||||
AppNavHost(
|
AppNavHost(
|
||||||
navController = navController,
|
navController = navController,
|
||||||
@@ -126,10 +124,11 @@ private fun getScreenTitle(route: String): String {
|
|||||||
return when (route) {
|
return when (route) {
|
||||||
AppRoutes.SEARCH -> "Search"
|
AppRoutes.SEARCH -> "Search"
|
||||||
AppRoutes.EXPLORE -> "Explore"
|
AppRoutes.EXPLORE -> "Explore"
|
||||||
AppRoutes.COLLECTIONS -> "Collections" // NEW!
|
AppRoutes.COLLECTIONS -> "Collections"
|
||||||
|
AppRoutes.DISCOVER -> "Discover People" // ✨ NEW!
|
||||||
AppRoutes.INVENTORY -> "People"
|
AppRoutes.INVENTORY -> "People"
|
||||||
AppRoutes.TRAIN -> "Train New Person"
|
AppRoutes.TRAIN -> "Train New Person"
|
||||||
AppRoutes.MODELS -> "AI Models"
|
AppRoutes.MODELS -> "AI Models" // Deprecated, but keep for backwards compat
|
||||||
AppRoutes.TAGS -> "Tag Management"
|
AppRoutes.TAGS -> "Tag Management"
|
||||||
AppRoutes.UTILITIES -> "Photo Util."
|
AppRoutes.UTILITIES -> "Photo Util."
|
||||||
AppRoutes.SETTINGS -> "Settings"
|
AppRoutes.SETTINGS -> "Settings"
|
||||||
@@ -144,7 +143,8 @@ private fun getScreenSubtitle(route: String): String? {
|
|||||||
return when (route) {
|
return when (route) {
|
||||||
AppRoutes.SEARCH -> "Find photos by tags, people, or date"
|
AppRoutes.SEARCH -> "Find photos by tags, people, or date"
|
||||||
AppRoutes.EXPLORE -> "Browse your collection"
|
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.INVENTORY -> "Trained face models"
|
||||||
AppRoutes.TRAIN -> "Add a new person to recognize"
|
AppRoutes.TRAIN -> "Add a new person to recognize"
|
||||||
AppRoutes.TAGS -> "Organize your photo collection"
|
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