相册自动 TAG 生成

版本: 2.0
状态: 已实施
最后更新: 2026-07-08
维护者: RD Agent

历史合并说明:本文档由以下 6 份文档合并而成:AUTO_TAG_GENERATION_SPEC.mdTAG_DATABASE_SCHEMA.mdTAG_SCAN_STATE_MACHINE.mdTAG_GENERATION_IMPLEMENTATION_REVIEW.mdTAG_GENERATION_PERFORMANCE_ANALYSIS.mdTAG_I18N_DESIGN.md。内容已按「总览 → 5-Pass Pipeline → 状态机 → 数据库模型 → 实现回顾 → 性能分析 → I18N」重组,并消除重复内容。

相关文档: GALLERY_SEARCH.md(相册搜索 SSOT)、ON_DEVICE_INFERENCE_INVENTORY_TECH_SPEC.md


1. 概述

1.1 目标

为相册中每张照片自动生成多维度标签(Tag),支撑 MediaSearchEngine 的自然语言搜索召回:

1.2 核心模块

模块 文件 职责
TagGenerationScheduler domain/tag/TagGenerationScheduler.kt 模型加载、单张处理、DBSCAN 聚类、OpenCL 守护
TagGenerationPipeline domain/tag/TagGenerationPipeline.kt 单张照片的 5-Pass 原子处理
TagScanOrchestrator domain/tag/scan/TagScanOrchestrator.kt 持久化任务队列、会话状态机、暂停/恢复/取消/重试
TagScanTaskEntity data/local/entity/TagScanTaskEntity.kt 任务持久化实体及 TagScanPass 枚举
TagCategory domain/tag/TagCategory.kt 用户可见类别与 Pass 阶段映射
OpenClGuardian domain/tag/OpenClGuardian.kt Pass 3 前 warmup 与 OpenCL → CPU 降级
MobileClipEngine domain/tag/MobileClipEngine.kt MobileCLIP-S0 语义编码
MlKitTagExtractor domain/tag/MlKitTagExtractor.kt ML Kit Image Labeler 英文标签提取
FaceClusterEngine domain/tag/FaceClusterEngine.kt Glint360K R100 Embedding + DBSCAN 聚类
TagNormalizer / ControlledVocab domain/tag/TagNormalizer.kt Qwen 输出规范化与受控词表映射
TagGenerationService service/tag/TagGenerationService.kt 前台 Service,驱动 Orchestrator 并暴露进度
TagGenerationControlScreen features/gallery/components/TagGenerationControlScreen.kt 3-Pass 控制与按类别/时间范围重新生成 UI

2. 5-Pass Pipeline

2.1 Pipeline 总览

照片入库 / 用户触发
        │
        ▼
┌─────────────────────────────────────────────┐
│ Pass 1: FACE_DETECTION                       │
│ • 人脸 ROI 检测 + 106 关键点                 │
│ • Glint360K R100 512 维人脸 Embedding         │
│ • MobileCLIP 语义 Embedding(Base64)        │
│ 写入: faceRoiResult / face_embeddings        │
│       semanticEmbedding / lastTagScanPasses  │
└─────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────┐
│ Pass 2: DBSCAN                               │
│ • 全局人脸聚类 → persons / faceId            │
└─────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────┐
│ Pass 3: QWEN_TAGGING                         │
│ • Qwen 3.5-2B 图像理解                       │
│ • 中文标签 + ControlledVocab 规范化          │
│ 写入: labels JSON                            │
└─────────────────────────────────────────────┘
        │
        ▼
┌─────────────────────────────────────────────┐
│ Pass 5: ML_KIT_TAGGING                       │
│ • ML Kit Image Labeler 英文标签              │
│ 写入: mlKitLabels JSON                       │
└─────────────────────────────────────────────┘

Pass 4: MOBILE_CLIP_ENCODING(保留枚举值,用于兼容历史任务/单独重编码场景;
        常规扫描已将 MobileCLIP 内联合并到 Pass 1)

2.2 TAG 分类体系

层级 来源 存储字段 / 表 示例
L1 人脸 FaceDetector + FaceClusterEngine faceRoiResult JSON、face_embeddings 表、persons 表、media_assets.faceId hasFace=true, faceCount=2, personId=3
L2 内容 Qwen 3.5-2B media_assets.labels JSON scene=户外, activity=旅行, tags=["风景","山"]
L3 元数据 EXIF / MediaStore / 逆地理编码 captureDate, locationName, source, latitude/longitude time:afternoon, location:北京
L4 ML Kit 标签 ML Kit Image Labeler media_assets.mlKitLabels JSON ["Outdoor","Food","Plant"]
L5 语义向量 MobileCLIP-S0 media_assets.semanticEmbedding Base64 512 维 L2 归一化向量

L1 人脸标签

标签 来源 示例值
has_face ROI 检测 true / false
face_count ROI 检测 1 / 2 / 3+
person:{name} 人脸聚类 + 用户命名 person:张三
group_photo face_count >= 2 true
selfie face_count == 1 true

L2 内容标签(Qwen 产出)

Qwen 输出经 TagNormalizer 映射到 ControlledVocab 后写入 labels JSON:

类别 字段 示例
场景 scene 户外办公室海边
活动 activity 旅行吃饭运动
物体 objects ["山","天空"]
标签 tags ["风景","旅行","户外"]
摘要 summary 一家人在公园野餐的温馨场景

语言策略:Qwen 统一输出中文;未命中受控词表的原始词保留在 nonStandard 中,仍然可被 LIKE 搜索命中。

L3 元数据标签

标签 来源 示例值
time:morning/afternoon/evening/night captureDate time:afternoon
season:spring/summer/autumn/winter captureDate season:summer
source:{source} MediaAsset.source source:wechat
location:{name} locationName location:北京

L4 ML Kit 英文标签

2.3 Pass 详细设计

Pass 1:人脸检测 + Embedding + MobileCLIP 语义编码

输入: 照片 URI
输出: Stage1WithEmbeddingsResult

suspend fun stage1WithEmbeddings(
    uri: String,
    lensFacing: Int,
    mediaId: Long
): Stage1WithEmbeddingsResult

