简介:该资源为SeetaFace6人脸识别算法的Android端DEMO工程,面向需要在移动端集成人脸检测、关键点定位、人脸识别等能力的Android开发者,尤其适合具备一定NDK与JNI基础的中高级工程师。工程同时提供arm64-v8a与armeabi-v7a两套so库,可直接用于真机调试与二次开发。压缩包共565个文件,约146.83MB,包含60个so动态库、276个hpp与87个h头文件、75个xml配置、10个bin模型文件及若干java、gradle构建脚本,覆盖算法接口、模型资源与工程配置。SeetaFace6除基础人脸能力外,还支持活体检测、质量评估、年龄性别估计,并开放口罩检测及佩戴口罩场景下的人脸识别模型,便于落地门禁、考勤等场景。目前已有751人学习下载,可帮助读者省去手动编译环节,快速跑通Android端人脸识别流程,并对照工程结构理解各模块调用关系。
1. 拿到一份 seetaface6 Android 工程,先别急着点 Run
如果你正在找一个能在 Android 上直接跑起来的人脸识别工程,大概率已经翻过一圈 GitHub,最后卡在同一个地方:so 库对不上。seetaface6 的 Android 集成,核心难点从来不是 Java 层怎么写,而是 arm64-v8a 和 armeabi-v7a 两套 so 库怎么放、怎么加载、怎么保证 ABI 匹配。这份工程的价值就在这——它把两套 ABI 的 so 库都打包好了,省掉你自己交叉编译的那一步。
它适合两类人:一是想快速验证 seetaface6 在 Android 端识别效果的开发者,二是需要把现成人脸检测/识别能力嵌进自己 App 的工程师。你不需要懂 NDK 编译链,但需要知道 jniLibs 目录结构、System.loadLibrary 的加载顺序,以及不同 ABI 下模型文件的差异。下面按「工程结构 → 集成步骤 → 参数调优 → 避坑」的顺序拆开讲。
2. 工程结构与 so 库加载机制:jniLibs 里到底放了什么
2.1 两套 ABI 的目录布局与取舍
拿到工程后第一件事是展开app/src/main/jniLibs,你会看到两个目录:arm64-v8a和armeabi-v7a。每个目录下是一组.so文件,通常包括libSeetaFaceDetector.so、libSeetaFaceRecognizer.so、libSeetaFaceTracker.so以及底层的libseeta_*系列。这些库之间存在依赖关系,不是随便丢进去就能加载。
为什么同时保留两套?因为 2020 年之后的真机基本是 arm64,但部分老设备、模拟器、以及某些定制 ROM 仍然跑在 armeabi-v7a 上。如果你只放 arm64-v8a,在 32 位设备上安装时不会报错,但运行到System.loadLibrary那一刻直接抛UnsatisfiedLinkError,而且错误信息往往只告诉你「找不到库」,不会告诉你「ABI 不匹配」。这是最常见的翻车点。
工程里两套都给了,意味着你在build.gradle里不需要写abiFilters去过滤,Gradle 会自动按设备 ABI 打包对应的 so。但要注意:如果你自己的 App 还依赖其他第三方 so,混用 ABI 会导致安装包体积膨胀,这时候才需要考虑用splits或abiFilters做裁剪。
2.2 System.loadLibrary 的加载顺序与依赖链
seetaface6 的 so 库不是平铺的,libSeetaFaceDetector.so依赖libseeta_common.so之类的底层库。Java 层调用System.loadLibrary("SeetaFaceDetector")时,Android 的 linker 会自动解析依赖,但前提是依赖库也在同一目录且名字匹配。
常见做法是在 Application 或 Activity 的静态块里按顺序加载:
static { // 先加载底层公共库,再加载上层功能库 System.loadLibrary("seeta_common"); System.loadLibrary("SeetaFaceDetector"); System.loadLibrary("SeetaFaceRecognizer"); System.loadLibrary("SeetaFaceTracker"); }逻辑说明:System.loadLibrary传入的是去掉lib前缀和.so后缀的名字。加载顺序上,虽然 linker 有自动依赖解析,但显式按依赖顺序加载能避免某些 ROM 上 linker 的玄学问题。参数方面没有可调项,但如果你把库放在非标准目录(比如从 assets 解压到私有目录),就需要改用System.load传绝对路径。
提示:如果你在
jniLibs里放了库但运行时报UnsatisfiedLinkError,先用adb shell进到/data/app/包名/lib/下ls看一眼,确认 so 是否真的被打进 APK。很多时候是sourceSets配置写错,Gradle 根本没把 jniLibs 当资源。
2.3 模型文件与 so 库的版本对应关系
so 库只是推理引擎,真正干活的是模型文件。seetaface6 的模型通常放在assets目录下,包括检测模型、识别模型、关键点模型等。这些模型文件和 so 库的版本是绑定的——用 v6 的 so 加载 v5 的模型,轻则精度暴跌,重则直接 crash。
工程里一般会在assets下建一个seetaface子目录,把.csta或.dat模型文件放进去。首次运行时需要把模型从 assets 拷贝到应用私有目录,因为 seetaface6 的 C++ 接口通常只接受文件路径,不接受 assets 流。拷贝逻辑写在 Java 层:
private void copyAssets() { String[] files = {"detector.csta", "recognizer.csta", "tracker.csta"}; File destDir = new File(getFilesDir(), "seetaface"); if (!destDir.exists()) destDir.mkdirs(); for (String name : files) { File out = new File(destDir, name); if (out.exists()) continue; // 已拷贝过就跳过,避免重复 IO try (InputStream is = getAssets().open("seetaface/" + name); OutputStream os = new FileOutputStream(out)) { byte[] buf = new byte[8192]; int len; while ((len = is.read(buf)) > 0) os.write(buf, 0, len); } catch (IOException e) { Log.e("SeetaCopy", "拷贝失败: " + name, e); } } }逻辑说明:这段代码在首次启动时把模型文件从 assets 复制到getFilesDir()/seetaface/。参数上,缓冲区 8192 字节是常规选择,模型文件通常几 MB 到几十 MB,拷贝耗时在几百毫秒量级,建议放在子线程。if (out.exists()) continue是后悔药——避免每次启动都重复拷贝,也防止拷贝中途被杀导致文件损坏后无法恢复。
3. 从零跑通 Demo:权限、相机与识别流程
3.1 动态权限与相机预览的初始化
Android 6.0 之后相机权限必须动态申请,这份工程如果没处理,你在 Android 10+ 设备上点开就是黑屏。需要申请CAMERA,如果涉及保存识别结果到相册还需要WRITE_EXTERNAL_STORAGE(Android 10 以下)或READ_MEDIA_IMAGES(Android 13+)。
相机预览一般用CameraX或Camera2。seetaface6 的 Demo 常见做法是用CameraX的ImageAnalysis拿到ImageProxy,转成 NV21 或 RGB 字节数组后传给 native 层。这里有个坑:ImageProxy的格式在不同设备上可能是 YUV_420_888,需要自己做 YUV 转 RGB,否则传给 seetaface6 的图像是花的。
// CameraX ImageAnalysis 回调中处理 ImageAnalysis.Analyzer analyzer = imageProxy -> { // 将 YUV_420_888 转为 NV21 byte[] nv21 = yuv420ToNv21(imageProxy); // 调用 native 检测接口 nativeDetect(nv21, imageProxy.getWidth(), imageProxy.getHeight()); imageProxy.close(); // 必须关闭,否则后续帧无法到达 };逻辑说明:yuv420ToNv21是自定义转换函数,核心是把 Y、U、V 三个 plane 按 NV21 的排列重新组装。参数上,imageProxy.getWidth()和getHeight()是图像尺寸,seetaface6 的检测接口通常要求传入图像宽高和通道数。imageProxy.close()必须调用,否则分析器只会收到第一帧,这是 CameraX 的背压机制。
3.2 native 接口的 JNI 签名与返回值解析
Java 层声明 native 方法时,包名和类名必须和 so 库里的 JNI 函数签名完全一致。常见形式:
public class SeetaEngine { public native long nativeCreateDetector(String modelPath); public native FaceInfo[] nativeDetect(long handle, byte[] data, int width, int height); public native void nativeDestroy(long handle); }对应的 C++ 侧函数名是Java_包名_类名_方法名,包名里的点换成下划线。如果签名对不上,System.loadLibrary能成功,但调用 native 方法时抛UnsatisfiedLinkError: No implementation found for ...。
返回值FaceInfo[]里通常包含人脸框坐标、置信度、关键点。解析时注意坐标系:seetaface6 返回的坐标一般以图像左上角为原点,但相机预览可能有旋转和镜像,需要根据ImageProxy.getImageInfo().getRotationDegrees()做变换,否则框会偏。
3.3 识别流程的串联:检测 → 对齐 → 特征提取 → 比对
完整的人脸识别不是一步到位,而是四步流水线。检测器先框出人脸,关键点模型定位五官,对齐模块把脸摆正,识别模型提取特征向量,最后算两个向量的余弦相似度。
| 步骤 | 输入 | 输出 | 常见参数 |
|---|---|---|---|
| 检测 | 原始图像 | 人脸框 + 置信度 | 最小人脸尺寸 80px |
| 关键点 | 人脸框区域 | 5 点坐标 | 无 |
| 对齐 | 图像 + 关键点 | 对齐后的人脸图 | 目标尺寸 112x112 |
| 识别 | 对齐人脸图 | 512 维特征向量 | 相似度阈值 0.7 |
相似度阈值是调优重点。0.7 是常见起点,但实际项目里要根据误识率和拒识率曲线来定。安防场景宁可拒识不可误识,阈值往 0.8 调;体验优先的场景可以降到 0.6。这个值没有标准答案,必须用你自己的测试集跑 ROC 曲线。
4. 避坑与排查:so 库、模型、线程的五个血泪经验
4.1 现象:安装成功但运行闪退,日志只有 UnsatisfiedLinkError
原因:APK 里没有对应 ABI 的 so,或者 so 被 Gradle 的abiFilters过滤掉了。有些工程在build.gradle里写了ndk { abiFilters 'arm64-v8a' },但你的测试机是 32 位。
解决:先确认设备 ABI,adb shell getprop ro.product.cpu.abi。然后检查build.gradle是否过滤了 ABI,以及jniLibs目录名是否拼写正确(是armeabi-v7a不是armeabi-v7)。
4.2 现象:检测框位置偏移,或者框比脸大一圈
原因:图像旋转角度没处理,或者 YUV 转 RGB 时宽高搞反了。相机传感器方向和应用界面方向经常差 90 度。
解决:在ImageAnalysis里设置setTargetRotation,并在 native 调用前根据rotationDegrees做矩阵变换。如果是前置摄像头,还要做水平镜像。
4.3 现象:连续识别几帧后卡死,或者只识别第一帧
原因:ImageProxy没有close(),CameraX 的背压机制导致后续帧不再投递。或者 native 层持有全局引用导致内存泄漏。
解决:确保每帧分析完立即imageProxy.close()。native 层创建的 handle 要在onDestroy里调nativeDestroy释放,否则多次进出页面会 OOM。
4.4 现象:模型拷贝失败,native 层报「模型文件不存在」
原因:assets 里的模型文件没被打进 APK,或者拷贝路径和 native 传入路径不一致。有些工程把模型放在assets/seetaface/,但拷贝时写成了assets/models/。
解决:用adb shell run-as 包名 ls files/seetaface/确认拷贝结果。路径拼接用new File(getFilesDir(), "seetaface/detector.csta").getAbsolutePath(),不要手写字符串。
4.5 现象:armeabi-v7a 设备上识别速度极慢,甚至 ANR
原因:32 位 so 在低端设备上推理性能本来就差,加上如果模型是浮点版本,没有 NEON 加速会更慢。
解决:确认 so 库是否开启了 NEON 优化。如果必须支持 32 位,考虑降低输入分辨率或跳帧检测。实在不行,在build.gradle里只保留 arm64-v8a,放弃 32 位设备。
5. 进阶技巧:用相似度阈值和特征缓存把识别做稳
跑通 Demo 只是第一步,真正上线要考虑的是稳定性和性能。我一般会做两件事:一是把相似度阈值做成可配置项,二是对已识别的人脸做特征缓存,避免每帧都跑完整识别流程。
阈值配置可以放在SharedPreferences或远程配置里,方便不同场景切换。特征缓存则用一个LruCache<String, float[]>,key 是人脸追踪 ID,value 是特征向量。追踪器给出稳定 ID 后,同一张脸在连续帧里只提取一次特征,后续帧直接复用,CPU 占用能降一半以上。
// 特征缓存示例 private LruCache<Integer, float[]> featureCache = new LruCache<>(20); public float[] getFeature(int trackId, byte[] alignedFace) { float[] cached = featureCache.get(trackId); if (cached != null) return cached; // 命中缓存,跳过推理 float[] feature = nativeExtractFeature(alignedFace); featureCache.put(trackId, feature); return feature; }逻辑说明:LruCache容量设为 20,意味着最多缓存 20 张人脸的特征。参数上,容量太大会占内存,太小会频繁失效。trackId由追踪器分配,人脸离开画面后 ID 失效,缓存自然淘汰。注意特征向量是 float 数组,512 维的话每个约 2KB,20 个也就 40KB,内存压力可以忽略。
另一个技巧是异步识别。检测可以在相机线程做,但特征提取和比对放到独立线程池,避免阻塞预览。用ExecutorService固定两个线程,配合Future拿结果,界面线程只负责画框。
从那以后我每次集成 seetaface6 到新工程,都会先把 ABI 确认、模型拷贝、权限申请这三步走一遍再写业务代码,省得后面在 native 崩溃里大海捞针。希望帮到你。
本文还有配套的精品资源,点击获取