Commit 1717e05b070fbbcfd4d2c01cacd928651a355fd6

Authored by yibo
1 parent a231863d

feat(lifelog/photolog): 微信联系人头像 → 人脸锚点 + 自动认人

把微信联系人头像建成人脸锚点,照片处理时自动认出"这是谁",标注时直接带名字。

- data:Person 加 faceEmbedding 锚点字段 + 迁移 v4→v5(ALTER 加 BLOB 列,
  DDL 对过 Room;真机迁移零数据损失);PersonDao anchors/getByContactRef
- FaceMatcher:gallery 统一 = 人物锚点(头像) + 已标注脸样本;match() 共享余弦匹配;
  PhotoProcessor 改用统一 gallery
- WeChatEnrollWorker:读 filesDir/wx_enroll/(enroll.json + 头像) → 对每张头像跑
  **端上 FaceEngine**(必须同引擎才能跟照片人脸比),恰 1 脸就建 Person(name,
  contactRef=wechat:wxid, avatarPath, faceEmbedding)。幂等、单轮预算 8min、retry 续
- UI:人物页「从微信导入联系人」按钮;人物卡片标注「微信/声纹」来源
- server/services/wechat/extract-wechat-avatars.py:Mac 侧提取脚本
  (sqlcipher 解密 EnMicroMsg.db → rcontact⨝img_flag 筛真人 → 下载头像)