处理步骤:

  1. 加载 Bitmap(最长边限制,避免 OOM)
  2. 人脸 ROI 检测 → Stage1ResulthasFace / faceCount / roiRects
  3. 对每张人脸:
    • 用 106 关键点做仿射对齐 → 112×112 ROI
    • FaceClusterEngine.extractFeature() → 512 维 Glint360K R100 embedding
    • 写入 face_embeddings
  4. MobileClipEngine.encodeImage() → 512 维语义向量 → Base64
    • 无论是否检测到人脸,都会执行 MobileCLIP 语义编码,确保风景、文档、静物等无人脸照片也能被语义搜索召回。
  5. 写入 media_assets.faceRoiResult / semanticEmbedding / hasFace

Pass 2:DBSCAN 全局聚类

人物命名与全量重聚类保名: - 用户在 TAG 生成控制页 可为每个人物簇输入名称,写入 persons.name - 全量重跑 Pass 2 时,系统会在清表前捕获已命名人物的 centroid 快照(NamedPersonSnapshot) - 新簇质心与旧快照做余弦相似度匹配,阈值 NAME_PRESERVE_MIN_SIMILARITY = 0.65 - 匹配成功则复用原 personIdname,避免重扫描后用户命名丢失 - 每个旧人物最多被复用一次,未匹配到的新簇将创建新的匿名人物

Pass 3:Qwen 图像理解标签生成

模型: Qwen 3.5-2B(MNN-LLM 运行时)
输入: 照片 URI + faceRoiResult 人脸上下文
输出: QwenTagsTagNormalizerUnifiedTagResult JSON

Prompt 约束: - 要求输出 JSON:scene / activity / objects / tags / summary - 所有字段使用中文(专有名词除外) - tags 3-5 个中文关键词

OpenCL 守护: - OpenClGuardian.warmup() 在 Pass 3 推理前执行 - 单次推理带超时;连续失败/超时后标记设备降级 CPU,黑名单持久化到 DataStore

Pass 4:MOBILE_CLIP_ENCODING(保留兼容)

Pass 5:ML Kit 英文标签提取

2.4 类别到 Pass 映射

TagCategory 映射 Pass
FACE FACE_DETECTION + DBSCAN
SCENE / ACTIVITY / OBJECTS / TAGS / SUMMARY QWEN_TAGGING
ML_KIT_LABELS ML_KIT_TAGGING

2.5 OpenCL 超时与 CPU 降级

class OpenClGuardian(
    context: Context,
    engine: LocalLlmEngine,
    prefs: UserSettingsRepository
)

3. 状态机

3.1 设计目标

TagScanOrchestrator 通过有限状态机管理 5-Pass TAG 扫描会话。状态机需要满足:

3.2 状态定义

enum class ScanSessionState {
    IDLE,        // 空闲,无活跃会话
    RUNNING,     // 正在执行任务
    PAUSING,     // 已收到暂停指令,等待当前任务结束
    PAUSED,      // 已暂停,可恢复
    CANCELLING,  // 已收到取消指令,正在终止
    CANCELLED,   // 已取消,终态
    COMPLETED    // 全部完成,终态
}

3.3 状态流转

IDLE --(scheduleAutoScan/scheduleRegenerate/schedulePass/resume)--> RUNNING
RUNNING --(pause)--> PAUSING --(当前任务结束)--> PAUSED
PAUSED --(resume)--> RUNNING
RUNNING/PAUSING/PAUSED --(cancel)--> CANCELLING --(当前任务被中断/任务全部标记为 CANCELLED)--> CANCELLED
RUNNING --(队列中无剩余任务)--> COMPLETED
RUNNING --(执行异常,非取消)--> PAUSED

3.4 关键行为说明

当前状态 用户操作 实际动作 下一状态
IDLE / PAUSED 启动扫描 createTasks() + startSession() RUNNING
RUNNING 暂停 pauseSession() 把 PENDING/RUNNING 任务改为 PAUSED PAUSING
PAUSING pollNextPendingBySession() 返回 null,当前任务结束后 PAUSED
PAUSED 恢复 resumeSession() 把 PAUSED 任务改回 PENDING,重新启动协程 RUNNING
RUNNING / PAUSING / PAUSED 取消 cancelSession() 把任务改 CANCELLED,取消 currentJob CANCELLING
CANCELLING runSession() 捕获 CancellationException 或轮询发现无 PENDING 任务 CANCELLED
RUNNING 所有任务完成 COMPLETED
RUNNING 执行异常 runSession() catch Exception,状态置为 PAUSED

3.5 防御性规则

3.6 与数据库任务状态的映射

tag_scan_tasks.status 是持久化状态,ScanSessionState 是会话级运行时状态。

运行时状态 对应任务状态
RUNNING 存在 RUNNING 任务
PAUSING / PAUSED 存在 PAUSED 任务,无 RUNNING/PENDING
CANCELLING / CANCELLED 存在 CANCELLED 任务,无 RUNNING/PENDING/PAUSED
COMPLETED 全部任务为 COMPLETED

3.7 UI 反馈

TagGenerationControlScreen 根据 ScanSessionState 显示:

3.8 线程模型

┌─────────────────────────────────────────────────────────────┐
│  Main Thread(Service / UI)                                 │
│  - onStartCommand 接收 Intent                                 │
│  - serviceScope 分发 pause/resume/cancel 到 control thread    │
└───────────────────────┬─────────────────────────────────────┘
                        │
┌───────────────────────▼─────────────────────────────────────┐
│  tag-control thread(控制线程)                              │
│  - TagScanOrchestrator 状态机                                │
│  - 任务队列 poll / pause / resume / cancel                   │
│  - 进度更新 _progress                                        │
└───────────────────────┬─────────────────────────────────────┘
                        │  dispatch executeTask()
┌───────────────────────▼─────────────────────────────────────┐
│  tag-worker thread(任务线程)                               │
│  - TagGenerationScheduler 原子任务                           │
│  - 人脸检测 / DBSCAN / Qwen JNI 推理                         │
│  可能被 native 推理阻塞,但不影响控制线程                    │
└─────────────────────────────────────────────────────────────┘

关键原则: - 控制线程与任务线程必须分离。 - JNI/OpenCL 阻塞只阻塞 tag-workertag-control 仍可立即响应取消、更新 _progressCANCELLED。 - cancel() 会立即把状态置为 CANCELLED,任务线程即使仍在运行,其结果也会被 ensureActive() 丢弃。


