Commit 906306c39e98b0de87d12ca035b46d3a1351f366

Authored by yibo
1 parent 6ea7f30b

feat(lifelog/photolog): v0 引擎+管线 — 端侧人脸识别 + Qwen-VL 描述

以「人」为中心融合身份的相册基础(声纹那套的人脸版):
- data:persons/photos/faces 三表 + 增量迁移 v3→v4(DDL 对过 Room,真机零数据损失)
- vision/FaceEngine:ML Kit 检测+5点 → 相似变换对齐 → TFLite MobileFaceNet 512维
  embedding(同人 0.976/异人 0.127,embed 44ms)。**不用 onnxruntime-android**——
  它和 sherpa 自带 libonnxruntime.so(自定义符号版本)无法共存,会弄坏音频
- vision/VisionClient:经 llama-swap 8080 的 vl 端点(Qwen2.5-VL-3B)出场景描述,只描场景不报人名
- vision/PhotoProcessor:人脸+embedding+VL+EXIF → 更新 photos 行 + 写 faces + 匹配 Person
- vision/PhotoWorker + PhotoScheduler:导入只瞬时登记,重活交后台 worker 定时/闲时处理
  (15min periodic + 导入触发 runNow,单轮 7min 预算,防并发),不阻塞导入
- vision/FaceMatcher:标注一张脸 → 回填其它照片同一人(同声纹回填)
- ImageUtil:EXIF 时间/GPS、按方向解码、降 512px 喂 VL、embedding 打包
- 模型 w600k_mbf.tflite(6.5MB) 进 assets(实测还原 onnx cos=1.0)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/app/build.gradle.kts
... ... @@ -48,8 +48,9 @@ android {
48 48 }
49 49  
50 50 androidResources {
51   - // Keep ONNX models uncompressed so sherpa-onnx can mmap them directly from assets.
  51 + // 模型保持不压缩,sherpa-onnx / TFLite 才能直接从 assets mmap。
52 52 noCompress.add("onnx")
  53 + noCompress.add("tflite")
53 54 }
54 55  
55 56 buildTypes {
... ... @@ -103,4 +104,14 @@ dependencies {
103 104 implementation(libs.work.runtime.ktx)
104 105 implementation(libs.okhttp)
105 106 implementation(libs.coroutines.android)
  107 +
  108 + // 人脸:ML Kit 检测+5关键点(端侧,与 sherpa 的 onnxruntime 零冲突)
  109 + // + TFLite MobileFaceNet(ArcFace) embedding。不能用 onnxruntime-android——
  110 + // 它和 sherpa 自带的 libonnxruntime.so(自定义符号版本)无法共存。
  111 + implementation("com.google.mlkit:face-detection:16.1.7")
  112 + implementation("org.tensorflow:tensorflow-lite:2.16.1")
  113 + // EXIF:照片拍摄时间 + GPS。
  114 + implementation("androidx.exifinterface:exifinterface:1.3.7")
  115 + // 相册图片异步加载/缓存。
  116 + implementation("io.coil-kt:coil-compose:2.7.0")
106 117 }
... ...
lifelog-recorder/app/src/main/assets/face/w600k_mbf.tflite 0 → 100644
No preview for this file type
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/LifelogApp.kt
... ... @@ -21,5 +21,7 @@ class LifelogApp : Application() {
21 21 // 纪要 + diarization periodic worker(各自 15min,UPDATE)。
22 22 SummaryScheduler.schedulePeriodic(this)
23 23 DiarizationScheduler.schedulePeriodic(this)
  24 + // 相册:未处理照片的周期后台处理(人脸+VL),空闲时跑,不阻塞导入。
  25 + com.lemon.lifelog.vision.PhotoScheduler.schedulePeriodic(this)
24 26 }
25 27 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
... ... @@ -16,8 +16,11 @@ import androidx.sqlite.db.SupportSQLiteDatabase
16 16 TranscriptFts::class,
17 17 ChatConversation::class,
18 18 ChatMessage::class,
  19 + Person::class,
  20 + Photo::class,
  21 + Face::class,
19 22 ],
20   - version = 3,
  23 + version = 4,