实测:4338 真人联系人,头像有脸覆盖率 41%(1796/4337),端上 90s 建 284 锚点(继续后台跑)。
头像/姓名属敏感 PII,存 _wx_enroll/ 已 gitignore。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
lifelog-recorder/.gitignore
... ... @@ -18,3 +18,6 @@ keys.properties
18 18  
19 19 # 录音恢复备份(DB 误清后从设备抢救的原始音频,勿入库)
20 20 _recovered_segments_*/
  21 +
  22 +# 微信联系人头像/姓名(enrollment 用,敏感 PII,勿入库)
  23 +_wx_enroll/
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologScreen.kt
... ... @@ -280,26 +280,50 @@ private fun TagFaceDialog(
280 280 @Composable
281 281 private fun PeopleList(vm: PhotologViewModel, persons: List<Person>, onOpen: (Long) -> Unit) {
282 282 var renameFor by remember { mutableStateOf<Person?>(null) }
283   - if (persons.isEmpty()) {
284   - Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
285   - Text("还没有人物。去「照片」里点一张脸标注,就会自动建立人物并归类照片。", style = MaterialTheme.typography.bodyMedium)
  283 + val enrollReady = remember { vm.wechatEnrollReady() }
  284 + var enrollTapped by remember { mutableStateOf(false) }
  285 +
  286 + Column(modifier = Modifier.fillMaxSize()) {
  287 + if (enrollReady) {
  288 + Row(
  289 + modifier = Modifier.fillMaxWidth().padding(12.dp),
  290 + verticalAlignment = Alignment.CenterVertically,
  291 + horizontalArrangement = Arrangement.spacedBy(8.dp),
  292 + ) {
  293 + Button(onClick = { vm.enrollWeChat(); enrollTapped = true }) { Text("从微信导入联系人") }
  294 + Text(
  295 + if (enrollTapped) "后台建锚点中,人物会陆续出现…" else "用头像建人脸锚点,照片自动认人",
  296 + style = MaterialTheme.typography.labelSmall,
  297 + )
  298 + }
  299 + HorizontalDivider()
286 300 }
287   - return
288   - }
289   - LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
290   - items(persons, key = { it.id }) { p ->
291   - val count by produceState(0, p.id) { value = withContext(Dispatchers.IO) { vm.repo.faces.countForPerson(p.id) } }
292   - Card(modifier = Modifier.fillMaxWidth().clickable { onOpen(p.id) }) {
293   - Row(
294   - modifier = Modifier.fillMaxWidth().padding(14.dp),
295   - horizontalArrangement = Arrangement.SpaceBetween,
296   - verticalAlignment = Alignment.CenterVertically,
297   - ) {
298   - Column {
299   - Text(p.name, style = MaterialTheme.typography.titleSmall)
300   - Text("$count 张人脸" + (if (p.voiceSpeakerId != null) " · 已绑声纹" else ""), style = MaterialTheme.typography.labelSmall)
  301 + if (persons.isEmpty()) {
  302 + Box(Modifier.fillMaxSize().padding(24.dp), contentAlignment = Alignment.Center) {
  303 + Text("还没有人物。点上方「从微信导入联系人」,或去「照片」里点一张脸标注。", style = MaterialTheme.typography.bodyMedium)
  304 + }
  305 + } else {
  306 + LazyColumn(modifier = Modifier.fillMaxSize().padding(12.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
  307 + items(persons, key = { it.id }) { p ->
  308 + val count by produceState(0, p.id) { value = withContext(Dispatchers.IO) { vm.repo.faces.countForPerson(p.id) } }
  309 + Card(modifier = Modifier.fillMaxWidth().clickable { onOpen(p.id) }) {
  310 + Row(
  311 + modifier = Modifier.fillMaxWidth().padding(14.dp),
  312 + horizontalArrangement = Arrangement.SpaceBetween,
  313 + verticalAlignment = Alignment.CenterVertically,
  314 + ) {
  315 + Column(modifier = Modifier.weight(1f)) {
  316 + Text(p.name, maxLines = 1, overflow = TextOverflow.Ellipsis, style = MaterialTheme.typography.titleSmall)
  317 + Text(
  318 + "$count 张人脸" +
  319 + (if (p.contactRef != null) " · 微信" else "") +
  320 + (if (p.voiceSpeakerId != null) " · 声纹" else ""),
  321 + style = MaterialTheme.typography.labelSmall,
  322 + )
  323 + }
  324 + TextButton(onClick = { renameFor = p }) { Text("重命名") }
  325 + }
301 326 }
302   - TextButton(onClick = { renameFor = p }) { Text("重命名") }
303 327 }
304 328 }
305 329 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/PhotologViewModel.kt
... ... @@ -8,9 +8,13 @@ import androidx.lifecycle.viewModelScope
8 8 import com.lemon.lifelog.data.Person
9 9 import com.lemon.lifelog.data.Photo
10 10 import com.lemon.lifelog.data.Repository
  11 +import androidx.work.ExistingWorkPolicy
  12 +import androidx.work.OneTimeWorkRequestBuilder
  13 +import androidx.work.WorkManager
11 14 import com.lemon.lifelog.vision.FaceMatcher
12 15 import com.lemon.lifelog.vision.ImageUtil
13 16 import com.lemon.lifelog.vision.PhotoScheduler
  17 +import com.lemon.lifelog.vision.WeChatEnrollWorker
14 18 import kotlinx.coroutines.Dispatchers
15 19 import kotlinx.coroutines.launch
16 20 import kotlinx.coroutines.withContext
... ... @@ -70,6 +74,16 @@ class PhotologViewModel(app: Application) : AndroidViewModel(app) {
70 74 viewModelScope.launch { repo.persons.updateName(personId, name.trim()) }
71 75 }
72 76  
  77 + /** 设备上是否已备好微信 enroll 数据(Mac 推过来的头像+清单)。 */
  78 + fun wechatEnrollReady(): Boolean =
  79 + File(getApplication<Application>().filesDir, "wx_enroll/enroll.json").exists()
  80 +
  81 + /** 触发后台把微信头像建成人脸锚点(幂等,已在跑则忽略)。 */
  82 + fun enrollWeChat() {
  83 + val req = OneTimeWorkRequestBuilder<WeChatEnrollWorker>().build()
  84 + WorkManager.getInstance(getApplication()).enqueueUniqueWork("wx-enroll", ExistingWorkPolicy.KEEP, req)
  85 + }
  86 +
73 87 fun deletePhoto(photoId: Long) {
74 88 viewModelScope.launch {
75 89 withContext(Dispatchers.IO) {
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/AppDatabase.kt
... ... @@ -20,7 +20,7 @@ import androidx.sqlite.db.SupportSQLiteDatabase
20 20 Photo::class,
21 21 Face::class,
22 22 ],
23   - version = 4,
  23 + version = 5,
24 24 exportSchema = false,
25 25 )
26 26 abstract class AppDatabase : RoomDatabase() {
... ... @@ -64,6 +64,13 @@ abstract class AppDatabase : RoomDatabase() {
64 64 }
65 65 }
66 66  
  67 + /** v0.7 photolog:persons 加人脸锚点 embedding(微信头像/标注样本)。 */
  68 + private val MIGRATION_4_5 = object : Migration(4, 5) {
  69 + override fun migrate(db: SupportSQLiteDatabase) {
  70 + db.execSQL("ALTER TABLE `persons` ADD COLUMN `faceEmbedding` BLOB")
  71 + }
  72 + }
  73 +
67 74 fun get(ctx: Context): AppDatabase =
68 75 instance ?: synchronized(this) {
69 76 instance ?: Room.databaseBuilder(
... ... @@ -71,7 +78,7 @@ abstract class AppDatabase : RoomDatabase() {
71 78 AppDatabase::class.java,
72 79 "lifelog.db",
73 80 )
74   - .addMigrations(MIGRATION_2_3, MIGRATION_3_4)
  81 + .addMigrations(MIGRATION_2_3, MIGRATION_3_4, MIGRATION_4_5)
75 82 // 不再 fallbackToDestructiveMigration:以后改 schema 必须写 Migration,
76 83 // 否则启动直接抛 IllegalStateException(fail-loud),绝不静默清掉真实录音。
77 84 .build()
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Daos.kt
... ... @@ -221,6 +221,16 @@ interface PersonDao {
221 221 @Query("SELECT * FROM persons WHERE id = :id")
222 222 suspend fun get(id: Long): Person?
223 223  
  224 + @Query("SELECT * FROM persons WHERE contactRef = :ref LIMIT 1")
  225 + suspend fun getByContactRef(ref: String): Person?
  226 +
  227 + /** 有人脸锚点的人(匹配 gallery 用)。 */
  228 + @Query("SELECT * FROM persons WHERE faceEmbedding IS NOT NULL")
  229 + suspend fun anchors(): List<Person>
  230 +
  231 + @Query("UPDATE persons SET faceEmbedding = :emb WHERE id = :id")
  232 + suspend fun updateFaceEmbedding(id: Long, emb: ByteArray)
  233 +
224 234 @Query("UPDATE persons SET name = :name WHERE id = :id")
225 235 suspend fun updateName(id: Long, name: String)
226 236  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/data/Entities.kt
... ... @@ -95,6 +95,8 @@ data class Person(
95 95 /** 微信/通讯录联系人引用(如 "wechat:wxid_xxx"),null = 手动建 */
96 96 val contactRef: String? = null,
97 97 val avatarPath: String? = null,
  98 + /** 人脸锚点 embedding(512维 LE bytes,来自头像或标注样本),用于自动认人;null = 还没有 */
  99 + val faceEmbedding: ByteArray? = null,
98 100 val createdAt: Long = System.currentTimeMillis(),
99 101 )
100 102  
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/FaceMatcher.kt
... ... @@ -3,36 +3,50 @@ package com.lemon.lifelog.vision
3 3 import com.lemon.lifelog.data.Repository
4 4  
5 5 /**
6   - * 人脸归属匹配(与声纹的 SpeakerMatcher 同构)。
7   - * 标注一张脸到某 Person 后,把所有未归属的脸跟已命名的脸库比余弦,自动回填。
  6 + * 人脸归属匹配(与声纹 SpeakerMatcher 同构)。
  7 + * 匹配 gallery = 人物锚点(微信头像 embedding) + 已标注的脸样本。
8 8 */
9 9 object FaceMatcher {
10   - private const val THRESH = PhotoProcessor.THRESH
  10 + const val THRESH = PhotoProcessor.THRESH
11 11  
12   - /** 把 [faceId] 指派给 [personId],然后回填其它未归属的脸。 */
  12 + /** 当前所有 (personId, embedding):人物锚点 + 已标注脸。 */
  13 + suspend fun gallery(repo: Repository): List<Pair<Long, FloatArray>> {
  14 + val out = ArrayList<Pair<Long, FloatArray>>()
  15 + for (p in repo.persons.anchors()) {
  16 + p.faceEmbedding?.let { out.add(p.id to ImageUtil.unpackEmbedding(it)) }
  17 + }
  18 + for (f in repo.faces.allWithEmbedding()) {
  19 + if (f.personId != null && f.embedding != null) {
  20 + out.add(f.personId!! to ImageUtil.unpackEmbedding(f.embedding!!))
  21 + }
  22 + }
  23 + return out
  24 + }
  25 +
  26 + /** 给一个 embedding 在 gallery 里找最佳人物,余弦 ≥ THRESH 返回 (personId, sim),否则 null。 */
  27 + fun match(gallery: List<Pair<Long, FloatArray>>, emb: FloatArray): Pair<Long, Float>? {
  28 + var bestId: Long? = null
  29 + var bestSim = THRESH
  30 + for ((pid, g) in gallery) {
  31 + val c = FaceEngine.cosine(emb, g)
  32 + if (c > bestSim) { bestSim = c; bestId = pid }
  33 + }
  34 + return bestId?.let { it to bestSim }
  35 + }
  36 +
  37 + /** 标注一张脸到某 Person,然后回填其它未归属的脸。 */
13 38 suspend fun assignAndBackfill(repo: Repository, faceId: Long, personId: Long) {
14 39 repo.faces.assignPerson(faceId, personId, 1f)
15 40 backfillAll(repo)
16 41 }
17 42  
18   - /**
19   - * 对所有未归属的脸:跟已命名脸库做最近邻,余弦 ≥ THRESH 则归属。
20   - * 反复一轮即可(新归属的脸不会再扩大已命名集合的语义)。
21   - */
  43 + /** 对所有未归属的脸跑一遍匹配(新建人物锚点 / 标注后调用)。 */
22 44 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
  45 + val g = gallery(repo)
  46 + if (g.isEmpty()) return
27 47 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)
  48 + val m = match(g, ImageUtil.unpackEmbedding(f.embedding!!))
  49 + if (m != null) repo.faces.assignPerson(f.id, m.first, m.second)
36 50 }
37 51 }
38 52 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/PhotoProcessor.kt
... ... @@ -40,26 +40,18 @@ class PhotoProcessor(
40 40  
41 41 if (caption != null) repo.photos.updateCaption(photo.id, caption, "vl")
42 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   -
  43 + // 匹配 gallery = 微信头像锚点 + 已标注脸样本。
  44 + val gallery = FaceMatcher.gallery(repo)
48 45 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   - }
  46 + val m = FaceMatcher.match(gallery, fr.embedding)
55 47 repo.faces.insert(
56 48 Face(
57 49 photoId = photo.id,
58 50 left = fr.face.x1, top = fr.face.y1, right = fr.face.x2, bottom = fr.face.y2,
59 51 detScore = fr.face.score,
60 52 embedding = ImageUtil.packEmbedding(fr.embedding),
61   - personId = bestId,
62   - personSim = if (bestId != null) bestSim else null,
  53 + personId = m?.first,
  54 + personSim = m?.second,
63 55 ),
64 56 )
65 57 }
... ...
lifelog-recorder/app/src/main/kotlin/com/lemon/lifelog/vision/WeChatEnrollWorker.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.data.Person
  8 +import com.lemon.lifelog.data.Repository
  9 +import org.json.JSONArray
  10 +import java.io.File
  11 +import java.util.concurrent.atomic.AtomicBoolean
  12 +
  13 +/**
  14 + * 把微信联系人头像建成人脸锚点:读 filesDir/wx_enroll/enroll.json([{username,name}])+
  15 + * avatars/{username}.jpg,对每张头像用**端上 FaceEngine** 检测,恰 1 张脸就建 Person
  16 + * (contactRef=wechat:username, avatarPath, faceEmbedding)。之后照片人脸自动认出联系人。
  17 + *
  18 + * 必须用端上 FaceEngine(与照片人脸同一引擎)才能跨比;不能用 Mac insightface 的 embedding。
  19 + * 单轮有时间预算,超了 retry 续;按 contactRef 幂等。
  20 + */
  21 +class WeChatEnrollWorker(ctx: Context, params: WorkerParameters) : CoroutineWorker(ctx, params) {
  22 +
  23 + override suspend fun doWork(): Result {
  24 + if (!isRunning.compareAndSet(false, true)) return Result.success()
  25 + try {
  26 + val dir = File(applicationContext.filesDir, "wx_enroll")
  27 + val manifest = File(dir, "enroll.json")
  28 + if (!manifest.exists()) {
  29 + Log.w(TAG, "no enroll.json at ${manifest.path}")
  30 + return Result.success()
  31 + }
  32 + val arr = JSONArray(manifest.readText())
  33 + val repo = Repository(applicationContext)
  34 + val engine = FaceEngine.load(applicationContext)
  35 + var enrolled = 0
  36 + var scanned = 0
  37 + var hitDeadline = false
  38 + val deadline = System.currentTimeMillis() + RUN_BUDGET_MS
  39 + try {
  40 + for (i in 0 until arr.length()) {
  41 + if (System.currentTimeMillis() > deadline) { hitDeadline = true; break }
  42 + val o = arr.getJSONObject(i)
  43 + val username = o.optString("username")
  44 + val name = o.optString("name").ifBlank { username }
  45 + val ref = "wechat:$username"
  46 + if (repo.persons.getByContactRef(ref) != null) continue // 幂等
  47 +
  48 + val avatar = File(dir, "avatars/$username.jpg")
  49 + if (!avatar.exists()) continue
  50 + val bmp = ImageUtil.decodeOriented(avatar.absolutePath, reqMax = 640) ?: continue
  51 + scanned++
  52 + try {
  53 + val det = engine.detect(bmp)
  54 + if (det.size == 1) {
  55 + val emb = engine.embed(bmp, det[0].kps)
  56 + repo.persons.insert(
  57 + Person(
  58 + name = name,
  59 + contactRef = ref,
  60 + avatarPath = avatar.absolutePath,
  61 + faceEmbedding = ImageUtil.packEmbedding(emb),
  62 + ),
  63 + )
  64 + enrolled++
  65 + }
  66 + } catch (t: Throwable) {
  67 + Log.w(TAG, "enroll $username failed: ${t.message}")
  68 + } finally {
  69 + bmp.recycle()
  70 + }
  71 + }
  72 + } finally {
  73 + engine.close()
  74 + }
  75 + // 用新锚点回填已处理照片里的未归属脸。
  76 + FaceMatcher.backfillAll(repo)
  77 + Log.i(TAG, "本轮 enroll $enrolled(扫 $scanned),deadline=$hitDeadline")
  78 + return if (hitDeadline) Result.retry() else Result.success()
  79 + } catch (t: Throwable) {
  80 + Log.e(TAG, "worker failed", t)
  81 + return Result.retry()
  82 + } finally {
  83 + isRunning.set(false)
  84 + }
  85 + }
  86 +
  87 + companion object {
  88 + const val TAG = "WeChatEnroll"
  89 + /** 单轮预算 8min(端上 ~0.4s/张),超了 retry 续;WorkManager ~10min 上限内。 */
  90 + const val RUN_BUDGET_MS = 8 * 60 * 1000L
  91 + private val isRunning = AtomicBoolean(false)
  92 + }
  93 +}
... ...
server/services/wechat/extract-wechat-avatars.py 0 → 100644
  1 +#!/usr/bin/env python3
  2 +"""
  3 +微信联系人头像提取 —— 给 lifelog/photolog 的人脸锚点 enrollment 备数据。
  4 +
  5 +流程(在 Mac 上跑,需要 `brew install sqlcipher`):
  6 + 1. adb 把设备上的 EnMicroMsg.db 拉到本地(需 root)。
  7 + 2. 用微信的 SQLCipher 配方解密:key + cipher_compatibility=1 + page 1024。
  8 + 3. join rcontact ⨝ img_flag,筛真人(verifyFlag=0,排除群/公众号/企业微信/系统号),
  9 + img_flag.reserved1 = 高清头像 URL(wx.qlogo.cn,公开可拉)。
  10 + 4. 并行下载头像 → avatars/{username}.jpg。
  11 + 5. (可选)insightface 估"有脸"覆盖率,导出 oneface 清单。
  12 +
  13 +产物(敏感 PII,勿入库)放 lifelog-recorder/_wx_enroll/:contacts.json / avatars/ / oneface_usernames.json
  14 +然后 push 到设备 filesDir/wx_enroll/,app「从微信导入联系人」用端上 FaceEngine 建锚点。
  15 +
  16 + DB key / 路径因设备而异,见 android-wechat-tracker-sqlcipher.js(key 'e03ce81',
  17 + 路径 /data/data/com.tencent.mm/MicroMsg/<hash>/EnMicroMsg.db)。
  18 +
  19 +用法:
  20 + python3 extract-wechat-avatars.py /tmp/EnMicroMsg.db e03ce81 ./_wx_enroll
  21 +"""
  22 +import sys, os, json, subprocess, urllib.request, concurrent.futures
  23 +
  24 +REAL_PERSON_SQL = """
  25 +SELECT r.username AS username,
  26 + COALESCE(NULLIF(r.conRemark,''), NULLIF(r.nickname,''), r.username) AS name,
  27 + f.reserved1 AS avatar
  28 +FROM rcontact r JOIN img_flag f ON f.username=r.username
  29 +WHERE r.verifyFlag=0
  30 + AND r.username NOT LIKE '%@chatroom'
  31 + AND r.username NOT LIKE 'gh_%'
  32 + AND r.username NOT LIKE '%@openim'
  33 + AND r.username NOT IN ('weixin','filehelper','fmessage','medianote','floatbottle',
  34 + 'qmessage','qqmail','newsapp','officialaccounts','helper_entry','exmail_tool',
  35 + 'mphelper','brandsessionholder')
  36 + AND f.reserved1 LIKE 'http%';
  37 +"""
  38 +
  39 +
  40 +def decrypt_and_query(db_path, key):
  41 + script = (f'PRAGMA key="{key}";\nPRAGMA cipher_compatibility=1;\n'
  42 + f'PRAGMA cipher_page_size=1024;\n.mode json\n{REAL_PERSON_SQL}\n')
  43 + out = subprocess.run(['sqlcipher', db_path], input=script,
  44 + capture_output=True, text=True).stdout
  45 + return json.loads(out[out.index('['):]) # 跳过开头 PRAGMA 的 "ok"
  46 +
  47 +
  48 +def download(items, out_dir):
  49 + os.makedirs(out_dir, exist_ok=True)
  50 +
  51 + def dl(it):
  52 + dest = os.path.join(out_dir, it['username'] + '.jpg')
  53 + if os.path.exists(dest) and os.path.getsize(dest) > 0:
  54 + return True
  55 + try:
  56 + req = urllib.request.Request(it['avatar'], headers={'User-Agent': 'Mozilla/5.0'})
  57 + b = urllib.request.urlopen(req, timeout=12).read()
  58 + if len(b) < 200:
  59 + return False
  60 + open(dest, 'wb').write(b)
  61 + return True
  62 + except Exception:
  63 + return False
  64 +
  65 + with concurrent.futures.ThreadPoolExecutor(max_workers=24) as ex:
  66 + return sum(1 for r in ex.map(dl, items) if r)
  67 +
  68 +
  69 +def main():
  70 + db_path, key, out_root = sys.argv[1], sys.argv[2], sys.argv[3]
  71 + os.makedirs(out_root, exist_ok=True)
  72 + items = decrypt_and_query(db_path, key)
  73 + json.dump(items, open(os.path.join(out_root, 'contacts.json'), 'w'), ensure_ascii=False)
  74 + print(f'真人联系人(有头像): {len(items)}')
  75 + n = download(items, os.path.join(out_root, 'avatars'))
  76 + print(f'头像下载: {n}')
  77 + print('下一步:(可选)insightface 估覆盖率导出 oneface_usernames.json,'
  78 + '再 push 到设备 filesDir/wx_enroll/,app 内「从微信导入联系人」。')
  79 +
  80 +
  81 +if __name__ == '__main__':
  82 + main()
... ...