4. 队列编排与生命周期

4.1 TagScanOrchestrator

负责将一次扫描拆分为可持久化的原子任务,支持:

4.2 任务持久化

@Entity(tableName = "tag_scan_tasks")
data class TagScanTaskEntity(
    @PrimaryKey(autoGenerate = true) val id: Long = 0,
    val sessionId: String,
    val mediaId: Long,
    val pass: TagScanPass,                 // FACE_DETECTION / DBSCAN / QWEN_TAGGING / MOBILE_CLIP_ENCODING / ML_KIT_TAGGING
    val tagCategories: String? = null,     // 目标类别 JSON 数组
    val status: TagScanTaskStatus = PENDING,
    val priority: Int = 0,
    val attemptCount: Int = 0,
    val createdAt: Long = System.currentTimeMillis(),
    val scheduledAt: Long? = null,
    val startedAt: Long? = null,
    val completedAt: Long? = null,
    val errorMessage: String? = null
)

4.3 增量去重策略


5. 数据库模型

当前数据库版本:7(Room @Database(version = 7))。版本 6→7 新增了 media_assets.mlKitLabelsZh 列,用于 ML Kit 标签中文翻译(见 I18N 章节)。

5.1 核心表结构

media_assets(媒体资产主表)

字段 类型 说明
id Long (PK) 自增主键,媒体唯一标识
uri String Content URI,指向系统媒体库
type MediaType 类型:IMAGE / VIDEO
captureDate Long 拍摄时间戳(毫秒)
fileName String 文件名
duration Long? 视频时长(图片为 null)
hasFace Boolean 是否检测到人脸(Pass 1 产出)
faceId String? 人物簇 ID(Pass 2 聚类后填充)
source String? 来源标识
labels String? TAG 结果 JSON(Pass 3 产出)
mlKitLabels String? ML Kit 英文标签 JSON 数组(Pass 5 产出)
mlKitLabelsZh String? ML Kit 中文标签 JSON 数组(Phase 1.5 新增)
ocrText String? OCR 提取的文字
latitude Double? GPS 纬度
longitude Double? GPS 经度
locationName String? 逆地理编码地名
indexedAt Long? 索引完成时间戳(null=未索引)
faceRoiResult String? 人脸 ROI 检测 JSON(Pass 1 产出)
semanticEmbedding String? MobileCLIP 语义 embedding(512 维 FloatArray 的 Base64,Pass 1 内联产出)
lastTagScanAt Long? 最近一次 TAG 扫描成功时间戳
lastTagScanPasses String? 已完成的 Pass 阶段 JSON,如 {"1":ts,"2":ts,"3":ts}

tags(标签词表)

字段 类型 说明
tagId Long (PK) 自增主键
name String 标签名称(唯一索引)
category String 类别:scene / animal / object / food / person / other

media_tag_cross_ref(媒体-标签关联表)

字段 类型 说明
mediaId Long (PK, FK) 关联 media_assets.id
tagId Long (PK, FK) 关联 tags.tagId
confidence Float? ML Kit 置信度(TAG 流程中未使用)

persons(人物聚类表)

字段 类型 说明
personId Long (PK) 自增主键,人物唯一标识
name String? 人物名称(用户可编辑)
coverMediaId Long? 封面媒体 ID
faceCount Int 该人物关联的人脸数量
createdAt Long 创建时间戳
updatedAt Long 更新时间戳

face_embeddings(人脸 Embedding 表)

字段 类型 说明
embeddingId Long (PK) 自增主键
mediaId Long 关联 media_assets.id
personId Long? 关联 persons.personId(聚类后填充)
embedding ByteArray 512 维 FloatArray 的序列化字节
createdAt Long 创建时间戳

tag_scan_tasks(扫描任务队列表)

字段 类型 说明
id Long (PK) 自增主键
sessionId String 扫描会话 ID
mediaId Long 关联媒体 ID
pass TagScanPass 阶段:FACE_DETECTION / DBSCAN / QWEN_TAGGING / MOBILE_CLIP_ENCODING / ML_KIT_TAGGING
tagCategories String? 目标 TAG 类别 JSON 数组,null=全部
status TagScanTaskStatus 状态:PENDING / RUNNING / PAUSED / COMPLETED / FAILED / CANCELLED
priority Int 优先级(数值越小越优先)
attemptCount Int 已尝试次数
createdAt Long 创建时间戳
scheduledAt Long? 计划执行时间(失败重试退避)
startedAt Long? 开始执行时间
completedAt Long? 完成时间
errorMessage String? 失败原因

5.2 JSON 数据格式

labels 字段格式(Pass 3 产出)

{
  "face": {
    "count": 2,
    "selfie": false,
    "groupPhoto": true,
    "personIds": [1, 3]
  },
  "scene": "户外公园",
  "activity": "野餐",
  "objects": ["毯子", "食物", "篮子"],
  "tags": ["春天", "家庭", "休闲"],
  "qwenSummary": "一家人在公园野餐的温馨场景"
}

faceRoiResult 字段格式(Pass 1 产出)

{
  "hasFace": true,
  "faceCount": 2,
  "isSelfie": false,
  "isGroupPhoto": true
}

注意:2026-06-27 优化后,106 点 landmarks 不再存储(RetinaFace bbox 已足够)。

semanticEmbedding 字段格式

MobileCLIP-S0 生成的 512 维语义 embedding,存储为 FloatArray 的 Base64 字符串

mlKitLabels 字段格式(Pass 5 产出)

ML Kit Image Labeler 输出的英文标签,按置信度过滤后存储为 JSON 数组:

["Outdoor", "Food", "Plant"]

lastTagScanPasses 字段格式

记录各 Pass 的完成时间戳,用于增量扫描去重:

{"1": 1750992000000, "2": 1750992100000, "3": 1750992200000, "5": 1750992300000}
Key 含义
"1" Pass 1(人脸检测 + MobileCLIP 内联合并)完成时间
"2" Pass 2(DBSCAN 聚类)完成时间
"3" Pass 3(Qwen 标签)完成时间
"4" Pass 4(MobileCLIP 单独重编码,保留兼容)完成时间
"5" Pass 5(ML Kit 英文标签)完成时间

5.3 5-Pass 数据流转

