SeetaFace6 Android DEMO:双ABI so库与JNI集成实战
简介本资源为SeetaFace6人脸识别算法的Android DEMO工程面向需要在移动端集成人脸检测、关键点定位、人脸识别及活体检测等能力的Android开发者尤其适合具备一定NDK与JNI基础的中高级工程师。压缩包共565个文件约146.83MB包含arm64-v8a与armeabi-v7a两套so动态库以及大量hpp、h头文件、xml模型配置、bin模型权重、java源码与gradle构建脚本覆盖从底层算法库到上层调用示例的完整结构。工程基于中科视拓2020年开源的SeetaFace6除基础人脸识别能力外还支持口罩检测与口罩佩戴场景下的人脸识别、年龄性别估计、质量评估等扩展模型。已有751人学习下载读者可直接获得可编译运行的Android工程骨架、双架构so库与配套模型文件便于快速验证算法效果、对照手动编译流程排查环境问题并在此基础上二次开发人脸识别应用。1. 从一次“人脸识别跑不起来”说起这个 Android DEMO 到底能干什么前阵子帮一个做门禁考勤的朋友看项目他拿着一个 Android 人脸识别 DEMO 折腾了两天摄像头预览正常一到检测就闪退日志里全是UnsatisfiedLinkError。翻到最后发现是 so 库只放了armeabi-v7a而测试机是arm64-v8aABI 对不上native 层直接找不到符号。这个坑其实特别典型——很多人拿到 seetaface6 的 Android 工程第一反应是改 Java 代码结果真正卡住的是 so 库和 ABI 的匹配问题。这份资源就是一个能直接跑的 seetaface6 Android DEMO 工程核心价值在于它同时打包了arm64-v8a和armeabi-v7a两套 so 库覆盖了目前绝大多数真机和模拟器。它适合两类人一类是想快速验证人脸检测、关键点、识别效果不想从零编译 native 库的开发者另一类是准备在 Android 端做人脸相关二次开发需要一个干净可改的工程骨架的人。下面我按“能干什么 → 怎么跑 → 怎么改”的顺序把这个工程拆开讲清楚。2. 工程结构与技术选型so 库、JNI 和 Camera 是怎么串起来的2.1 目录结构so 库放在哪Java 层怎么调拿到工程后先别急着点运行把目录结构看一遍能省很多事。典型的 seetaface6 Android DEMO 工程结构大致是这样SeetaFace6AndroidDemo/ ├── app/ │ ├── libs/ # 第三方 jar如果有 │ ├── src/main/ │ │ ├── java/com/example/seetaface/ │ │ │ ├── MainActivity.java # 入口负责权限和预览 │ │ │ ├── FaceDetector.java # JNI 封装类 │ │ │ └── CameraPreview.java # 相机预览回调 │ │ ├── jniLibs/ │ │ │ ├── arm64-v8a/ │ │ │ │ ├── libseetaface_detector.so │ │ │ │ ├── libseetaface_recognizer.so │ │ │ │ └── libseetaface_utils.so │ │ │ └── armeabi-v7a/ │ │ │ ├── libseetaface_detector.so │ │ │ ├── libseetaface_recognizer.so │ │ │ └── libseetaface_utils.so │ │ ├── assets/ │ │ │ └── seetaface/ # 模型文件 │ │ │ ├── face_detector.csta │ │ │ └── face_recognizer.csta │ │ └── res/ │ └── build.gradle └── settings.gradle这里有两个关键点。第一jniLibs下必须按 ABI 分目录arm64-v8a和armeabi-v7a各放一套缺一个就会在对应架构的设备上崩。第二模型文件放在assets/seetaface/下首次启动时需要拷贝到应用私有目录因为 native 层读模型通常走文件路径而不是直接读 assets。2.2 build.gradle 里的 ABI 配置很多人忽略build.gradle里的ndk.abiFilters结果打包时 Gradle 把不支持的 ABI 也塞进去APK 体积暴涨还容易冲突。常见做法是显式声明android { defaultConfig { ndk { // 只保留工程实际提供的两套 ABI abiFilters arm64-v8a, armeabi-v7a } } sourceSets { main { // 告诉 Gradle jniLibs 的位置 jniLibs.srcDirs [src/main/jniLibs] } } }abiFilters的作用是过滤最终打进 APK 的 so 库避免把 x86 之类的架构也带进来。jniLibs.srcDirs则是显式指定 so 库目录防止某些 Android Studio 版本识别不到。改完这两处同步一下 Gradle工程基本就能识别到 native 库了。2.3 JNI 封装层Java 和 C 的边界在哪seetaface6 的核心能力都在 C 层Android 端通过 JNI 调用。工程里通常会有一个FaceDetector类用native方法声明接口public class FaceDetector { static { // 按加载顺序引入识别库依赖检测库 System.loadLibrary(seetaface_utils); System.loadLibrary(seetaface_detector); System.loadLibrary(seetaface_recognizer); } // 初始化模型传入模型文件的绝对路径 public native boolean init(String detectorModelPath, String recognizerModelPath); // 对 NV21 数据做检测返回人脸框数组 public native float[] detect(byte[] nv21, int width, int height, int rotation); // 提取特征值用于比对 public native float[] extractFeature(byte[] nv21, int width, int height, float[] faceRect); // 释放 native 资源 public native void release(); }static块里的加载顺序不能乱seetaface_recognizer依赖seetaface_detector里的符号先加载识别库会报找不到符号。init接收的是模型文件的绝对路径所以 Java 层要先把 assets 里的模型拷到getFilesDir()下。detect返回的float[]一般是[x, y, w, h, score, ...]的扁平数组每个五元组表示一张人脸。extractFeature则是在检测框基础上提取特征向量用于后续比对。2.4 相机预览与数据格式Android 端做人脸检测绕不开相机数据格式。DEMO 里一般用Camera2或CameraX预览回调拿到的是 NV21 或 YUV_420_888。seetaface6 的 native 接口通常接受 NV21所以中间要做一次格式转换// CameraX 的 ImageAnalysis 回调 ImageAnalysis imageAnalysis new ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .build(); imageAnalysis.setAnalyzer(executor, image - { // YUV_420_888 转 NV21 byte[] nv21 YuvUtils.yuv420ToNv21(image); // 注意旋转角度前置摄像头通常要镜像 int rotation image.getImageInfo().getRotationDegrees(); float[] faces faceDetector.detect(nv21, image.getWidth(), image.getHeight(), rotation); // 把结果回传到 UI 线程画框 image.close(); });STRATEGY_KEEP_ONLY_LATEST保证只处理最新帧避免分析队列堆积导致预览卡顿。rotation参数很关键手机竖屏时相机传感器方向和数据方向不一致不传旋转角度会导致检测框位置偏移甚至检测不到。image.close()必须调用否则分析器会阻塞后续帧。3. 跑起来从导入工程到看到检测框的完整步骤3.1 环境准备与工程导入先把基础环境对齐不然后面全是玄学问题。需要 Android Studio建议较新版本、NDK如果工程带 native 源码、以及一台支持arm64-v8a或armeabi-v7a的真机。模拟器也可以但要注意模拟器的 ABIx86 模拟器跑不了这两套 so。导入步骤解压工程用 Android Studio 的Open打开根目录不要用Import。等待 Gradle Sync 完成如果提示 NDK 版本不匹配在local.properties里指定ndk.dir或安装对应版本。检查jniLibs下两套 ABI 目录是否都在缺了就从资源包里补上。检查assets/seetaface/下模型文件是否完整模型缺失会在init时返回 false。提示如果 Sync 报Could not find method ndk()说明build.gradle里android块的位置写错了ndk必须在defaultConfig内。3.2 模型文件拷贝为什么不能直接读 assetsnative 层读模型用的是标准文件路径而 assets 里的文件在 APK 内是压缩的没有真实路径。所以首次启动必须把模型拷到私有目录private String copyAsset(String assetName) throws IOException { File outFile new File(getFilesDir(), assetName); if (outFile.exists()) { return outFile.getAbsolutePath(); // 已拷贝过直接复用 } try (InputStream is getAssets().open(seetaface/ assetName); FileOutputStream fos new FileOutputStream(outFile)) { byte[] buffer new byte[8192]; int len; while ((len is.read(buffer)) ! -1) { fos.write(buffer, 0, len); } } return outFile.getAbsolutePath(); }getFilesDir()返回的是应用私有目录不需要额外存储权限。先判断exists()是为了避免每次启动都重复拷贝模型文件通常几 MB 到几十 MB重复拷贝会拖慢启动。拷贝完成后把绝对路径传给initnative 层就能正常加载。3.3 权限申请与相机启动Android 6.0 以后相机权限要动态申请DEMO 里一般用ActivityResultLauncherprivate final ActivityResultLauncherString permissionLauncher registerForActivityResult(new ActivityResultContracts.RequestPermission(), granted - { if (granted) { startCamera(); // 权限通过再开相机 } else { Toast.makeText(this, 需要相机权限才能检测, Toast.LENGTH_SHORT).show(); } }); // 在 onCreate 里检查并申请 if (ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ! PackageManager.PERMISSION_GRANTED) { permissionLauncher.launch(Manifest.permission.CAMERA); } else { startCamera(); }权限没通过就开相机会直接抛SecurityException所以必须先判断再启动。startCamera()里绑定Preview和ImageAnalysis两个用例前者负责显示后者负责送数据给检测器。3.4 验证检测结果日志和画框跑起来后怎么确认检测生效了两个办法。一是看 Logcat在detect返回后打一行日志float[] faces faceDetector.detect(nv21, width, height, rotation); Log.d(SeetaFace, detected faces: (faces null ? 0 : faces.length / 5));如果一直是 0先确认模型路径对不对再确认 NV21 数据是否有效。二是画框把返回的坐标映射到预览 View 上用Canvas画矩形。坐标映射要注意预览的缩放比例和镜像前置摄像头通常需要x previewWidth - x - w做水平翻转否则框会左右颠倒。注意如果检测框位置整体偏移八成是rotation没传对或者预览 View 的宽高比和相机输出不一致先对齐这两个再调坐标。4. 避坑与排查so 库、模型和相机的常见翻车点4.1 UnsatisfiedLinkErrorABI 不匹配或加载顺序错现象是应用一启动就崩日志里java.lang.UnsatisfiedLinkError: dlopen failed: library libseetaface_detector.so not found。原因有两个一是设备 ABI 和 so 库目录对不上比如 64 位机器只放了armeabi-v7a二是System.loadLibrary顺序错了识别库先于检测库加载。解决办法先确认设备 ABI用adb shell getprop ro.product.cpu.abi查看然后确保jniLibs下有对应目录。加载顺序按依赖关系来utils→detector→recognizer不要颠倒。4.2 模型加载失败路径不对或文件损坏现象是init返回 false检测一直没结果。原因通常是模型路径传了 assets 里的相对路径或者拷贝过程中文件不完整。native 层拿到不存在的路径加载直接失败。解决办法在init前打印模型绝对路径用adb shell ls -l确认文件存在且大小正常。如果是从 assets 拷贝的检查拷贝逻辑有没有提前 return 导致文件没写完。4.3 预览卡顿分析队列堆积现象是预览画面一顿一顿的检测结果延迟明显。原因是ImageAnalysis默认策略是STRATEGY_BLOCK_PRODUCER分析跟不上就阻塞预览。改成STRATEGY_KEEP_ONLY_LATEST后只保留最新帧旧帧直接丢弃。另外检测本身耗时如果每帧都跑识别而不只是检测帧率会掉得厉害。常见做法是检测每帧跑识别按需触发比如检测到人脸后再隔几帧提一次特征。4.4 检测框偏移旋转和镜像没处理现象是人脸明明在画面中间框却画到了角落或者左右颠倒。原因是相机传感器的旋转角度没传给 native或者前置摄像头的镜像没做。rotation参数要传image.getImageInfo().getRotationDegrees()前置摄像头还要在画框时做水平翻转。解决办法先把rotation传对再在Canvas绘制时根据摄像头方向做canvas.scale(-1, 1, centerX, centerY)。两个都对齐后框就正了。4.5 打包后 APK 体积异常现象是 debug 能跑release 包体积比预期大很多。原因是abiFilters没配Gradle 把其他 ABI 的 so 也打进去了或者模型文件被重复打包。检查build.gradle的abiFilters只保留两套模型文件只放一份在 assets 下。5. 二次开发换模型、加活体、做比对的改造思路5.1 替换模型换检测器或识别器seetaface6 的模型是可替换的只要接口兼容。比如想把检测器换成更轻量的版本把新的.csta文件放进assets/seetaface/然后改init时传入的路径即可。注意模型输入尺寸要和 native 层预期一致否则检测结果会异常。常见做法是先在 PC 端用同一套模型验证效果再挪到 Android。5.2 加活体检测在检测和识别之间插一层活体检测通常有两种思路动作活体眨眼、张嘴和静默活体基于纹理。动作活体可以在关键点基础上判断seetaface6 的关键点接口能拿到眼睛、嘴巴坐标通过连续帧判断开合状态// 简化逻辑连续 N 帧内眼睛开合比变化超过阈值 float eyeOpenRatio computeEyeRatio(landmarks); if (eyeOpenRatio 0.2f) { blinkCounter; } if (blinkCounter 2) { // 判定为活体 }computeEyeRatio用关键点算眼睛高度和宽度比阈值根据实际场景调。静默活体则需要额外模型工程里没有的话要自己集成注意 so 库的 ABI 也要补齐。5.3 人脸比对特征值怎么存和怎么算extractFeature返回的是特征向量比对就是算两个向量的相似度。常见做法是余弦相似度public static float cosineSimilarity(float[] a, float[] b) { float dot 0, normA 0, normB 0; for (int i 0; i a.length; i) { dot a[i] * b[i]; normA a[i] * a[i]; normB b[i] * b[i]; } return dot / (float) (Math.sqrt(normA) * Math.sqrt(normB)); }相似度阈值一般设在 0.6 到 0.8 之间具体看场景。注册时把特征值存本地数据库或文件比对时取出算相似度。注意特征值要归一化后再存避免不同设备间差异。5.4 性能优化降分辨率、跳帧、异步移动端做人脸检测性能是绕不开的。三个常用手段一是把分析分辨率降到 640x480 或更低检测精度损失不大但速度提升明显二是跳帧每两帧检测一次三是把检测放到独立线程不阻塞 UI。工程里如果用的是ImageAnalysis它本身就在后台线程但detect是同步的耗时太长还是会丢帧可以考虑用ExecutorService再包一层。5.5 一个我踩过的坑特征值维度对不上有次换了个识别模型extractFeature返回的数组长度从 1024 变成了 512但比对代码里写死了 1024结果数组越界直接崩。从那以后我每次换模型都强制走一遍“打印特征维度 → 确认比对逻辑 → 跑一组已知人脸验证相似度”的流程再也不敢跳过。希望这个习惯能帮到你。本文还有配套的精品资源点击获取