简介面向需要在 Android 端集成人脸识别能力的开发者这份 SeetaFace6 DEMO 工程提供了一套可参考的完整项目结构重点解决多 ABI 适配与本地 SO 库部署问题。工程同时包含 arm64-v8a 与 armeabi-v7a 两套预编译动态库并配套 C 头文件、配置及数据文件、Java 调用示例与 Gradle 构建脚本便于对照理解 JNI 接口和工程组织方式。压缩包共 565 个文件以 hpp、h 头文件、xml 配置、so 库及 java 源码为主整体约 146.83MB目录按模块划分检索与替换库文件较为方便。已有 756 人学习适合具备一定 Android NDK 基础、希望快速验证人脸检测、关键点定位、人脸识别、活体检测、质量评估、年龄性别估计及口罩检测等能力的开发者参考。1. 从一台旧 Android 机跑起这套双 ABI 人脸识别 DEMO 到底能做什么拿一台四年前的中端机装上这个 Android 人脸识别 DEMO 工程切到前置摄像头画面里人脸框跟着走关键点落在眼角和嘴角识别结果在几百毫秒内刷出来。这套 SeetaFace6 的 Android DEMO 自带 arm64-v8a 和 armeabi-v7a 两套 so 库相机预览、人脸检测、关键点定位、特征提取和 1:N 比对都有现成调用链。它适合想在人脸考勤、门禁、相册聚类里快速验证效果的 Android 开发也适合手里有 32 位老设备、不想被只发 64 位 so 的 SDK 卡住的团队。别急着改模型先把 demo 跑通后面换阈值、加活体、抽 AAR 才有参照。2. 工程骨架拆解app、native、jniLibs 与 CMake 的四层结构2.1 目录树与模块职责这份 DEMO 的目录不复杂但几个关键目录一旦放错运行时就只会给你一个UnsatisfiedLinkError。常见结构如下不同 Android Studio 版本可能多一层cpp或jni职责基本一致。app/ src/main/ java/com/example/facedemo/ MainActivity.java # 相机预览、权限、按钮交互 FaceEngine.java # native 方法声明与 Java 层封装 FaceResult.java # 检测框、关键点、相似度结果 cpp/ face_jni.cpp # JNI 入口桥接 Java 与 C face_engine.cpp # 检测、关键点、识别器生命周期 CMakeLists.txt # 编译 native 库 jniLibs/ arm64-v8a/ libseeta_facedetector.so libseeta_facelandmarker.so libseeta_facerecognizer.so libseeta_faceengine.so armeabi-v7a/ libseeta_facedetector.so libseeta_facelandmarker.so libseeta_facerecognizer.so libseeta_faceengine.so assets/ models/ face_detector.csta face_landmarker_pts5.csta face_recognizer.cstajniLibs下按 ABI 分目录这是 Android 打包 so 的默认规则。arm64-v8a给 64 位设备armeabi-v7a给仍在流通的 32 位设备。assets/models放模型文件安装后不能直接当文件路径用常见做法是首次启动时拷贝到filesDir再传给 native。cpp目录只负责 JNI 和引擎封装不把业务逻辑塞进MainActivity否则后面抽 AAR 会很痛苦。我一般会先看jniLibs里两套 ABI 的 so 数量是否一致。如果arm64-v8a有 8 个 soarmeabi-v7a只有 6 个运行到 32 位设备上大概率在System.loadLibrary阶段就崩。这个目录树的价值不是好看而是让你在构建前就能发现缺库。2.2 Gradle 里 ABIs 与 so 库的挂载方式老工程常见用abiFilters控制打包新工程可能用splits或packaging。核心是别让 Gradle 把不需要的 ABI 打进去也别把jniLibs排除掉。android { defaultConfig { ndk { // 只保留工程实际提供的两套 ABI abiFilters arm64-v8a, armeabi-v7a } } sourceSets { main { // 默认就会读 src/main/jniLibs这里显式写出来便于排查 jniLibs.srcDirs [src/main/jniLibs] } } packagingOptions { // 避免多个依赖携带同名 so 时冲突 pickFirst lib/arm64-v8a/libseeta_faceengine.so pickFirst lib/armeabi-v7a/libseeta_faceengine.so } }abiFilters里写两个 ABI打出来的 APK 会同时包含两套 so包体会变大但换来老设备兼容。pickFirst只在依赖冲突时用不要无脑加否则可能把正确版本盖掉。jniLibs.srcDirs显式声明后Android Studio 的 Build 输出里能看到mergeDebugJniLibFolders任务排查缺库时直接看这个任务有没有把两套目录都合进去。另一个容易忽略的点是 16KB 页对齐。新系统对 so 的段对齐有要求如果 so 是旧工具链编的安装时可能报INSTALL_FAILED_INVALID_APK或运行时加载失败。常见做法是用 NDK r27 以上重新链接或者在CMakeLists.txt里加链接参数。这个坑在双 ABI 工程里更明显因为 32 位 so 往往是历史产物。2.3 主 Activity 的相机预览链路DEMO 里相机部分通常是 CameraX 的ImageAnalysis因为拿到ImageProxy后可以转 NV21 或直接转 RGB。下面这段是常见写法重点看setBackpressureStrategy和YUV_420_888的转换入口。// MainActivity.java private void startCamera() { ListenableFutureProcessCameraProvider future ProcessCameraProvider.getInstance(this); future.addListener(() - { try { ProcessCameraProvider provider future.get(); Preview preview new Preview.Builder().build(); ImageAnalysis analysis new ImageAnalysis.Builder() // 只保留最新帧避免排队导致预览延迟 .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setTargetResolution(new Size(640, 480)) .build(); analysis.setAnalyzer(executor, image - { // 这里不要直接跑识别先转成引擎需要的格式 byte[] nv21 ImageUtils.yuv420ToNv21(image); int rotation image.getImageInfo().getRotationDegrees(); faceEngine.detectAndRecognize(nv21, image.getWidth(), image.getHeight(), rotation); image.close(); }); provider.unbindAll(); provider.bindToLifecycle(this, CameraSelector.DEFAULT_FRONT_CAMERA, preview, analysis); } catch (Exception e) { Log.e(TAG, camera bind failed, e); } }, ContextCompat.getMainExecutor(this)); }STRATEGY_KEEP_ONLY_LATEST很关键人脸检测一帧几十毫秒如果相机每秒出 30 帧而分析器排队预览会越来越滞后。setTargetResolution(640, 480)是常见折中太大检测慢太小远景脸丢关键点。rotation必须传下去前置摄像头还有镜像问题不处理会出现检测框左右反。image.close()一定要在分析结束调用否则相机会卡住。这段链路只负责“拿到帧并送进引擎”识别结果通过回调或runOnUiThread刷到预览层。不要把模型加载放在onCreate里同步做首次拷贝模型可能几百毫秒主线程会白屏。常见做法是开一个Executors.newSingleThreadExecutor()做初始化UI 上先显示“引擎加载中”。3. JNI 桥接与 so 加载从 System.loadLibrary 到 native 句柄3.1 native 方法签名与 Java 层封装Java 层不要直接把所有 native 方法散在 Activity 里抽一个FaceEngine类方法签名和 C 侧严格对应。包名、类名、方法名任何一处改动JNI 注册就会失败。// FaceEngine.java package com.example.facedemo; public class FaceEngine { static { // 顺序很重要被依赖的库先加载 System.loadLibrary(seeta_facedetector); System.loadLibrary(seeta_facelandmarker); System.loadLibrary(seeta_facerecognizer); System.loadLibrary(seeta_faceengine); } private long nativeHandle; public native boolean nativeInit(String modelDir); public native FaceResult[] nativeDetect(byte[] nv21, int width, int height, int rotation); public native float nativeCompare(byte[] feature1, byte[] feature2); public native void nativeRelease(); }static块里的加载顺序不是随便写的。seeta_faceengine依赖检测器、关键点、识别器三个 so如果先加载引擎库动态链接器找不到符号会报dlopen failed: library libseeta_facedetector.so not found或cannot locate symbol。nativeHandle用long存 C 对象指针避免 Java 层直接持有 native 内存。nativeInit传模型目录nativeDetect传 NV21 字节数组、宽高和旋转角nativeCompare传两个特征字节数组。参数类型要和 C 的jbyteArray、jint对上别用int[]接 NV21。签名一旦确定nativeInit里要做三件事创建检测器、创建关键点定位器、创建识别器。任何一步失败都返回falseJava 层据此提示“模型加载失败”而不是让空指针在 native 里崩掉。3.2 so 库加载顺序与 ABI 选择System.loadLibrary(seeta_faceengine)会按当前进程的 ABI 去jniLibs里找libseeta_faceengine.so。64 位进程找arm64-v8a32 位进程找armeabi-v7a。如果只放了一套另一套设备安装后运行就崩。// 排查当前进程 ABI 的常用写法 private String currentAbi() { if (Build.VERSION.SDK_INT Build.VERSION_CODES.LOLLIPOP) { for (String abi : Build.SUPPORTED_ABIS) { if (arm64-v8a.equals(abi) || armeabi-v7a.equals(abi)) { return abi; } } } return Build.CPU_ABI; }Build.SUPPORTED_ABIS返回顺序是从首选到次选64 位设备通常第一个是arm64-v8a。如果 APK 里只有armeabi-v7a系统会以 32 位模式运行这时arm64-v8a的 so 不会被加载。双 ABI 工程的价值就在这里同一份 APK 在两种设备上都能跑。但要注意如果某个第三方依赖只带了arm64-v8aGradle 合并后 32 位包可能缺库abiFilters要结合实际依赖检查。我一般会在Application.onCreate里打一条日志把SUPPORTED_ABIS和applicationInfo.nativeLibraryDir打出来。nativeLibraryDir能直接告诉你系统从哪个目录加载 so排查“明明放了库却找不到”时非常省时间。3.3 C 侧初始化与模型文件路径模型文件在assets里native 不能直接读。常见做法是 Java 层首次启动时把assets/models拷贝到getFilesDir()/models再把绝对路径传给nativeInit。// face_jni.cpp extern C JNIEXPORT jboolean JNICALL Java_com_example_facedemo_FaceEngine_nativeInit( JNIEnv* env, jobject thiz, jstring modelDir) { const char* dir env-GetStringUTFChars(modelDir, nullptr); if (dir nullptr) { return JNI_FALSE; } std::string base(dir); env-ReleaseStringUTFChars(modelDir, dir); // 模型文件名要和 assets 里保持一致 std::string detectorPath base /face_detector.csta; std::string landmarkerPath base /face_landmarker_pts5.csta; std::string recognizerPath base /face_recognizer.csta; auto* engine new FaceEngineWrapper(); bool ok engine-init(detectorPath, landmarkerPath, recognizerPath); if (!ok) { delete engine; return JNI_FALSE; } // 把 native 指针塞回 Java 的 long 字段 jclass clazz env-GetObjectClass(thiz); jfieldID field env-GetFieldID(clazz, nativeHandle, J); env-SetLongField(thiz, field, reinterpret_castjlong(engine)); return JNI_TRUE; }GetStringUTFChars和ReleaseStringUTFChars必须成对否则有内存泄漏。FaceEngineWrapper里持有检测器、关键点、识别器三个对象init里依次调用各自的构造或加载方法。模型路径用std::string拼接不要用sprintf拼固定长度缓冲区。nativeHandle的字段签名是J对应 Java 的long写错会抛NoSuchFieldError。nativeRelease里要delete这个指针并把 Java 字段置 0。如果 Activity 销毁时不释放反复进出页面会累积 native 内存。常见做法是在FaceEngine里加一个release()内部判断nativeHandle ! 0再调 native避免重复释放。4. 人脸检测与识别主链路一帧 NV21 数据怎么走完检测、关键点和对齐4.1 帧数据格式转换与旋转处理CameraX 的ImageAnalysis默认给YUV_420_888而很多人脸引擎接口吃 NV21。转换时要注意rowStride和pixelStride不同机型不一致直接按宽高拷会花屏。// ImageUtils.java public static byte[] yuv420ToNv21(ImageProxy image) { ImageProxy.PlaneProxy[] planes image.getPlanes(); ByteBuffer yBuffer planes[0].getBuffer(); ByteBuffer uBuffer planes[1].getBuffer(); ByteBuffer vBuffer planes[2].getBuffer(); int ySize yBuffer.remaining(); int uSize uBuffer.remaining(); int vSize vBuffer.remaining(); byte[] nv21 new byte[ySize uSize vSize]; yBuffer.get(nv21, 0, ySize); // NV21 是 VU 交错先 V 后 U vBuffer.get(nv21, ySize, vSize); uBuffer.get(nv21, ySize vSize, uSize); return nv21; }这段是最简版本适合rowStride等于宽度的机型。如果遇到预览花屏先把image.getWidth()、getHeight()、每个 plane 的rowStride打出来。有些设备 Y 平面有 padding需要按行拷贝。rotation从image.getImageInfo().getRotationDegrees()拿前置摄像头还要判断是否镜像。常见做法是旋转和镜像都在 native 里做Java 只传原始 NV21 和角度减少一次大数组拷贝。4.2 检测、关键点、特征提取的调用顺序一帧数据的处理顺序是检测人脸框 → 对每个人脸定位关键点 → 根据关键点对齐 → 提取特征。顺序不能反关键点依赖检测框特征依赖对齐后的人脸。// face_engine.cpp FaceResultList FaceEngineWrapper::detectAndRecognize( const uint8_t* nv21, int width, int height, int rotation) { // 1. 转成引擎需要的 RGB 或灰度图 SeetaImageData image convertToSeetaImage(nv21, width, height, rotation); // 2. 检测人脸返回框和置信度 std::vectorSeetaFaceInfo faces detector.detect(image); FaceResultList results; for (const auto face : faces) { // 3. 五点关键点定位 std::vectorSeetaPointF points landmarker.mark(image, face.pos); if (points.size() 5) { continue; } // 4. 根据关键点对齐得到 256x256 的人脸图 SeetaImageData aligned alignFace(image, points); // 5. 提取特征 std::vectorfloat feature recognizer.Extract(aligned); results.push_back(makeResult(face, points, feature)); } return results; }detector.detect返回的face.pos是矩形框landmarker.mark需要原图和框。alignFace通常做相似变换把眼睛、鼻子、嘴角对齐到固定位置。recognizer.Extract输出固定长度特征向量常见是 1024 维 float。如果检测不到人脸直接返回空列表不要在对齐阶段对空框做运算。4.3 识别阈值与结果封装1:1 比对用余弦相似度或欧氏距离1:N 识别要维护特征库。阈值不要拍脑袋常见起点是 0.6 到 0.7具体看模型和场景。// FaceResult.java public class FaceResult { public final Rect rect; public final float[] landmarks; // 10 个 float5 个点 public final byte[] feature; public final float similarity; public FaceResult(Rect rect, float[] landmarks, byte[] feature, float similarity) { this.rect rect; this.landmarks landmarks; this.feature feature; this.similarity similarity; } public boolean isSamePerson(float threshold) { // 相似度高于阈值才认为同一人 return similarity threshold; } }feature用byte[]传回 Java 是为了方便存数据库或做网络传输实际比对时在 native 里转成float*。similarity是当前帧和特征库里最相似那条的得分。阈值调低会提高通过率但误识也会上来调高则相反。门禁场景一般偏严相册聚类可以偏松。这个参数最好放到配置里别写死在if里。5. 避坑与排查双 ABI、模型路径、16KB 页对齐和相机方向5.1 so 加载与 ABI 相关的崩溃现象一64 位真机安装后一打开就闪退日志里是java.lang.UnsatisfiedLinkError: dlopen failed: library libseeta_faceengine.so not found。原因APK 里只打进了armeabi-v7a或者jniLibs目录名写成了arm64、armeabi-v7a/下少了某个依赖 so。解决解压 APK 看lib/下有没有arm64-v8a和armeabi-v7a两个目录数量是否一致。Gradle 里确认abiFilters没把 64 位过滤掉sourceSets.jniLibs.srcDirs指向正确。如果是依赖冲突用pickFirst前先看./gradlew app:dependencies里谁带了同名库。现象二32 位老设备能装但跑起来报cannot locate symbol __android_log_print或某个 C 符号缺失。原因so 编译时链接了不同版本的 NDK 运行时或者 32 位 so 依赖了未打包的第三方库。解决用readelf -d libseeta_faceengine.so看NEEDED列表确认每个依赖都在jniLibs里。统一用同一套 NDK 重新编译别把不同来源的 so 混放。32 位 so 还要注意 16KB 页对齐旧工具链产物在新系统上可能加载失败。5.2 模型、权限与混淆导致的静默失败现象三nativeInit返回false但日志里没有明显异常相机预览正常就是不出检测框。原因assets/models拷贝到filesDir时文件名大小写不一致或者拷贝没完成就调了初始化。解决在nativeInit前打印模型目录和每个文件的length()为 0 就是没拷过去。拷贝放在Executors里完成后再回调初始化。模型文件名不要改face_landmarker_pts5.csta和face_landmarker_pts68.csta是不同模型混用会关键点数量不对。现象四Debug 包正常Release 包一调 native 就崩报NoSuchMethodError。原因混淆把FaceEngine的类名或 native 方法名改了JNI 按原名找不到。解决在proguard-rules.pro里保留类和 native 方法-keep class com.example.facedemo.FaceEngine { native methods; }。同时保留FaceResult的字段避免结果解析空指针。5.3 预览方向与帧格式的现场排查现象五前置摄像头检测框左右相反或者人脸旋转 90 度后关键点全错。原因只传了rotation没处理镜像或者 NV21 转换时 U/V 顺序反了。解决前置摄像头在 native 里做水平翻转或者 Java 层对 NV21 做镜像。先用一张静态图测试转换函数确认yuv420ToNv21出来的图在 ImageView 里颜色正常再进检测。rotation为 90 或 270 时宽高要交换别把原图宽高直接传给引擎。注意双 ABI 工程排查时先确认当前进程 ABI再看nativeLibraryDir里实际加载了哪些 so最后才去看模型。顺序反了容易在错误的方向上耗时间。6. 二次开发换模型、加活体检测、抽成 AAR 的三个改造方向第一个方向是换模型和调阈值。检测器、关键点、识别器是分开加载的换face_recognizer.csta后特征维度可能变nativeCompare里的循环要跟着改。阈值别只改一个全局变量我一般按场景拆成detectThreshold、similarityThreshold、landmarkQualityThreshold三个前两个控制召回和误识第三个过滤侧脸和遮挡。第二个方向是加活体检测。DEMO 里没有活体模块时常见做法是在检测到人脸后加动作指令比如转头、眨眼用关键点位移做简单判断或者接一个静默活体 so把 RGB 帧同时送进活体和识别两条链路。注意活体模型也会占 ABIjniLibs两套目录都要补齐否则又回到 32 位设备崩的老路。第三个方向是抽 AAR。把FaceEngine、FaceResult、jniLibs、assets/models从 app 模块挪到face-sdk模块对外只暴露初始化和检测接口。Gradle 里用api而不是implementation暴露必要类consumerProguardFiles带上混淆规则。抽完后用一个空 app 依赖 AAR 跑一遍双 ABI 真机确认模型拷贝和 so 加载都不依赖原 DEMO 的MainActivity。改造方向主要改动验证重点换模型替换 assets 模型、调整特征维度关键点数量、特征长度、阈值加活体增加活体 so 和调用链双 ABI 是否齐全、帧率是否掉抽 AAR模块拆分、混淆规则外置空工程依赖后能否初始化血泪经验是每次改完jniLibs或CMakeLists.txt先./gradlew clean再装包别信增量编译。从那以后我每次动 so 目录都强制走一遍“解压 APK 看 lib 目录 → 真机看 nativeLibraryDir → 跑一张静态图”的流程再谈业务效果。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?