┌─────────────────────────────────────────────────────────────────┐
│  Pass 1: 人脸检测 + MobileCLIP 语义编码(同一张 Bitmap)          │
│  ├─ faceDetector.detectFacesOnly(bitmap) → ROI 列表            │
│  ├─ faceClusterEngine.extractFeature(bitmap, roi) → embedding   │
│  ├─ mobileClipEngine.encode(bitmap) → 512维语义向量              │
│  │                                                              │
│  └─ 写入 DB:                                                    │
│     • media_assets.hasFace = true/false                        │
│     • media_assets.faceRoiResult = JSON(人脸上下文)             │
│     • media_assets.semanticEmbedding = Base64(512维向量)          │
│     • face_embeddings.embedding = ByteArray(512维)             │
│     • media_assets.lastTagScanPasses += {"1": ts}               │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  Pass 2: DBSCAN 全局聚类(批量,mediaId = -1 标记)              │
│  ├─ 读取所有未聚类的 face_embeddings                            │
│  ├─ 余弦距离矩阵 + DBSCAN 聚类                                  │
│  │                                                              │
│  └─ 写入 DB:                                                    │
│     • persons 表新增/更新人物簇                                  │
│     • face_embeddings.personId = 聚类分配的人物 ID              │
│     • media_assets.faceId = personId(可选,用于快速查询)       │
│     • media_assets.lastTagScanPasses += {"2": ts}               │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  Pass 3: Qwen 图像理解标签生成                                   │
│  ├─ 从 faceRoiResult 恢复人脸上下文(无需重新检测)               │
│  ├─ llmEngine.imageInference(bitmap, prompt) → JSON 标签         │
│  │                                                              │
│  └─ 写入 DB:                                                    │
│     • media_assets.labels = 最终 TAG JSON                        │
│     • tags 表新增规范化标签(去重)                               │
│     • media_tag_cross_ref 新增关联记录                           │
│     • media_assets.lastTagScanPasses += {"3": ts}               │
│     • media_assets.lastTagScanAt = 当前时间戳                   │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│  Pass 5: ML Kit Image Labeler 英文标签提取                       │
│  ├─ mlKitTagExtractor.extract(uri) → 英文标签列表                │
│  │                                                              │
│  └─ 写入 DB:                                                    │
│     • media_assets.mlKitLabels = JSON 英文标签数组                │
│     • media_assets.mlKitLabelsZh = JSON 中文标签数组              │
│     • media_assets.lastTagScanPasses += {"5": ts}               │
└─────────────────────────────────────────────────────────────────┘

5.4 数据库版本迁移历史

版本 变更内容
2 → 3 新增 tag_scan_tasks 表;media_assets 新增 lastTagScanAt / lastTagScanPasses
3 → 4 media_assets 新增 semanticEmbedding(MobileCLIP 语义向量)
4 → 5 空迁移(修复设备上版本已升到 5 的问题)
5 → 6 media_assets 新增 mlKitLabels(ML Kit 英文标签 JSON);TagScanPass 新增 ML_KIT_TAGGING
6 → 7 media_assets 新增 mlKitLabelsZh(ML Kit 中文标签 JSON)

6. 实现回顾

6.1 总体判断

方向基本合理,但 Pass 3 的生成式标签体系存在「用大炮打蚊子」问题。

使用端侧多模态生成模型(Qwen 2B 级别)进行相册图像打标,在隐私合规中文复杂场景理解上是成立的;但在效率、标签一致性、成本控制上不是最优解。当前 5-Pass 架构本身设计良好,但 Pass 3 的生成式标签体系存在多项可优化空间。

一句话建议:保留 Qwen 做「摘要 + 复杂语义 + OCR」,把「标签分类」交给更快、更可控的 CLIP/分类体系;同时把当前扁平词表升级为「受控层级标签体系」。

6.2 架构层面的优点

方面 评价
5-Pass 分离 人脸检测 / Embedding / 聚类 / VL 理解 / ML Kit 快速标签解耦合理,便于独立调度、失败重试、增量更新。
持久化任务队列 TagScanOrchestrator 将扫描拆分为原子任务,支持暂停 / 恢复 / 取消 / 重试,适合长时间后台 Service。
OpenCL Guardian 带 warmup、超时、黑名单降级,是端侧 GPU 推理的务实做法。
混合召回设计 Qwen 中文标签 + ML Kit 英文标签 + MobileCLIP 语义向量 + 人脸聚类,搜索侧有丰富信号可用。
隐私优先 全部本地推理,符合项目 [PRIVACY] 红线。
Category 重新生成 TagCategory 按类别触发不同 Pass,用户可单独重标人脸 / 场景 / 物体等,体验友好。

6.3 模型选择的核心问题

命名与版本不一致

代码中模型 key 为 qwen3_5_2b

// TagGenerationScheduler.kt
private const val MODEL_KEY = "qwen3_5_2b"

文档中同时出现 Qwen 3.5-2BQwen3-VL 2B。需要确认实际运行的模型版本,并在 MODEL_KEY 附近和文档中统一模型 ID,注释实际模型文件来源与版本。

用 2B 生成模型做「标签抽取」是过度设计

当前 Qwen 的任务是:看一张图 → 输出固定 JSON(scene / activity / objects / tags / summary)。该任务本质上是结构化图像分类 / image captioning,而非自由对话。用 2B 生成模型做这件事的代价:

对比更轻量的方案:

方案 单张延迟 标签一致性 适合场景
MobileCLIP / Chinese-CLIP 零 shot 5–100ms 极高 从受控词表选标签
Tiny image captioner(如 LightCap 类) <50ms 生成 summary
Qwen2.5-VL 2B 1–8s 复杂场景理解、OCR、summary

结论:Qwen 应专注于其擅长的「复杂中文理解 + 摘要 + OCR 场景」,而不是把所有标签都交给它自由生成。

6.4 打标体系的详细问题

标签字段存在冗余和重叠

当前 QwenTags 输出 5 个字段:sceneactivityobjectstagssummary

