从“人物主题”到“身份级记忆”:智能影记新增人脸聚类主线与调试能力实践
本文介绍了智能相册项目中新增人脸聚类主线的关键决策与技术实现。针对原有"人物主题聚类"无法满足身份识别需求的问题,团队建立了独立的人脸聚类系统,核心包括:1) 新增FaceEntity按脸存储数据;2) 构建完整的人脸检测-裁剪-特征提取-聚类链路;3) 实现可替换的FaceEmbeddingService抽象层;4) 开发可视化调试页面。该系统采用"先长大再淘汰&q
前言
在做智能相册时,“人物”永远是一个非常自然、也非常高价值的入口。
一开始,我们已经有一条围绕整张图片语义 embedding 的人物主题链路,它可以比较好地回答这类问题:
-
这是不是一张人物照片
-
这些照片能不能组成一个“人物主题”
-
这些照片在 UI 上能不能被组织成某种人物时刻
但随着项目推进,我们越来越清楚地意识到:人物主题聚类 和 人脸身份聚类 根本不是同一个问题。
前者更偏“主题召回”,后者则是在回答:
-
这几张脸是不是同一个人
-
一张合影里的多张脸分别属于哪个身份簇
-
同一个人在不同场景、不同时间里能不能被重新串起来
如果继续把“身份聚类”逻辑往旧的人物主题链路里堆,最后只会得到一个越来越难维护、同时两边都做不好的混合系统。
所以这次我们做了一个非常关键的架构决策:
旧的人物主题链路继续负责“人物主题粗聚类”
新的人脸链路独立负责“按脸建模、按脸聚类、按身份组织”
也正是在这个判断下,这次新增的人脸聚类主线正式落地。
一、为什么要单独做一条人脸聚类主线
1.1 旧链路擅长的是“人物主题”,不是“人物身份”
原有链路本质上是围绕整张图片的视觉语义建模,它更适合做:
-
人物照片筛选
-
人物相关照片主题组织
-
某一类“有人的时刻”聚合
但如果把它继续拿来做身份聚类,就会遇到天然问题:
-
同一张照片里多张脸无法独立建模
-
同一张图里的主脸和次脸难以区分
-
低质量脸、偏侧脸、遮挡脸难以单独剔除
-
cluster merge / reassign 没有真正的落点
1.2 身份聚类必须“按脸存数据”
只要目标变成“同一个人能不能跨场景串起来”,数据建模就必须从“按图”切换到“按脸”。
这意味着系统需要:
-
先检测出一张图中的多张脸
-
每张脸单独裁剪
-
每张脸单独产 embedding
-
每张脸单独落库
-
每张脸独立参与聚类与回写
也就是说,人脸聚类必须是一条独立主线,而不是旧人物主题链路上的补丁。
二、这次提交的核心目标
这次新增人脸聚类主线,目标非常明确:
2.1 新建“按脸存数据”的底层结构
不再直接按照片讨论“人物身份”,而是给每一张被检测到的人脸建立独立记录。
2.2 新建“检测 -> 裁脸 -> embedding -> 聚类 -> 调试”的完整链路
完整链路如下:
人脸检测 -> 裁脸 -> embedding -> 落库 -> 聚类 -> 调试页观察
2.3 不污染旧主链路
整条人脸聚类主线尽量独立实现,不继续把旧的人物主题聚类文件堆成大杂烩。
三、这次新增的核心模块
这次新增的内容并不是一个单点功能,而是一条比较完整的新子系统。
3.1 数据层:FaceEntity
新增文件:
lib/models/entity/face_entity.dart
这是整条人脸主线最关键的底层结构。
过去系统里,一张照片对应一条 PhotoEntity 记录;但在人物身份场景下,这远远不够。因为一张合影里可能有 2、3、4 张脸,它们必须分别建模、分别聚类、分别回写结果。
所以我们新增了 FaceEntity,专门按“脸”来存储数据。每条记录包含的典型信息有:
-
photoId -
assetId -
faceIndex -
bbox(left / top / right / bottom) -
roll / yaw -
smilingProbability -
leftEyeOpenProbability / rightEyeOpenProbability -
embedding -
embeddingModelVersion -
qualityScore -
clusterId -
isPrimaryFace -
debugCropPath
这一层一旦建立起来,后面很多复杂问题才真正变得可解:
-
一张图里多张脸可以分别聚类
-
主脸和非主脸可以区分
-
低质量脸可以单独剔除
-
cluster merge / reassign 终于有了真实落点
3.2 Embedding 抽象层:FaceEmbeddingService
新增文件:
lib/service/face_embedding_service.dart
这层的目标不是直接做聚类,而是把“人脸 embedding 能力”抽象成统一接口。
当前接口很简单:
-
warmUp() -
resetWarmState() -
embedFaceCrop(File imageFile)
这样做的核心好处是:
上层只依赖
FaceEmbeddingService,不关心底层到底用的是哪一个 embedding 模型。
这让整个系统具备了非常好的可替换性:
-
现在可以先接一个 baseline
-
后面可以切到专用 ArcFace / InsightFace ONNX 模型
-
聚类层、调试页、数据层都不需要跟着重写
这一步在工程上非常关键,因为它让“模型”从一次性写死的实现,变成了一个真正可以替换的模块。
3.3 Baseline 与专用模型接入口
在人脸 embedding 层,这次实际上做了两层实现。
(1)MobileClipFaceEmbeddingService
文件:
lib/service/face_embedding_service.dart
这一层保留了 MobileCLIP2 + face crop 作为 baseline / fallback 方案。
这个 baseline 有实际价值,因为它已经验证过:
-
可以做人脸“近似外观”粗聚类
-
可以抓住近重复自拍
-
但不适合做真正的身份聚类
所以它现在不再被当作最终方案,而是:
-
fallback
-
debug 对照组
-
没有专用模型时的保底路线
(2)OnnxFaceEmbeddingService
新增文件:
lib/service/onnx_face_embedding_service.dart
这一步是新主线里最重要的落地之一。
它负责:
-
读取专用 ONNX 人脸 embedding 模型
-
根据模型要求做人脸 crop 预处理
-
调用 ONNX Runtime 推理
-
输出 embedding,并回写
embeddingModelVersion
并且已经支持通过 dart-define 配置以下参数:
-
FACE_EMBEDDING_ONNX_FILE -
FACE_EMBEDDING_ONNX_ASSET -
FACE_EMBEDDING_MODEL_VERSION -
FACE_EMBEDDING_INPUT_SIZE -
FACE_EMBEDDING_INPUT_LAYOUT -
FACE_EMBEDDING_MEAN -
FACE_EMBEDDING_STD
实际策略采用的是:
优先走专用 ONNX face embedding
如果模型不存在或推理失败,则自动回退到 MobileCLIP baseline
这样不会因为模型还没就位,整条 face pipeline 直接失效。
3.4 裁脸工具层:FaceCropUtil
新增文件:
lib/utils/face_crop_util.dart
这一层的目标是把“从原图裁出人脸”这件事标准化。
它主要做了几件事:
-
同一张图只解码一次,避免多脸场景重复 decode
-
根据 bbox 裁出人脸区域
-
生成固定尺寸的调试缩略图
-
输出临时文件供 embedding 服务使用
这一层看似不起眼,但非常重要,因为它同时解决了几个常见工程问题:
-
多人脸场景的重复解码性能浪费
-
调试页预览裁偏
-
后续 embedding 输入不统一
3.5 Pipeline 层:FacePipelineService
新增文件:
lib/service/face_pipeline_service.dart
这一层是真正把整条人脸主线串起来的“流程服务”。
它负责:
-
复用现有 ML Kit 人脸检测结果
-
读取原图
-
裁出每一张脸
-
写 debug crop
-
调 embedding service
-
估算质量分
-
落库到
FaceEntity
这层和旧的人物主题链路是分开的。它不直接参与 UI 主题展示,也不直接决定人物主题页,而只是负责把“按脸的数据资产”准备好。
这个设计原则非常重要:
先把按脸建模的底盘打出来
再决定上层产品怎么消费它
3.6 聚类层:FaceClusterService
新增文件:
lib/service/face_cluster_service.dart
这是整条人脸主线真正的“聚类大脑”。
它目前已经形成了一个相对清晰的策略:
-
先过滤明显不该进入身份聚类的脸
-
再筛出
attach candidate与seed candidate -
每张照片只保留一个 representative 参与主结构决策
-
representative 先形成初始小簇
-
小 representative 簇之间先互相 merge
-
再按
minClusterSize判断 stable / small -
然后附属脸做 attach
-
最后回写
clusterId
这条链路里,后期还经历了一轮关键修正:
流程从“先判死,再看能不能并”
改成了“先长大,再决定谁淘汰”
这一步非常重要,因为它直接解决了同一身份被拆成多个小簇、但一开始就因为 minClusterSize 被判死的问题。
3.7 调试页:FaceClusterDebugPage
新增文件:
lib/view/pages/face_cluster_debug_page.dart
如果让我评价这次提交里最值钱的功能之一,那一定是调试页。
因为人脸聚类不是写完代码就结束,它必须是一个可观察、可诊断、可迭代的系统。没有调试页,后面所有阈值、merge、过滤、cover 选择都会变成盲调。
当前调试页已经支持:
-
显示总脸数
-
显示已分簇 / 未分簇数量
-
显示
真人未匹配 / 已拒绝 -
显示每个 cluster 的:
-
clusterId -
size -
averageQuality -
embeddingModelVersion
-
-
显示每个成员的:
-
face crop 缩略图
-
faceId -
photoId -
qualityScore -
isPrimaryFace
-
-
支持重新聚类
-
支持仅看大簇
更重要的是,调试页后来还做了一轮非常关键的修正:
不再在页面里临时用原图 + bbox 做动态裁剪
而是直接显示 pipeline 阶段生成的固定尺寸debugCropPath
这样最终绕开了很多老问题:
-
原图 / 压缩图尺寸不一致
-
EXIF 方向
-
bbox 坐标系
-
Flutter 布局裁切误差
也就是说,这个调试页最终变成了一个可信的观察工具,而不是“看起来有图,但其实坐标可能错了”的半成品。
3.8 调试入口:ProfilePage
修改文件:
lib/view/pages/profile_page.dart
为了不污染正式主流程,这个调试页没有挂到普通用户入口,而是放到了“我的 -> 开发者工具”里。
这个落点很合理:
-
不影响正常用户路径
-
但也不至于藏得太深
-
作为开发阶段工具非常顺手
四、这次做了哪些关键工程优化
除了主线搭起来之外,这次还有几处很值得记录的工程细节。
4.1 FaceEntity 生命周期清理补齐
这次专门补了很多“人脸数据不能变脏”的收尾动作。
例如在这些路径上:
-
清空本地缓存
-
重建相册缓存
-
重新加入 AI 打标队列
-
migrateToMobileClip()
都补上了 FaceEntity 的同步清理。
这一点非常关键,因为如果只重置 PhotoEntity,不重置 FaceEntity,就会出现:
-
照片状态已经重置
-
但旧的人脸 embedding / clusterId 还留在库里
那后面无论调聚类还是看调试页,都会被历史脏数据误导。
4.2 同图只解码一次
一张合影如果有 5 张脸,最糟糕的实现就是原图 decode 5 次。
这次在 FaceCropUtil 和 FacePipelineService 里都做成了:
-
同一张图只 decode 一次
-
后续多脸循环里复用 decoded image
这是一个非常值的性能优化,尤其是在多人脸场景里。
4.3 Warm-up 缓存
Embedding 如果每张脸都重新 warmUpBackend(),整条链路会非常慢。
所以这次在 FaceEmbeddingService 里加了:
-
warm 状态缓存
-
缓存失效入口
避免每张脸都重复预热。
4.4 Debug crop 文件唯一化
调试缩略图最开始有一个很隐蔽但很致命的问题:
如果 debugCropPath 固定命名,那么旧记录清理时可能会把新生成的同名文件一起删掉,导致调试页明明写了新路径,却显示空白。
后来把 debug crop 改成带唯一后缀之后,这个问题才真正解决。
4.5 Representative 策略
为了防止一张照片里多张近似 crop 反复参与主结构决策,这次新增了一个很关键的策略:
每张照片只保留一个 representative 参与主聚类 / 主合并
其他普通脸只在后面作为附属脸 attach
这一步对降低:
-
重复投票
-
误桥接
-
同图内部噪声干扰
都非常有帮助。
五、聚类策略是怎么一步步演进的
这次人脸聚类并不是“一次写完就正确”,而是在调试页和 pairwise 日志的支持下,一轮一轮收口出来的。
5.1 初始版本的问题:同一身份一开始就被判死
最开始的流程更接近:
-
representative 初始建簇
-
立刻按
minClusterSize分 stable / small -
small 只能挂到 stable
这个流程的问题很明显:
-
同一个人明明被拆成多个 1 张/2 张小簇
-
但它们还没来得及互相 merge
-
就已经因为
minClusterSize被判死
5.2 关键修正:先长大,再淘汰
后来流程被改成:
-
representative 先形成初始小簇
-
所有小簇先互相 merge 一轮
-
merge 完之后再按
minClusterSize判断 stable / small -
然后附属脸 attach
也就是:
先长大,再淘汰
这一步的收益非常明显,它把问题从:
-
“小簇活不下来”
推进成:
-
“小簇活下来了,但彼此还碎着”
这其实是一个明显进步。
5.3 调试能力升级:从看结果,到看原因
为了不再靠肉眼猜,我们后来又补了 cluster pair 诊断日志,输出:
-
centroid -
cover -
maxPair -
bestFaces
同时把未分簇拆成了:
-
真人未匹配 -
已拒绝
这样后续每一轮实验都可以明确判断:
-
是过滤在挡垃圾样本
-
还是聚类召回不够
-
是小簇 merge 太严
-
还是 cover 代表脸逻辑出了问题
这一步让系统真正从“玄学调参”进入了“证据驱动调优”。
六、这条新主线目前做到了什么程度
如果按阶段来划分,我会这样看当前进度。
阶段 1:底盘建立 —— 已完成
这一阶段已经完成:
-
按脸存数据成立
-
按脸裁剪成立
-
按脸 embedding 成立
-
按脸聚类成立
-
调试页观察成立
阶段 2:Baseline 验证与模型替换 —— 已跨过去
这一阶段也基本完成:
-
MobileCLIP face crop baseline已经验证过,结论是:-
能做近似外观粗聚类
-
不适合最终身份聚类
-
-
专用 ONNX face embedding 已接入接口层
-
后续可直接切模型,而不必推倒重来
阶段 3:聚类策略收口 —— 正在推进
当前系统已经进入更高阶的问题处理阶段:
-
非真人过滤
-
seed / attach 分层
-
representative 主导结构
-
小簇先 merge 再判死
-
rejected / human unmatched 拆桶
-
pairwise cluster diagnosis
也就是说,现在已经不再是处理“低级错误很多”的阶段,而是在处理真正更高级的目标:
同一身份跨场景、跨发型、跨阶段的连续性组织
七、这次提交最重要的价值是什么
如果让我总结这次提交最有价值的地方,不是代码量,而是边界终于清晰了。
7.1 旧主题链路没有继续被污染
-
theme_subclustering.dart继续负责“人物主题粗聚类” -
新的人脸链路独立负责“按脸建模与按身份聚类”
这个边界守住了,后续工程才会越走越顺。
7.2 观察能力建立了
有了 FaceClusterDebugPage 之后,后续所有聚类优化终于可以:
-
看真实簇
-
看真实代表脸
-
看簇间相似度
-
看 rejected 与 unmatched 分布
这比继续闷头调阈值值钱得多。
7.3 模型从“可选项”变成了“可替换项”
现在的系统不再是:
-
我们只能在 MobileCLIP 上不断修修补补
而是:
FaceEmbeddingService 可以替换
上层 pipeline / clustering / debug page 都能继续复用
这才是一个健康的移动端 AI 工程结构。
八、下一步准备怎么继续做
这次提交并没有一次性解决所有问题,但它把系统推进到了一个真正正确的位置。
接下来更值得继续做的方向有四个。
8.1 继续强化 small cluster merge
当前系统已经不是“乱混”,而是更典型的:
活着,但碎着
所以下一步还会继续围绕小 representative 簇之间的 merge 做更细的诊断与收口。
8.2 优化 cover / representative 逻辑
当前日志已经开始暴露一个问题:
-
有些簇整体很像
-
但单个 cover face 并不能很好代表整个簇
这说明 cover 代表脸逻辑后面还会继续优化。
8.3 引入时间 / 事件先验
因为智能影记本来就是一个相册叙事产品,不是纯人脸识别系统。
系统天然拥有这些信息:
-
时间
-
地点
-
事件
-
连拍关系
这些上下文先验都非常适合帮助“同一个人跨阶段再连接”。
8.4 长期演进成“身份簇 + 阶段子簇”
这可能是最适合相册产品的最终形态:
-
第一层:同一个人
-
第二层:这个人在不同时间 / 不同外观阶段的小簇
这样既保留了身份连续性,也保留了回忆本身的阶段感。
九、总结
这次提交真正完成的,不是一个“最终可交付的人脸聚类功能”,而是一条可以继续进化的人脸聚类新主线。
它已经具备:
-
独立数据层
-
独立 pipeline
-
独立 embedding 接口
-
独立聚类服务
-
独立调试页
-
可替换模型能力
-
基础回归测试
在工程上,这已经不是“试试看”的实验,而是一个真正站住了的子系统。
如果说过去我们的人物组织能力还停留在“语义主题级别”,那么这次提交最大的意义就在于:
智能影记开始真正拥有了“按脸建模、按脸聚类、按身份观察和调优”的能力。
而这,正是人物记忆组织能力从“主题聚合”迈向“身份级记忆”的第一步。
结尾
如果你也在做这些方向:
-
端侧相册聚类
-
多模态照片检索
-
人脸 embedding 落地
-
Flutter + ONNX Runtime 的移动端 AI 工程
欢迎交流你踩过的坑,或者你当前在做的方案。
更多推荐
所有评论(0)