StellarEyes 的目标是在 Android 设备上完成人脸识别,不把生物特征或图片上传到服务器。应用使用 Kotlin 和 Jetpack Compose 编写界面,CameraX 获取预览帧,ML Kit 做人脸检测,TensorFlow Lite 运行 FaceNet 提取 512 维特征,最后在本地向量库中做余弦相似度匹配。
整体流程
整个流程可以分成两段:检测负责回答“脸在哪里”,识别负责回答“这是谁”。ML Kit 返回人脸框,应用根据人脸框裁剪图像,缩放到 160×160,再交给 FaceNet。两段之间只通过裁剪和缩放衔接,因此可以分别替换。assets 目录里也放过 RetinaFace 模型,用于后续实验。
检测与裁剪
CameraX 的 ImageAnalysis 提供 ImageProxy,转换成 InputImage 后交给 ML Kit。第一版没有传入 rotationDegrees,横屏时检测框整体旋转了 90 度。裁剪还有另一个问题:即使检测框位置正确,如果裁剪使用的坐标系和人脸框不一致,裁出来的图像仍然会偏移。这两个问题需要一起处理。
特征提取与推理
FaceNet 的输入是 160×160 的 RGB 浮点张量,数值归一化到 [-1, 1],输出 512 维向量。TensorFlow Lite 的 GPU delegate 有一个约束:Interpreter 必须在创建它的线程上调用。早期版本在协程中初始化、在另一个线程推理,应用会直接崩溃。现在的实现使用单线程调度器,把初始化和推理固定在同一条线程上。
GPU delegate 会先用 CompatibilityList 检查设备支持情况,不支持时回退到 CPU。低端机和部分驱动异常的机型会走到这条路径,否则应用启动阶段就会失败。
预处理是逐像素完成的,通过位运算取出 RGB 分量,避免 getPixel 每次返回对象带来的开销。160×160 只有 25600 个像素,在 Kotlin 中执行一次的成本可以接受。代码里还保留着一套基于 StandardizeOp 的预处理,但主路径没有使用它,实际生效的是手动的 (p - 127.5) / 128。两套实现并存容易造成误解。
摄像头每秒产生 30 帧,没有必要每帧都执行推理。持续推理模式下使用 100ms 的最小间隔,将频率限制在 10fps,降低发热和耗电。手动识别时直接调用不节流的版本。
向量检索
检索部分使用一个 MutableList 保存人脸向量,线性扫描计算余弦相似度,阈值设为 0.84。几百张人脸的规模下,线性扫描足够使用,因此没有引入向量索引。阈值不是理论推导的结果,而是在自己的设备、光照和测试人群上调整出来的:过低会认错人,过高会拒绝本人。它表示相似度,不是身份认证强度。
模型输出的向量不一定有效,GPU delegate 偶尔会产生 NaN 或 Infinity。查询前会先把非有限值清零,再做 L2 归一化;全零向量返回零数组,避免除零。归一化之后余弦相似度等价于点积,但代码中保留了完整公式,便于将来使用非归一化向量。
持久化与并发
并发方面,识别在后台线程执行,UI 线程可能同时修改或删除人脸记录。查询接口返回的是防御性拷贝,向量本身也会复制,否则一个线程修改数组、另一个线程同时计算相似度时会产生难以复现的读取错误。代价是每次检索多分配一些内存,在当前规模下可以接受。
向量保存在 filesDir/vectors.json,使用 kotlinx.serialization 整体读写。姓名、描述、头像和自定义字段放在单独的 face_catalog.json 中,两个文件通过同一个 id 关联。这样检索层只处理向量,“是谁”和“有什么备注”由另一层负责。
NCNN 的部分
项目里还集成了 NCNN:assets 中有 retinaface、yolo 和 mbv2facenet 模型,main.cpp 通过 JNI 加载并启用 Vulkan。这部分更像一条已经铺好、尚未启用的支线——模型、入口和开关都在,但识别流程仍然沿 TensorFlow Lite 展开。README 中“使用 NCNN 计算余弦相似度”的说法,描述的是这条支线的目标,而非主链路当下的走向;文档与实现之间的这点距离,也如实反映了项目在两种方案之间的取舍。
隐私限制
隐私是这个项目的核心限制。向量和图片只保存在应用私有目录,不联网。但 vectors.json 是明文 JSON,头像以 base64 存储,设备被 root 或备份被导出时数据会泄露。应用没有活体检测,照片对着摄像头也可以通过识别。导出功能会把整个人脸库写入公共下载目录。这些限制决定了它适合用于本地相册整理,而不适合门禁等需要可靠身份验证的场景。
这条流程涉及的环节不算复杂,但需要同时处理线程、GPU delegate、数值稳定性、并发和隐私。相比单独运行一个模型,把这些部分组合成稳定可用的链路是更主要的工作量。