问题: - objectstags 高度重叠推车婴儿 同时出现在两个列表里,搜索侧无法区分权重。 - 人脸相关标签让 Qwen 推断是浪费单人 / 双人 / 多人 / 合影 / 自拍 完全可以通过 faceCount 和镜头方向计算得到,无需 LLM 重复判断。 - sceneactivity 本质上也是标签:用户搜「公园」或「散步」都应召回,字段拆分对搜索价值有限。 - 缺少层级关系:例如 车 → 汽车 → SUV食物 → 蛋糕 → 生日蛋糕,当前是扁平列表,无法做上位词模糊召回。

受控词表「大而全」但利用率低

controlled_vocab.json700+ 词,分布在 10 个类别。但 Qwen 每张照片仅输出 5–8 个 tags,意味着大量词表词永远不会被命中。

问题: - 词表维护成本高,新增 / 删减缺乏反馈闭环。 - objects 类别包含 口红粉底眼影 等细粒度物体,Qwen 在 512px 输入上很难识别。 - 同义词映射覆盖有限且存在不对称:例如 帅哥男性,但搜索时用户更可能输入「男」而非「男性」。

TagNormalizer 的匹配策略有风险

当前归一化流程:精确匹配 → 包含匹配 → 编辑距离 ≤ 1 → 同义词映射。

编辑距离 ≤ 1 在中文上非常危险: - ↔︎ :编辑距离 1,语义完全不同。 - ↔︎ ↔︎ :容易误匹配。 - 对长度 2–3 的中文词尤其敏感。

包含匹配同样有风险: - 沙滩 包含 ,可能错误映射。 - T恤 包含 T

建议:中文归一化优先使用同义词表 + 精确匹配,慎用编辑距离;如需模糊匹配,使用基于 embedding 的语义相似度(MobileCLIP 文本编码)替代 Levenshtein。

JSON 输出解析过于脆弱

private fun extractJson(text: String): String? {
    val start = text.indexOf('{')
    val end = text.lastIndexOf('}')
    return if (start != -1 && end > start) {
        text.substring(start, end + 1)
    } else null
}

如果模型输出包含 markdown code block 或多个 {},提取会失败。当前仅记录 nonStandard,没有重试机制。

建议: - Prompt 中强制要求纯 JSON 输出,禁止 markdown code block。 - 增加 JSON 修复 / 重试:提取失败后尝试截断到第一个合法 JSON,或让模型重新输出。 - 增加 schema 校验(字段类型、数组长度等)。

缺少质量评估和反馈闭环

当前没有: - 标签置信度分数。 - 标签覆盖率统计(哪些词表词从未被命中)。 - 人工 / 半自动反馈修正机制。 - 端到端搜索召回率测试。

这导致标签质量不可知,只能凭手感调整 prompt。

6.5 优先级建议

P0:立即实施(低风险高收益)

  1. 统一 QWEN_MAX_TOKENS 为 64–128:至少先降到 128,验证后再到 64,可省 30–50% Pass 3 时间。
  2. 修复中文归一化风险:移除或严格限制 TagNormalizer 的编辑距离匹配,改用同义词表 + 语义相似度。
  3. 统一模型命名:在代码和文档中明确模型真实版本。
  4. 改进 JSON 提取:支持 markdown code block 包裹、schema 校验、失败重试。

P1:近期实施(架构改进)

  1. 把「标签分类」从 Qwen 迁移到 MobileCLIP / Chinese-CLIP 零 shot
    • 用受控词表作为候选标签集合。
    • 图像编码与每个标签文本编码计算相似度,取 Top-K。
    • 延迟从秒级降到毫秒级,一致性大幅提高。
    • Qwen 仅负责 summary、OCR 场景、复杂活动推断。
  2. 合并 objectstags,或建立层级标签关系;移除 Qwen 对 单人 / 双人 / 合影 等可由 faceCount 计算的标签要求。
  3. 加入照片去重:Pass 1 计算 dHash,Pass 3 优先复用已有标签。

P2:中长期

  1. 建立标签质量评估 pipeline:抽样评估召回率,统计词表命中率。
  2. DBSCAN 加速:人脸 embedding 超过阈值后引入近似最近邻。
  3. 标签层级与推理:建立「上位词 → 下位词」映射,使搜索「食物」能召回「蛋糕」。

6.6 实现评分

维度 评分 说明
隐私合规 ⭐⭐⭐⭐⭐ 全端侧,符合 [PRIVACY] 红线。
架构设计 ⭐⭐⭐⭐☆ 5-Pass + 队列 + 降级设计合理。
标签体系 ⭐⭐⭐☆☆ 字段冗余、词表过大、归一化有风险。
模型选择 ⭐⭐⭐☆☆ Qwen 做纯标签分类是过度设计。
效率 ⭐⭐☆☆☆ Pass 3 是瓶颈,maxTokens / 去重 / 复用都有优化空间。
可维护性 ⭐⭐⭐☆☆ 文档较全,但代码与文档存在不一致。

7. 性能分析

7.1 当前性能基线(9000 张)

Pass 1 (人脸 ROI + 106关键点 + Glint360K R100 Embedding)  →  Pass 2 (DBSCAN 全局聚类)  →  Pass 3 (Qwen3.5-2B 图像理解)
  ~10-50ms + ~20-80ms + ~30-60ms                                 ~2-5s (一次)                  ~2-8s/张

执行模型:单线程 Foreground Service,专用单线程调度器 singleThreadDispatcher + 3000ms/500ms 两级节流。

7.2 各阶段耗时估算

阶段 单张耗时 9000 张总耗时 占比
Pass 1 人脸检测 + Embedding ~170ms (推理) + 500ms (节流) = ~670ms ~100 min 13%
Pass 2 DBSCAN 全局聚类 一次执行 ~2-5s ~3s <1%
Pass 3 Qwen 图像理解 ~4000ms (推理) + 500ms (节流) = ~4500ms ~11.25 hrs 87%
合计 ~13 hrs

7.3 三大核心瓶颈

瓶颈 1:THROTTLE_MS = 500ms — 硬节流浪费

private const val THROTTLE_MS = 500L  // 每张照片强制 delay

Pass 1 和 Pass 3 的每张照片循环末尾都执行 delay(THROTTLE_MS)。Pass 1 实际推理仅 ~70-150ms,但被 500ms delay 拖成 ~670ms — 节流耗时是推理耗时的 3-7 倍

瓶颈 2:Pass 3 Qwen 推理是物理上限