21 24 exportSchema = false,
22 25 )
23 26 abstract class AppDatabase : RoomDatabase() {
... ... @@ -26,6 +29,9 @@ abstract class AppDatabase : RoomDatabase() {
26 29 abstract fun summaryDao(): SummaryDao
27 30 abstract fun speakerDao(): SpeakerDao
28 31 abstract fun chatDao(): ChatDao
  32 + abstract fun personDao(): PersonDao
  33 + abstract fun photoDao(): PhotoDao
  34 + abstract fun faceDao(): FaceDao
29 35  
30 36 companion object {
31 37 @Volatile
... ... @@ -43,6 +49,21 @@ abstract class AppDatabase : RoomDatabase() {
43 49 }
44 50 }
45 51  
  52 + /** v0.7 photolog v0:新增 persons / photos / faces 三表。纯增量。 */
  53 + private val MIGRATION_3_4 = object : Migration(3, 4) {
  54 + override fun migrate(db: SupportSQLiteDatabase) {
  55 + db.execSQL(
  56 + "CREATE TABLE IF NOT EXISTS `persons` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `voiceSpeakerId` INTEGER, `contactRef` TEXT, `avatarPath` TEXT, `createdAt` INTEGER NOT NULL)",
  57 + )
  58 + db.execSQL(
  59 + "CREATE TABLE IF NOT EXISTS `photos` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `path` TEXT NOT NULL, `takenTs` INTEGER NOT NULL, `lat` REAL, `lon` REAL, `caption` TEXT, `captionModel` TEXT, `faceCount` INTEGER NOT NULL, `processedAt` INTEGER)",
  60 + )
  61 + db.execSQL(
  62 + "CREATE TABLE IF NOT EXISTS `faces` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `photoId` INTEGER NOT NULL, `left` REAL NOT NULL, `top` REAL NOT NULL, `right` REAL NOT NULL, `bottom` REAL NOT NULL, `detScore` REAL NOT NULL, `embedding` BLOB, `personId` INTEGER, `personSim` REAL)",
  63 + )
  64 + }
  65 + }
  66 +
46 67 fun get(ctx: Context): AppDatabase =
47 68 instance ?: synchronized(this) {
48 69 instance ?: Room.databaseBuilder(
... ... @@ -50,7 +71,7 @@ abstract class AppDatabase : RoomDatabase() {
50 71 AppDatabase::class.java,
51 72 "lifelog.db",
52 73 )
53   - .addMigrations(MIGRATION_2_3)
  74 + .addMigrations(MIGRATION_2_3, MIGRATION_3_4)
54 75 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
55 76 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
56 77 .build()
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -38,6 +38,10 @@ interface SegmentDao {
38 38 @Query("UPDATE segments SET speakerId = :speakerId, speakerSim = :sim WHERE id = :id")
39 39 suspend fun updateSpeaker(id: Long, speakerId: Long?, sim: Float?)
40 40  
  41 + /** diarization 用干净的 dominant-turn embedding 覆盖实时的混合 embedding。 */
  42 + @Query("UPDATE segments SET embedding = :embedding WHERE id = :id")
  43 + suspend fun updateEmbedding(id: Long, embedding: ByteArray)
  44 +
41 45 @Query("DELETE FROM segments WHERE id = :id")
42 46 suspend fun deleteSegment(id: Long)
43 47  
... ... @@ -192,6 +196,122 @@ interface SummaryDao {
192 196 suspend fun delete(id: Long)
193 197 }
194 198  
  199 +/** 照片网格/人物页用的投影行。 */
  200 +data class PhotoRow(
  201 + val id: Long,
  202 + val path: String,
  203 + val takenTs: Long,
  204 + val caption: String?,
  205 + val faceCount: Int,
  206 + val lat: Double?,
  207 + val lon: Double?,
  208 +)
  209 +
  210 +@Dao
  211 +interface PersonDao {
  212 + @Insert
  213 + suspend fun insert(p: Person): Long
  214 +
  215 + @Query("SELECT * FROM persons ORDER BY name ASC")
  216 + fun observeAll(): Flow<List<Person>>
  217 +
  218 + @Query("SELECT * FROM persons ORDER BY name ASC")
  219 + suspend fun all(): List<Person>
  220 +
  221 + @Query("SELECT * FROM persons WHERE id = :id")
  222 + suspend fun get(id: Long): Person?
  223 +
  224 + @Query("UPDATE persons SET name = :name WHERE id = :id")
  225 + suspend fun updateName(id: Long, name: String)
  226 +
  227 + /** 融合:把一个声纹 SpeakerProfile 绑到这个人。 */
  228 + @Query("UPDATE persons SET voiceSpeakerId = :speakerId WHERE id = :id")
  229 + suspend fun linkVoice(id: Long, speakerId: Long?)
  230 +
  231 + @Query("DELETE FROM persons WHERE id = :id")
  232 + suspend fun delete(id: Long)
  233 +}
  234 +
  235 +@Dao
  236 +interface PhotoDao {
  237 + @Insert
  238 + suspend fun insert(p: Photo): Long
  239 +
  240 + @Query("SELECT * FROM photos WHERE id = :id")
  241 + suspend fun get(id: Long): Photo?
  242 +
  243 + @Query("SELECT * FROM photos WHERE path = :path LIMIT 1")
  244 + suspend fun getByPath(path: String): Photo?
  245 +
  246 + /** 还没跑过人脸+VL 的照片(处理 worker 取这个)。 */
  247 + @Query("SELECT * FROM photos WHERE processedAt IS NULL ORDER BY takenTs DESC")
  248 + suspend fun unprocessed(): List<Photo>
  249 +
  250 + /** 未处理照片数(UI 显示"待处理 N")。 */
  251 + @Query("SELECT COUNT(*) FROM photos WHERE processedAt IS NULL")
  252 + fun observeUnprocessedCount(): Flow<Int>
  253 +
  254 + @Query("UPDATE photos SET caption = :caption, captionModel = :model WHERE id = :id")
  255 + suspend fun updateCaption(id: Long, caption: String, model: String)
  256 +
  257 + @Query("UPDATE photos SET faceCount = :n, processedAt = :ts WHERE id = :id")
  258 + suspend fun markProcessed(id: Long, n: Int, ts: Long)
  259 +
  260 + @Query("UPDATE photos SET lat = :lat, lon = :lon WHERE id = :id")
  261 + suspend fun updateGeo(id: Long, lat: Double?, lon: Double?)
  262 +
  263 + @Query(
  264 + "SELECT id, path, takenTs, caption, faceCount, lat, lon FROM photos ORDER BY takenTs DESC LIMIT :limit",
  265 + )
  266 + fun observeGrid(limit: Int = 500): Flow<List<PhotoRow>>
  267 +
  268 + /** 某个人出现的所有照片(faces.personId join)。 */
  269 + @Query(
  270 + """
  271 + SELECT DISTINCT p.id AS id, p.path AS path, p.takenTs AS takenTs,
  272 + p.caption AS caption, p.faceCount AS faceCount, p.lat AS lat, p.lon AS lon
  273 + FROM photos p INNER JOIN faces f ON f.photoId = p.id
  274 + WHERE f.personId = :personId
  275 + ORDER BY p.takenTs DESC
  276 + """,
  277 + )
  278 + fun observePhotosOfPerson(personId: Long): Flow<List<PhotoRow>>
  279 +
  280 + @Query("DELETE FROM photos WHERE id = :id")
  281 + suspend fun delete(id: Long)
  282 +}
  283 +
  284 +@Dao
  285 +interface FaceDao {
  286 + @Insert
  287 + suspend fun insert(f: Face): Long
  288 +
  289 + @Query("SELECT * FROM faces WHERE photoId = :photoId")
  290 + suspend fun byPhoto(photoId: Long): List<Face>
  291 +
  292 + @Query("SELECT * FROM faces WHERE photoId = :photoId")
  293 + fun observeByPhoto(photoId: Long): Flow<List<Face>>
  294 +
  295 + /** 所有带 embedding 的脸——matcher/backfill 在 Kotlin 里做余弦(Room 算不了)。 */
  296 + @Query("SELECT * FROM faces WHERE embedding IS NOT NULL")
  297 + suspend fun allWithEmbedding(): List<Face>
  298 +
  299 + @Query("SELECT * FROM faces WHERE personId IS NULL AND embedding IS NOT NULL")
  300 + suspend fun unassigned(): List<Face>
  301 +
  302 + @Query("UPDATE faces SET personId = :personId, personSim = :sim WHERE id = :id")
  303 + suspend fun assignPerson(id: Long, personId: Long?, sim: Float?)
  304 +
  305 + @Query("SELECT COUNT(*) FROM faces WHERE personId = :personId")
  306 + suspend fun countForPerson(personId: Long): Int
  307 +
  308 + @Query("UPDATE faces SET personId = NULL, personSim = NULL WHERE personId = :personId")
  309 + suspend fun clearPersonAssignments(personId: Long)
  310 +
  311 + @Query("DELETE FROM faces WHERE photoId = :photoId")
  312 + suspend fun deleteByPhoto(photoId: Long)
  313 +}
  314 +
195 315 @Dao
196 316 interface ChatDao {
197 317 @Insert
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
... ... @@ -80,3 +80,54 @@ data class ChatMessage(
80 80 val model: String? = null,
81 81 val createdAt: Long,
82 82 )
  83 +
  84 +// ============================================================ photolog (v0)
  85 +// 以"人"为中心的身份融合:Person 绑名字 + 声纹(链 SpeakerProfile) + 联系人 + 人脸(faces.personId)。
  86 +// 跟声纹那套同形——人脸只是另一个模态的 embedding。
  87 +
  88 +/** 一个"人"。voiceSpeakerId 把声纹和人脸融合到同一身份上。 */
  89 +@Entity(tableName = "persons")
  90 +data class Person(
  91 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
  92 + val name: String,
  93 + /** 融合声纹:链到 SpeakerProfile.id,null = 还没绑声音 */
  94 + val voiceSpeakerId: Long? = null,
  95 + /** 微信/通讯录联系人引用(如 "wechat:wxid_xxx"),null = 手动建 */
  96 + val contactRef: String? = null,
  97 + val avatarPath: String? = null,
  98 + val createdAt: Long = System.currentTimeMillis(),
  99 +)
  100 +
  101 +/** 一张照片 + Qwen-VL 场景描述 + EXIF 时间/GPS。 */
  102 +@Entity(tableName = "photos")
  103 +data class Photo(
  104 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
  105 + val path: String,
  106 + /** 拍摄时间(EXIF DateTimeOriginal,回退文件时间) */
  107 + val takenTs: Long,
  108 + val lat: Double? = null,
  109 + val lon: Double? = null,
  110 + /** Qwen-VL 场景/活动描述(不含人名——人名由人脸给) */
  111 + val caption: String? = null,
  112 + val captionModel: String? = null,
  113 + val faceCount: Int = 0,
  114 + /** 人脸+VL 处理完成时间,null = 还没处理 */
  115 + val processedAt: Long? = null,
  116 +)
  117 +
  118 +/** 照片里检测到的一张脸:bbox + ArcFace embedding + 归属 Person。结构同 SpeakerProfile 的脸版。 */
  119 +@Entity(tableName = "faces")
  120 +data class Face(
  121 + @PrimaryKey(autoGenerate = true) val id: Long = 0,
  122 + val photoId: Long,
  123 + val left: Float,
  124 + val top: Float,
  125 + val right: Float,
  126 + val bottom: Float,
  127 + val detScore: Float = 0f,
  128 + /** 512维 ArcFace embedding,packed LE bytes (512*4=2048B),null = 还没算 */
  129 + val embedding: ByteArray? = null,
  130 + /** 匹配到的 Person.id,null = 未归属 / 还没匹配 */
  131 + val personId: Long? = null,
  132 + val personSim: Float? = null,
  133 +)
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Repository.kt
... ... @@ -9,4 +9,7 @@ class Repository(ctx: Context) {
9 9 val summaries = db.summaryDao()
10 10 val speakers = db.speakerDao()
11 11 val chats = db.chatDao()
  12 + val persons = db.personDao()
  13 + val photos = db.photoDao()
  14 + val faces = db.faceDao()
12 15 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/FaceEngine.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.content.Context
  4 +import android.graphics.Bitmap
  5 +import android.graphics.Canvas
  6 +import android.graphics.Matrix
  7 +import android.graphics.Paint
  8 +import com.google.android.gms.tasks.Tasks
  9 +import com.google.mlkit.vision.common.InputImage
  10 +import com.google.mlkit.vision.face.FaceDetection
  11 +import com.google.mlkit.vision.face.FaceDetector
  12 +import com.google.mlkit.vision.face.FaceDetectorOptions
  13 +import com.google.mlkit.vision.face.FaceLandmark
  14 +import org.tensorflow.lite.Interpreter
  15 +import java.io.FileInputStream
  16 +import java.nio.ByteBuffer
  17 +import java.nio.ByteOrder
  18 +import java.nio.channels.FileChannel
  19 +import kotlin.math.sqrt
  20 +
  21 +/** 一张检测到的脸:原图坐标 bbox + 5 关键点(已按图像左右排好序)。 */
  22 +data class DetectedFace(
  23 + val x1: Float, val y1: Float, val x2: Float, val y2: Float,
  24 + val score: Float,
  25 + /** 10 个 float,原图坐标,顺序:[左眼,右眼,鼻,左嘴角,右嘴角](左右=图像左右) */
  26 + val kps: FloatArray,
  27 +)
  28 +
  29 +/** 一张脸 + 它的 512 维 ArcFace embedding(已 L2 归一化)。 */
  30 +data class FaceResult(
  31 + val face: DetectedFace,
  32 + val embedding: FloatArray,
  33 +)
  34 +
  35 +/**
  36 + * On-device 人脸引擎:ML Kit 检测+5关键点 → 5点相似变换对齐到 ArcFace 112×112 模板
  37 + * → TFLite MobileFaceNet(w600k_mbf) 出 512 维 embedding。
  38 + *
  39 + * 为什么不用 onnxruntime:sherpa-onnx 自带 libonnxruntime.so 用了自定义符号版本,
  40 + * 和微软 onnxruntime-android 无法共存。ML Kit + TFLite 是独立运行时,零冲突。
  41 + *
  42 + * embedding 预处理:112×112 对齐脸, (px-127.5)/127.5, RGB, NHWC。TFLite 与 onnx 实测 cos=1.0。
  43 + */
  44 +class FaceEngine private constructor(
  45 + private val detector: FaceDetector,
  46 + private val rec: Interpreter,
  47 +) {
  48 + /** 检测 + 给每张脸算 embedding(阻塞,跑在后台线程)。 */
  49 + fun analyze(bitmap: Bitmap): List<FaceResult> =
  50 + detect(bitmap).map { FaceResult(it, embed(bitmap, it.kps)) }
  51 +
  52 + /** ML Kit 检测,返回带 5 关键点的脸。阻塞等待,调用方需在后台线程。 */
  53 + fun detect(bitmap: Bitmap): List<DetectedFace> {
  54 + val faces = Tasks.await(detector.process(InputImage.fromBitmap(bitmap, 0)))
  55 + val out = ArrayList<DetectedFace>()
  56 + for (f in faces) {
  57 + val le = f.getLandmark(FaceLandmark.LEFT_EYE)?.position
  58 + val re = f.getLandmark(FaceLandmark.RIGHT_EYE)?.position
  59 + val nose = f.getLandmark(FaceLandmark.NOSE_BASE)?.position
  60 + val ml = f.getLandmark(FaceLandmark.MOUTH_LEFT)?.position
  61 + val mr = f.getLandmark(FaceLandmark.MOUTH_RIGHT)?.position
  62 + if (le == null || re == null || nose == null || ml == null || mr == null) continue
  63 + // 按 x 排序,杜绝 ML Kit 命名(subject 左右) vs 模板(图像左右)的镜像歧义。
  64 + val eyeL = if (le.x <= re.x) le else re
  65 + val eyeR = if (le.x <= re.x) re else le
  66 + val mouthL = if (ml.x <= mr.x) ml else mr
  67 + val mouthR = if (ml.x <= mr.x) mr else ml
  68 + val b = f.boundingBox
  69 + out.add(
  70 + DetectedFace(
  71 + b.left.toFloat(), b.top.toFloat(), b.right.toFloat(), b.bottom.toFloat(),
  72 + 1f,
  73 + floatArrayOf(
  74 + eyeL.x, eyeL.y, eyeR.x, eyeR.y, nose.x, nose.y,
  75 + mouthL.x, mouthL.y, mouthR.x, mouthR.y,
  76 + ),
  77 + ),
  78 + )
  79 + }
  80 + return out
  81 + }
  82 +
  83 + /** 对齐脸 → MobileFaceNet → 512 维 L2 归一化 embedding。 */
  84 + fun embed(bitmap: Bitmap, kps: FloatArray): FloatArray {
  85 + val aligned = align112(bitmap, kps)
  86 + val input = ByteBuffer.allocateDirect(112 * 112 * 3 * 4).order(ByteOrder.nativeOrder())
  87 + val px = IntArray(112 * 112)
  88 + aligned.getPixels(px, 0, 112, 0, 0, 112, 112)
  89 + aligned.recycle()
  90 + for (p in px) {
  91 + input.putFloat((((p shr 16) and 0xFF) - 127.5f) / 127.5f) // R
  92 + input.putFloat((((p shr 8) and 0xFF) - 127.5f) / 127.5f) // G
  93 + input.putFloat(((p and 0xFF) - 127.5f) / 127.5f) // B
  94 + }
  95 + input.rewind()
  96 + val out = Array(1) { FloatArray(512) }
  97 + rec.run(input, out)
  98 + val e = out[0]
  99 + var norm = 0f
  100 + for (v in e) norm += v * v
  101 + norm = sqrt(norm)
  102 + if (norm > 0f) for (i in e.indices) e[i] /= norm
  103 + return e
  104 + }
  105 +
  106 + /** 5点相似变换对齐到 ArcFace 112×112 模板。 */
  107 + private fun align112(bitmap: Bitmap, kps: FloatArray): Bitmap {
  108 + val m = similarityTransform(kps, ARCFACE_DST)
  109 + val out = Bitmap.createBitmap(112, 112, Bitmap.Config.ARGB_8888)
  110 + val mat = Matrix()
  111 + mat.setValues(floatArrayOf(m[0], m[1], m[2], m[3], m[4], m[5], 0f, 0f, 1f))
  112 + Canvas(out).drawBitmap(bitmap, mat, Paint(Paint.FILTER_BITMAP_FLAG))
  113 + return out
  114 + }
  115 +
  116 + fun close() {
  117 + runCatching { detector.close() }
  118 + runCatching { rec.close() }
  119 + }
  120 +
  121 + companion object {
  122 + /** ArcFace 112×112 标准 5 点模板(左眼,右眼,鼻,左嘴角,右嘴角,图像左右)。 */
  123 + private val ARCFACE_DST = floatArrayOf(
  124 + 38.2946f, 51.6963f,
  125 + 73.5318f, 51.5014f,
  126 + 56.0252f, 71.7366f,
  127 + 41.5493f, 92.3655f,
  128 + 70.7299f, 92.2041f,
  129 + )
  130 +
  131 + fun load(ctx: Context): FaceEngine {
  132 + val opts = FaceDetectorOptions.Builder()
  133 + .setPerformanceMode(FaceDetectorOptions.PERFORMANCE_MODE_ACCURATE)
  134 + .setLandmarkMode(FaceDetectorOptions.LANDMARK_MODE_ALL)
  135 + .setMinFaceSize(0.05f)
  136 + .build()
  137 + val detector = FaceDetection.getClient(opts)
  138 + val model = mapAsset(ctx, "face/w600k_mbf.tflite")
  139 + val rec = Interpreter(model, Interpreter.Options().apply { numThreads = 4 })
  140 + return FaceEngine(detector, rec)
  141 + }
  142 +
  143 + /** 余弦相似度(两个已归一化向量直接点积)。 */
  144 + fun cosine(a: FloatArray, b: FloatArray): Float {
  145 + var s = 0f
  146 + for (i in a.indices) s += a[i] * b[i]
  147 + return s
  148 + }
  149 +
  150 + private fun mapAsset(ctx: Context, path: String): ByteBuffer {
  151 + val afd = ctx.assets.openFd(path)
  152 + FileInputStream(afd.fileDescriptor).use { fis ->
  153 + return fis.channel.map(FileChannel.MapMode.READ_ONLY, afd.startOffset, afd.declaredLength)
  154 + }
  155 + }
  156 + }
  157 +}
  158 +
  159 +/**
  160 + * 闭式 2D 相似变换(旋转+均匀缩放+平移,无反射) src→dst 最小二乘。
  161 + * 等价 skimage SimilarityTransform / Umeyama。返回 [a,-b,tx, b,a,ty](2×3 行优先)。
  162 + */
  163 +private fun similarityTransform(src: FloatArray, dst: FloatArray): FloatArray {
  164 + val n = 5
  165 + var sx = 0f; var sy = 0f; var dx = 0f; var dy = 0f
  166 + for (i in 0 until n) {
  167 + sx += src[i * 2]; sy += src[i * 2 + 1]
  168 + dx += dst[i * 2]; dy += dst[i * 2 + 1]
  169 + }
  170 + sx /= n; sy /= n; dx /= n; dy /= n
  171 + var num1 = 0f; var num2 = 0f; var den = 0f
  172 + for (i in 0 until n) {
  173 + val xp = src[i * 2] - sx
  174 + val yp = src[i * 2 + 1] - sy
  175 + val xP = dst[i * 2] - dx
  176 + val yP = dst[i * 2 + 1] - dy
  177 + num1 += xP * xp + yP * yp
  178 + num2 += yP * xp - xP * yp
  179 + den += xp * xp + yp * yp
  180 + }
  181 + val a = num1 / den
  182 + val b = num2 / den
  183 + val tx = dx - (a * sx - b * sy)
  184 + val ty = dy - (b * sx + a * sy)
  185 + return floatArrayOf(a, -b, tx, b, a, ty)
  186 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/FaceMatcher.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import com.lemon.lifelog.data.Repository
  4 +
  5 +/**
  6 + * 人脸归属匹配(与声纹的 SpeakerMatcher 同构)。
  7 + * 标注一张脸到某 Person 后,把所有未归属的脸跟已命名的脸库比余弦,自动回填。
  8 + */
  9 +object FaceMatcher {
  10 + private const val THRESH = PhotoProcessor.THRESH
  11 +
  12 + /** 把 [faceId] 指派给 [personId],然后回填其它未归属的脸。 */
  13 + suspend fun assignAndBackfill(repo: Repository, faceId: Long, personId: Long) {
  14 + repo.faces.assignPerson(faceId, personId, 1f)
  15 + backfillAll(repo)
  16 + }
  17 +
  18 + /**
  19 + * 对所有未归属的脸:跟已命名脸库做最近邻,余弦 ≥ THRESH 则归属。
  20 + * 反复一轮即可(新归属的脸不会再扩大已命名集合的语义)。
  21 + */
  22 + suspend fun backfillAll(repo: Repository) {
  23 + val gallery = repo.faces.allWithEmbedding()
  24 + .filter { it.personId != null && it.embedding != null }
  25 + .map { it.personId!! to ImageUtil.unpackEmbedding(it.embedding!!) }
  26 + if (gallery.isEmpty()) return
  27 + for (f in repo.faces.unassigned()) {
  28 + val emb = ImageUtil.unpackEmbedding(f.embedding!!)
  29 + var bestId: Long? = null
  30 + var bestSim = THRESH
  31 + for ((pid, g) in gallery) {
  32 + val c = FaceEngine.cosine(emb, g)
  33 + if (c > bestSim) { bestSim = c; bestId = pid }
  34 + }
  35 + if (bestId != null) repo.faces.assignPerson(f.id, bestId, bestSim)
  36 + }
  37 + }
  38 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/ImageUtil.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.graphics.Bitmap
  4 +import android.graphics.BitmapFactory
  5 +import android.graphics.Matrix
  6 +import androidx.exifinterface.media.ExifInterface
  7 +import java.io.ByteArrayOutputStream
  8 +import java.io.File
  9 +import java.nio.ByteBuffer
  10 +import java.nio.ByteOrder
  11 +import java.text.SimpleDateFormat
  12 +import java.util.Locale
  13 +import kotlin.math.min
  14 +
  15 +/** 照片 EXIF + 解码 + 缩放工具。 */
  16 +object ImageUtil {
  17 + data class Meta(val takenTs: Long, val lat: Double?, val lon: Double?)
  18 +
  19 + private val EXIF_FMT = SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.US)
  20 +
  21 + fun readMeta(path: String): Meta {
  22 + val f = File(path)
  23 + return try {
  24 + val ex = ExifInterface(path)
  25 + val ts = ex.getAttribute(ExifInterface.TAG_DATETIME_ORIGINAL)
  26 + ?.let { runCatching { EXIF_FMT.parse(it)?.time }.getOrNull() } ?: f.lastModified()
  27 + val ll = ex.latLong // DoubleArray[lat,lon] 或 null
  28 + Meta(ts, ll?.get(0), ll?.get(1))
  29 + } catch (t: Throwable) {
  30 + Meta(f.lastModified(), null, null)
  31 + }
  32 + }
  33 +
  34 + /** 解码并按 EXIF 方向摆正。可选下采样 reqMax(最长边)以省内存。 */
  35 + fun decodeOriented(path: String, reqMax: Int = 2048): Bitmap? {
  36 + val opts = BitmapFactory.Options()
  37 + opts.inJustDecodeBounds = true
  38 + BitmapFactory.decodeFile(path, opts)
  39 + var sample = 1
  40 + val longest = maxOf(opts.outWidth, opts.outHeight)
  41 + while (longest / sample > reqMax) sample *= 2
  42 + val decoded = BitmapFactory.decodeFile(path, BitmapFactory.Options().apply { inSampleSize = sample })
  43 + ?: return null
  44 + val orientation = try {
  45 + ExifInterface(path).getAttributeInt(ExifInterface.TAG_ORIENTATION, ExifInterface.ORIENTATION_NORMAL)
  46 + } catch (t: Throwable) {
  47 + ExifInterface.ORIENTATION_NORMAL
  48 + }
  49 + val m = Matrix()
  50 + when (orientation) {
  51 + ExifInterface.ORIENTATION_ROTATE_90 -> m.postRotate(90f)
  52 + ExifInterface.ORIENTATION_ROTATE_180 -> m.postRotate(180f)
  53 + ExifInterface.ORIENTATION_ROTATE_270 -> m.postRotate(270f)
  54 + else -> return decoded
  55 + }
  56 + val rotated = Bitmap.createBitmap(decoded, 0, 0, decoded.width, decoded.height, m, true)
  57 + if (rotated != decoded) decoded.recycle()
  58 + return rotated
  59 + }
  60 +
  61 + /** 把 bitmap 压成最长边 ≤ maxDim 的 JPEG(喂 VL,512 左右最快)。 */
  62 + fun toJpeg(bmp: Bitmap, maxDim: Int = 512, quality: Int = 85): ByteArray {
  63 + val scale = min(1f, maxDim.toFloat() / maxOf(bmp.width, bmp.height))
  64 + val src = if (scale < 1f) {
  65 + Bitmap.createScaledBitmap(bmp, (bmp.width * scale).toInt(), (bmp.height * scale).toInt(), true)
  66 + } else {
  67 + bmp
  68 + }
  69 + val out = ByteArrayOutputStream()
  70 + src.compress(Bitmap.CompressFormat.JPEG, quality, out)
  71 + if (src != bmp) src.recycle()
  72 + return out.toByteArray()
  73 + }
  74 +
  75 + /** 512维 float embedding ↔ LE bytes(与声纹一致的打包方式)。 */
  76 + fun packEmbedding(e: FloatArray): ByteArray {
  77 + val b = ByteBuffer.allocate(e.size * 4).order(ByteOrder.LITTLE_ENDIAN)
  78 + for (v in e) b.putFloat(v)
  79 + return b.array()
  80 + }
  81 +
  82 + fun unpackEmbedding(bytes: ByteArray): FloatArray {
  83 + val bb = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN)
  84 + return FloatArray(bytes.size / 4) { bb.float }
  85 + }
  86 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/PhotoProcessor.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.util.Log
  4 +import com.lemon.lifelog.data.Face
  5 +import com.lemon.lifelog.data.Photo
  6 +import com.lemon.lifelog.data.Repository
  7 +import kotlinx.coroutines.Dispatchers
  8 +import kotlinx.coroutines.withContext
  9 +import java.io.File
  10 +
  11 +/**
  12 + * 单张照片处理:人脸(FaceEngine) + 场景描述(VisionClient) → 更新已登记的 photos 行 + 写 faces。
  13 + * 导入时只登记 photos 行(processedAt=null);这里由后台 PhotoWorker 闲时调用,不阻塞 UI。
  14 + */
  15 +class PhotoProcessor(
  16 + private val repo: Repository,
  17 + private val face: FaceEngine,
  18 + private val vision: VisionClient,
  19 +) {
  20 + /** 处理一张已登记但未处理的照片(processedAt=null)。 */
  21 + suspend fun process(photo: Photo) {
  22 + withContext(Dispatchers.IO) {
  23 + val bmp = ImageUtil.decodeOriented(photo.path) ?: run {
  24 + Log.w(TAG, "decode failed: ${photo.path}")
  25 + repo.photos.markProcessed(photo.id, 0, System.currentTimeMillis())
  26 + return@withContext
  27 + }
  28 +
  29 + val faces = try {
  30 + face.analyze(bmp)
  31 + } catch (t: Throwable) {
  32 + Log.w(TAG, "face analyze failed: ${t.message}"); emptyList()
  33 + }
  34 + val caption = try {
  35 + vision.caption(ImageUtil.toJpeg(bmp))
  36 + } catch (t: Throwable) {
  37 + Log.w(TAG, "caption failed: ${t.message}"); null
  38 + }
  39 + bmp.recycle()
  40 +
  41 + if (caption != null) repo.photos.updateCaption(photo.id, caption, "vl")
  42 +
  43 + // 已命名 Person 的人脸样本(personId != null)→ 匹配新脸。
  44 + val gallery = repo.faces.allWithEmbedding()
  45 + .filter { it.personId != null && it.embedding != null }
  46 + .map { it.personId!! to ImageUtil.unpackEmbedding(it.embedding!!) }
  47 +
  48 + for (fr in faces) {
  49 + var bestId: Long? = null
  50 + var bestSim = THRESH
  51 + for ((pid, emb) in gallery) {
  52 + val c = FaceEngine.cosine(fr.embedding, emb)
  53 + if (c > bestSim) { bestSim = c; bestId = pid }
  54 + }
  55 + repo.faces.insert(
  56 + Face(
  57 + photoId = photo.id,
  58 + left = fr.face.x1, top = fr.face.y1, right = fr.face.x2, bottom = fr.face.y2,
  59 + detScore = fr.face.score,
  60 + embedding = ImageUtil.packEmbedding(fr.embedding),
  61 + personId = bestId,
  62 + personSim = if (bestId != null) bestSim else null,
  63 + ),
  64 + )
  65 + }
  66 + repo.photos.markProcessed(photo.id, faces.size, System.currentTimeMillis())
  67 + Log.i(TAG, "✓ ${File(photo.path).name}: ${faces.size} 脸, caption=${caption?.take(30)}")
  68 + }
  69 + }
  70 +
  71 + companion object {
  72 + private const val TAG = "PhotoProcessor"
  73 + /** 同人余弦阈值(实测同人 0.96 / 异人 0.13,取 0.45 稳)。 */
  74 + const val THRESH = 0.45f
  75 + }
  76 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/PhotoScheduler.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.content.Context
  4 +import androidx.work.BackoffPolicy
  5 +import androidx.work.Constraints
  6 +import androidx.work.ExistingPeriodicWorkPolicy
  7 +import androidx.work.ExistingWorkPolicy
  8 +import androidx.work.NetworkType
  9 +import androidx.work.OneTimeWorkRequestBuilder
  10 +import androidx.work.PeriodicWorkRequestBuilder
  11 +import androidx.work.WorkManager
  12 +import java.util.concurrent.TimeUnit
  13 +
  14 +/**
  15 + * 相册照片处理调度(与纪要 SummaryScheduler 同构):
  16 + * - schedulePeriodic:每 15min(WorkManager 硬下限)扫一遍未处理照片,闲时跑。
  17 + * - runNow:导入后触发一次,让新照片尽快在后台开始处理(不阻塞导入 UI)。
  18 + */
  19 +object PhotoScheduler {
  20 + private const val PERIODIC_TAG = "lifelog-photo-periodic"
  21 + private const val ONE_SHOT_TAG = "lifelog-photo-once"
  22 +
  23 + fun schedulePeriodic(ctx: Context) {
  24 + val constraints = Constraints.Builder()
  25 + .setRequiredNetworkType(NetworkType.NOT_REQUIRED) // VL 在本机
  26 + .setRequiresBatteryNotLow(true)
  27 + .build()
  28 + val request = PeriodicWorkRequestBuilder<PhotoWorker>(15, TimeUnit.MINUTES)
  29 + .setConstraints(constraints)
  30 + .setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
  31 + .build()
  32 + WorkManager.getInstance(ctx).enqueueUniquePeriodicWork(
  33 + PERIODIC_TAG, ExistingPeriodicWorkPolicy.UPDATE, request,
  34 + )
  35 + }
  36 +
  37 + /** 导入后调一次,让后台尽快开工。已在跑则忽略(KEEP)。 */
  38 + fun runNow(ctx: Context) {
  39 + val constraints = Constraints.Builder()
  40 + .setRequiresBatteryNotLow(true)
  41 + .build()
  42 + val request = OneTimeWorkRequestBuilder<PhotoWorker>()
  43 + .setConstraints(constraints)
  44 + .addTag(ONE_SHOT_TAG)
  45 + .setBackoffCriteria(BackoffPolicy.LINEAR, 1, TimeUnit.MINUTES)
  46 + .build()
  47 + WorkManager.getInstance(ctx).enqueueUniqueWork(
  48 + ONE_SHOT_TAG, ExistingWorkPolicy.KEEP, request,
  49 + )
  50 + }
  51 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/PhotoWorker.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.content.Context
  4 +import android.util.Log
  5 +import androidx.work.CoroutineWorker
  6 +import androidx.work.WorkerParameters
  7 +import com.lemon.lifelog.chat.ChatConfig
  8 +import com.lemon.lifelog.data.Repository
  9 +import java.util.concurrent.atomic.AtomicBoolean
  10 +
  11 +/**
  12 + * 后台批处理未处理照片(processedAt=null):人脸 + Qwen-VL 场景描述。
  13 + * 导入只登记照片行,处理交给这里——闲时/定时跑,不让用户在导入时干等。
  14 + * 单轮有时间预算,超了下轮(retry/periodic)继续,避开 WorkManager 执行上限。
  15 + */
  16 +class PhotoWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
  17 +
  18 + override suspend fun doWork(): Result {
  19 + if (!isRunning.compareAndSet(false, true)) return Result.success()
  20 + try {
  21 + val repo = Repository(applicationContext)
  22 + val pending = repo.photos.unprocessed()
  23 + if (pending.isEmpty()) return Result.success()
  24 +
  25 + val engine = FaceEngine.load(applicationContext)
  26 + val vision = VisionClient(ChatConfig.localBase(applicationContext))
  27 + val proc = PhotoProcessor(repo, engine, vision)
  28 + val deadline = System.currentTimeMillis() + RUN_BUDGET_MS
  29 + var done = 0
  30 + try {
  31 + for (p in pending) {
  32 + if (System.currentTimeMillis() > deadline) break
  33 + try {
  34 + proc.process(p)
  35 + } catch (t: Throwable) {
  36 + Log.w(TAG, "process failed ${p.path}: ${t.message}")
  37 + }
  38 + done++
  39 + }
  40 + } finally {
  41 + engine.close()
  42 + }
  43 + Log.i(TAG, "processed $done / ${pending.size} pending")
  44 + // 还有剩的就 retry 继续(带退避),否则结束等下个周期。
  45 + return if (done < pending.size) Result.retry() else Result.success()
  46 + } catch (t: Throwable) {
  47 + Log.e(TAG, "worker failed", t)
  48 + return Result.retry()
  49 + } finally {
  50 + isRunning.set(false)
  51 + }
  52 + }
  53 +
  54 + companion object {
  55 + const val TAG = "PhotoWorker"
  56 + /** 单轮预算 7min(VL ~45s/张,约 9 张/轮),避开 WorkManager ~10min 上限。 */
  57 + const val RUN_BUDGET_MS = 7 * 60 * 1000L
  58 + /** 防并发:periodic + one-shot + retry 可能同时触发,多 worker 打同一 llama-server 会 502。 */
  59 + private val isRunning = AtomicBoolean(false)
  60 + }
  61 +}
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/VisionClient.kt 0 → 100644
  1 +package com.lemon.lifelog.vision
  2 +
  3 +import android.util.Base64
  4 +import okhttp3.MediaType.Companion.toMediaType
  5 +import okhttp3.OkHttpClient
  6 +import okhttp3.Request
  7 +import okhttp3.RequestBody.Companion.toRequestBody
  8 +import org.json.JSONArray
  9 +import org.json.JSONObject
  10 +import java.io.IOException
  11 +import java.util.concurrent.TimeUnit
  12 +
  13 +/**
  14 + * 经 llama-swap(8080) 的 "vl" 模型(Qwen2.5-VL-3B)给一张照片出场景描述。
  15 + * 只描述场景/地点/活动,**不报人名**(人名由人脸识别给)。非流式,批处理用。
  16 + */
  17 +class VisionClient(
  18 + private val baseUrl: String,
  19 + private val model: String = "vl",
  20 +) {
  21 + companion object {
  22 + private val JSON = "application/json; charset=utf-8".toMediaType()
  23 + const val DEFAULT_PROMPT =
  24 + "用一两句中文描述这张照片:场景/地点是哪里、在做什么、有哪些主要物体。" +
  25 + "不要猜测或编造人物的姓名。"
  26 + }
  27 +
  28 + private val http = OkHttpClient.Builder()
  29 + .connectTimeout(10, TimeUnit.SECONDS)
  30 + .readTimeout(600, TimeUnit.SECONDS) // 含 llama-swap 切模型 + VL 推理,可能几十秒
  31 + .build()
  32 +
  33 + @Throws(IOException::class)
  34 + fun caption(jpeg: ByteArray, prompt: String = DEFAULT_PROMPT, maxTokens: Int = 220): String {
  35 + val b64 = Base64.encodeToString(jpeg, Base64.NO_WRAP)
  36 + val payload = JSONObject().apply {
  37 + put("model", model)
  38 + put("max_tokens", maxTokens)
  39 + put("temperature", 0.2)
  40 + put("messages", JSONArray().put(JSONObject().apply {
  41 + put("role", "user")
  42 + put("content", JSONArray().apply {
  43 + put(JSONObject().put("type", "text").put("text", prompt))
  44 + put(JSONObject().apply {
  45 + put("type", "image_url")
  46 + put("image_url", JSONObject().put("url", "data:image/jpeg;base64,$b64"))
  47 + })
  48 + })
  49 + }))
  50 + }
  51 + val req = Request.Builder()
  52 + .url("$baseUrl/v1/chat/completions")
  53 + .header("Content-Type", "application/json")
  54 + .post(payload.toString().toRequestBody(JSON))
  55 + .build()
  56 + http.newCall(req).execute().use { resp ->
  57 + val body = resp.body?.string().orEmpty()
  58 + if (!resp.isSuccessful) throw IOException("vl ${resp.code}: ${body.take(200)}")
  59 + val choices = JSONObject(body).optJSONArray("choices") ?: throw IOException("no choices")
  60 + if (choices.length() == 0) throw IOException("empty choices")
  61 + return choices.getJSONObject(0).optJSONObject("message")?.optString("content", "").orEmpty().trim()
  62 + }
  63 + }
  64 +}
... ...