前言

在做智能相册时,“人物”永远是一个非常自然、也非常高价值的入口。

一开始,我们已经有一条围绕整张图片语义 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

这是整条人脸主线真正的“聚类大脑”。

它目前已经形成了一个相对清晰的策略:

  1. 先过滤明显不该进入身份聚类的脸

  2. 再筛出 attach candidateseed candidate

  3. 每张照片只保留一个 representative 参与主结构决策

  4. representative 先形成初始小簇

  5. 小 representative 簇之间先互相 merge

  6. 再按 minClusterSize 判断 stable / small

  7. 然后附属脸做 attach

  8. 最后回写 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 次。

这次在 FaceCropUtilFacePipelineService 里都做成了:

  • 同一张图只 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 初始版本的问题:同一身份一开始就被判死

最开始的流程更接近:

  1. representative 初始建簇

  2. 立刻按 minClusterSize 分 stable / small

  3. small 只能挂到 stable

这个流程的问题很明显:

  • 同一个人明明被拆成多个 1 张/2 张小簇

  • 但它们还没来得及互相 merge

  • 就已经因为 minClusterSize 被判死

5.2 关键修正:先长大,再淘汰

后来流程被改成:

  1. representative 先形成初始小簇

  2. 所有小簇先互相 merge 一轮

  3. merge 完之后再按 minClusterSize 判断 stable / small

  4. 然后附属脸 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 工程

欢迎交流你踩过的坑,或者你当前在做的方案。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