Qwen3.5-2B 多模态推理使用 engineMutex 串行保护,每张约 2-8s(CPU 模式)。9000 张的理论下限 (2s/张) 即 5 小时

瓶颈 3:Bitmap 双重解码

Pass 1 以 640px 加载,Pass 3 又加载 512px。两次 ContentResolver.openInputStream() + BitmapFactory.decodeStream(),每张浪费 20-50ms。

7.4 节流机制详解

两层节流架构

┌─────────────────────────────────────────────────────────────┐
│  TagGenerationService.checkGuard()     ← 电池/热状态守卫     │
│  ┌─────────────────────────────────────────────────────┐   │
│  │  Battery ≤ 5%           → ABORT(终止扫描)          │   │
│  │  Battery ≤ 15% (非充电)  → PAUSE(→ 3000ms 节流)    │   │
│  │  Thermal ≥ SEVERE       → ABORT                     │   │
│  │  Thermal ≥ MODERATE     → PAUSE(→ 3000ms 节流)    │   │
│  │  其他                   → ALLOW(→ 500ms 节流)     │   │
│  └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────┐
│  TagGenerationScheduler.guardCheck()                        │
│  → 调用 guard() 获取结果                                    │
│    ALLOW  → 正常执行(后续仍要 delay(THROTTLE_MS)=500ms)   │
│    PAUSE  → delay(THROTTLE_RESTRICTED_MS)=3000ms           │
│    ABORT  → return false → 退出循环                         │
└─────────────────────────────────────────────────────────────┘

恒速节流 THROTTLE_MS = 500ms 的作用

作用 说明
热预防 即使”允许”状态,500ms 间隙让 SoC 有散热窗口,避免快速升温到 MODERATE/SEVERE
降低平均功耗 将”连续满载”变为”爆发+休息”模式,降低时间平均功率 (TAP)
Foreground Service 存活 Android 对持续高功耗的 FG Service 会限频甚至杀进程,500ms break 降低被回收概率
减轻 IO 竞争 每次 delay 让 Room WAL checkpoint、ContentResolver 缓存有机会完成
进度更新节流 避免每张照片都触发 StateFlow 更新→UI 重组(每 500ms 一次已足够)

移除节流的潜在害处

热失控 → 性能反而下降:移除 500ms 间隙后,CPU/GPU 持续满负荷运行,SoC 温度持续上升 → 触发强制降频,整体速度反而降低 50%+。

FG Service 被系统回收:Android 12+ 的 Battery Optimization 对前台 Service 有严格的功耗监测。连续 2 分钟 CPU 占用 > 50% 的 FG Service 会被标记为异常,系统可能杀死 Service、强制降低进程优先级或限制后台运行时间。

数据库写入压力:Pass 1 每次循环写入 face_embeddings 和更新 faceRoiResult,Room 的 WAL 机制在连续写入时会有 checkpoint 开销。无间隙的连续写入会导致 WAL 文件快速增长、偶发写入延迟尖峰。

7.5 优化方案

P0:立即实施(低风险,高收益)

1. 移除 Pass 1 的 THROTTLE_MS

2. 减少 Qwen maxTokens 从 128 → 64

3. 降低 Pass 3 的 THROTTLE_MS 到 100ms

P1:中等投入

4. 启用 OpenCL GPU 加速

5. 照片去重跳过 Pass 3

P2:长远优化

6. 合并 Bitmap 解码:Pass 1 的 640px Bitmap 缩放至 512px 供 Pass 3 复用,避免二次 ContentResolver 调用。收益 ~5 分钟(边际收益)。

7. 批量 DB 写入:Pass 1 现在每条 embedding 单独 insert,可以批量 50 条/事务。收益 Pass 1 缩短 ~2-3 分钟。

7.6 优化效果对比

策略 Pass 1 Pass 3 总计 累计降幅
当前基线 ~100 min ~11.25 hrs ~13 hrs
+ 移除 P1 节流 ~25 min ~11.25 hrs ~11.7 hrs 10%
+ maxTokens=64 ~25 min ~7 hrs ~7.4 hrs 43%
+ P3 节流 500→100ms ~25 min ~6.5 hrs ~6.9 hrs 47%
+ OpenCL GPU ~25 min ~3.3 hrs ~3.7 hrs 72%
+ 照片去重 40% ~27 min ~2 hrs ~2.5 hrs 81%
最优组合 ~25 min ~1.5 hrs ~2 hrs 85%

7.7 Pass 2 DBSCAN 性能评估

当前 DBSCAN 是朴素 O(n²) 实现:

for (i in 0 until n)          // n = 所有 face embedding 数量
    for (j in 0 until n)      // O(n²) 全量比较
        cosineDistance(a, b)  // 512 维浮点运算

即便 3000 张有人脸(~3000 embeddings),复杂度为 3000²/2 × 512 ≈ 2.3B FLOPs,手机 CPU 约 1-2 GFLOPS → ~2s

结论:非瓶颈,不需要优化。但如果后续 embedding 数量 > 50000,建议引入 KD-tree / Ball-tree 或近似最近邻。

7.8 最终结论

  1. TAG 生成瓶颈的本质:不是代码效率问题,而是 Qwen 模型 2-8s/张的物理推理延迟。
  2. 最大单点收益maxTokens=64(减 50% decode 时间)+ Pass 1 移除节流(省 75 分钟)。
  3. 节流可以移除但不能完全放弃:Pass 1 安全,Pass 3 建议保留 100ms 最小间隙防止热积累。
  4. 终极方案:去重 + GPU 加速 + 智能节流,可将 13 小时压缩到 2-3 小时

8. 国际化(I18N)

8.1 背景与约束

当前 PicMe 的 TAG 体系存在以下与国际化相关的痛点:

痛点 根因 影响
英文界面搜索无结果 media_assets.labels 中存储的是中文 JSON(如 ["猫","户外"]),LIKE '%cat%' 无法命中 英文用户无法通过自然语言检索照片
TAG 重生成成本高 三阶段生成涉及 InsightFace 人脸检测、Glint360K R100 Embedding、DBSCAN 聚类、Qwen 视觉推理,全量重跑耗时耗电 不能因为切换语言就要求用户重新扫描所有照片
Prompt 与提示词中文硬编码 TagGenerationPipeline.stage3SystemPromptAutoTagCapability 描述、MediaSearchEngine 的 LLM prompt、搜索停用词/城市词均为中文 Agent 在英文界面仍用中文与用户交互
UI 直接展示中文 TAG MediaPager 等详情页把 labels 原样显示 英文用户看到 “男/女/户外” 体验割裂

