Commit 906306c39e98b0de87d12ca035b46d3a1351f366
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>
Showing
14 changed files
with
773 additions
and
3 deletions
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
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 | +} | ... | ... |