红线约束:

8.2 设计原则

  1. Canonical 不变:已生成的中文 TAG 作为事实来源,切换语言不触发重写。
  2. 薄本地化层:语言差异在查询和展示时注入,存储层保持中文为主。
  3. 隐私优先:翻译/同义词扩展走本地双语词表。
  4. 渐进式:先让英文能搜、能看;再让新 TAG 按语言生成;最后重构为语言无关概念模型。

8.3 总体架构

┌─────────────────────────────────────────────────────────────┐
│  UI / Search                                                │
│  • 展示:中文 TAG ──TagTranslator──> 英文 displayName         │
│  • 搜索:英文 query ──TagTranslator──> 中文 canonical 集合    │
│         ──> Room LIKE / TagDao 查询                          │
├─────────────────────────────────────────────────────────────┤
│  Runtime Localization Layer(新增)                          │
│  • TagTranslator:zh↔en 映射 + 同义词扩展                     │
│  • BilingualVocab:从 assets/tag_translations.json 加载       │
├─────────────────────────────────────────────────────────────┤
│  Existing Storage(不变)                                    │
│  • media_assets.labels(中文 JSON)                           │
│  • tags / media_tag_cross_ref(可后续迁移)                   │
└─────────────────────────────────────────────────────────────┘

8.4 双语词表与翻译器

新增 domain/tag/i18n/TagTranslator.kt

class TagTranslator(private val vocab: BilingualVocab) {

    /** 把数据库里的中文标签翻译成当前界面语言(用于展示) */
    fun display(chineseTag: String, lang: AppLanguage): String = when (lang) {
        AppLanguage.ENGLISH -> vocab.zhToEn[chineseTag] ?: chineseTag
        else -> chineseTag
    }

    /**
     * 把用户输入的查询词扩展为数据库中可能存在的形式。
     * 例如英文 "cat" -> ["cat", "猫"],这样既能命中未来英文标签,也能命中现有中文标签。
     */
    fun expandForSearch(query: String, uiLang: AppLanguage): Set<String> {
        val result = linkedSetOf(query)
        when (uiLang) {
            AppLanguage.ENGLISH -> {
                vocab.enToZh[query.lowercase()]?.let { result += it }
                vocab.enSynonyms[query.lowercase()]?.let { syn ->
                    vocab.enToZh[syn]?.let { result += it }
                }
            }
            else -> {
                vocab.zhToEn[query]?.let { result += it }
            }
        }
        return result
    }
}

新增 asset app/src/main/assets/tag_translations.json

{
  "zh_to_en": {
    "猫": "cat",
    "狗": "dog",
    "男性": "male",
    "女性": "female",
    "户外": "outdoor",
    "室内": "indoor"
  },
  "en_synonyms": {
    "kitten": "cat",
    "puppy": "dog",
    "guy": "male"
  }
}

词表从 controlled_vocab.json 的 10 个类别导出,核心 TAG 约 600 个。可通过 scripts/generate_tag_translations.py 批量生成初稿,再人工校对高频/敏感词。

8.5 搜索层改造

MediaSearchEngine.executeFilter 中,对每个 keyword 先通过 TagTranslator.expandForSearch 扩展候选词集合,再分别查询:

val uiLang = settingsRepository.getAppLanguageBlocking()
for (keyword in filter.keywords) {
    val candidates = tagTranslator.expandForSearch(keyword, uiLang)
    for (candidate in candidates) {
        tagDao?.searchByExactTag(candidate)?.let { ... }
        mediaDao.searchByLabel(candidate).let { ... }
    }
}

若高频搜索性能吃紧,可在 MediaDao 增加多关键词 OR 查询,减少 SQL 往返。

8.6 UI 展示改造

features/gallery/components/MediaPager.kt 等展示处,对 sceneactivityobjectstagsqwenSummary 经过 TagTranslator.display(...) 映射后再显示。未命中词表时回退原中文。

8.7 Prompt 本地化

抽象 TagPromptProvider

interface TagPromptProvider {
    fun systemPrompt(lang: AppLanguage): String
    fun userPrompt(faceContext: FaceRoiPersist?, lang: AppLanguage): String
}

TagGenerationPipeline 注入 UserSettingsRepository,根据当前 AppLanguage 选择 prompt。这样:

8.8 Phase 1.5:搜索翻译链路增强

问题背景

Phase 0/1 解决了英文用户搜索中文标签的问题,但存在以下缺口:

缺口 根因 影响
中文搜索无法命中 ML Kit 英文标签 mlKitLabels 列存英文(如 ["Cat","Outdoor"]),中文查询 LIKE '%猫%' 无法命中 中文用户搜索丢召回
词表覆盖不全 tag_translations.json 仅 534 条 zh→en,未命中时无回退 长尾中文词搜索丢召回
中文同义词无法扩展 ControlledVocab.reverseSynonyms 仅在标签生成时使用,搜索链路未接入 “猫咪”搜不到”猫”
显式约束管道跳过翻译 ExplicitFirstSearchPipeline 无 TagTranslator “去年在室内猫” 内容词未翻译

ML Kit 标签中文翻译(静态映射)

ML Kit Image Labeling API 使用固定的 ~400 个英文标签。创建静态中英文映射表 assets/mlkit_labels_zh.json,在索引时同时存储中文翻译。

数据流

MlKitTagExtractor → 英文标签 ["Cat","Outdoor","Food"]
                          ↓
              MlKitLabelTranslator.translateToZh()
                          ↓
              中文标签 ["猫","户外","食物"]
                          ↓
         ┌────────────────┼────────────────┐
         ↓                                 ↓
  mlKitLabels (EN)                mlKitLabelsZh (ZH)
  ["Cat","Outdoor","Food"]       ["猫","户外","食物"]

DB Migration 6→7: 新增 media_assets.mlKitLabelsZh TEXT 列。

搜索时: 所有搜索路径同时查询 mlKitLabelsmlKitLabelsZh,确保中英文查询都能命中。

TagTranslator 翻译分层策略

expandForSearch(中文查询词)
  │
  ├─ 1. BilingualVocab.zhToEn 词表精确匹配(0ms)
  │    命中 → 返回 {中文词, 英文词}
  │
  ├─ 2. ControlledVocab 中文同义词双向扩展(0ms)
  │    synonyms: "美女"→"女性"
  │    reverseSynonyms: "女性"→["美女","大美女"]
  │
  ├─ 3. OPUS-MT 模型翻译回退(~50ms,词表未命中时)
  │    中文词 → NMT → 英文词
  │    质量校验:过滤空白/异常输出
  │
  └─ 4. 保留原词兜底

Tokenizer 说明:OPUS-MT 的编解码依赖 :sentencepiece 模块加载的 source.spm / target.spmtokenizer.json 仅用于 Hugging Face token ID 与 SentencePiece piece 之间的映射。详见 ON_DEVICE_INFERENCE_INVENTORY_TECH_SPEC.md

ChineseQueryTranslator CLIP 扩展增强

expandForClip(中文查询)
  │
  ├─ 1. CLIP_QUERY_EXPANSIONS 硬编码表(人工维护,CLIP 优化短语)
  │    如 "小孩"→["child","kid","children","young child"]
  │
  ├─ 2. ControlledVocab 同义词动态翻译(新增)
  │    "美女"→synonym "女性"→translateForClip("女性")→"female"
  │    弥补硬编码表仅 20 条的不足
  │
  └─ 3. translateForClip 基础翻译(vocab→OPUS-MT 回退)

8.9 实施路线图

阶段 目标 改动范围 是否重生成 状态
Phase 0 英文能搜、能看 TagTranslator + tag_translations.json;改造 MediaSearchEngineQueryBuilderMediaPager ✅ 已完成
Phase 1 新照片按语言生成 TagPromptProvider / DefaultTagPromptProvider;按 AppLanguage 切换 prompt 仅新照片 ✅ 已完成
Phase 1.5 中文搜索召回 ML Kit 英文标签 + 中文同义词扩展 mlKitLabelsZh 列、MlKitLabelTranslatorTagTranslator OPUS-MT 回退 + ControlledVocab 同义词 全量重扫 ML Kit(仅新增列) ✅ 已完成 (2026-07-01)
Phase 2 结构化多语言 TAG TagConcept/TagConceptTranslation 表;迁移 TagEntity/MediaTagCrossRef 否(仅迁移) 📋 待规划
Phase 3 搜索体验升级 接入 FTS5 / 前缀索引;支持英文同义词、拼写容错 📋 待规划

8.10 长期架构:语言无关 TAG 概念模型

若后续支持更多语言,中文 canonical 将不再是最佳选择。建议第二阶段引入:

@Entity(tableName = "tag_concepts")
data class TagConcept(
    @PrimaryKey val conceptId: String, // 稳定 key,如 "animal.cat"
    val category: String
)

@Entity(tableName = "tag_concept_translations", primaryKeys = ["conceptId", "locale"])
data class TagConceptTranslation(
    val conceptId: String,
    val locale: String,          // "en", "zh", ...
    val displayName: String,
    val synonyms: String         // JSON 数组
)

该改动需要一次 DB migration 和历史数据迁移,但新增语言时无需重跑视觉模型。

8.11 验收标准


9. 风险与 Mitigation

风险 影响 Mitigation
Qwen 推理耗时过长 全量扫描慢 后台 Service + 可暂停/取消 + 批次冷却
OpenCL 兼容性问题 部分设备挂起 OpenClGuardian warmup + 超时降级 CPU + DataStore 黑名单
人脸聚类误判 同一人分成多簇 支持用户手动合并/重命名;DBSCAN 参数调优
标签质量不可靠 搜索召回差 受控词表规范化 + 只生成场景级粗粒度标签 + 用户可触发重生成
电量消耗 后台扫描费电 仅充电+Wi-Fi 时自动全量扫描;运行时电池/热状态守卫
ML Kit 英文标签与中文 Query 语言不一致 中文搜不到英文标签 QuerySegmenter / LLM 语义解析层负责跨语言桥接;mlKitLabelsZh 静态翻译兜底
编辑距离中文误匹配 标签归一化错误 优先同义词表 + 精确匹配;用 MobileCLIP 语义相似度替代 Levenshtein

10. 关键接口定义

10.1 TagGenerationScheduler(简化)

class TagGenerationScheduler(
    context: Context,
    dispatcher: CoroutineDispatcher = Dispatchers.IO,
    guard: suspend () -> GuardResult = { GuardResult.ALLOW }
) {
    val isScanning: StateFlow<Boolean>
    val progress: StateFlow<TagScanProgress?>
    val lastScanMessage: StateFlow<String?>

    suspend fun processSingle(uri: String, mediaId: Long)
    suspend fun ensureModelLoaded(): Boolean

    enum class GuardResult { ALLOW, PAUSE, ABORT }
}

旧版 scanAll() / scanIncremental() / scanPass1/2/3() 已标记 @Deprecated,请使用 TagScanOrchestrator 替代。

10.2 TagScanOrchestrator(简化)

class TagScanOrchestrator(
    context: Context,
    scheduler: TagGenerationScheduler
) {
    val progress: StateFlow<TagScanSessionProgress?>

    suspend fun scheduleAutoScan(policy: ScanQueuePolicy = ScanQueuePolicy()): String
    suspend fun scheduleRegenerate(mediaIds, categories, mode, policy): String
    suspend fun scheduleRegenerateByQuery(query, categories, mode): String
    suspend fun schedulePass(pass, query, mode, policy): String
    fun pause()
    fun resume()
    fun cancel()
}

11. 后续迭代方向

方向 说明
标签质量提升 优化 Qwen Prompt、扩充 ControlledVocab、引入用户反馈修正
人物簇管理 UI 支持合并、拆分、忽略误检簇
ML Kit 跨语言 完善英文标签 → 中文的翻译映射或语义桥接
语义召回增强 尝试更大 MobileCLIP 变体或端侧多模态模型
自动化测试 增加端到端 Tag 生成回归测试与性能基线
标签分类迁移 将「标签分类」从 Qwen 迁移到 MobileCLIP / Chinese-CLIP 零 shot
层级标签体系 建立「上位词 → 下位词」映射,提升搜索召回