首页 / 资讯中心 / 文章详情

Android seetaface6 DEMO:so库、JNI与模型加载实战

Android seetaface6 DEMO:so库、JNI与模型加载实战 ★ FEATURED ARTICLE
简介面向 Android 端人脸识别开发者的 SeetaFace6 DEMO 工程合集重点解决在移动设备上集成人脸检测、关键点定位、人脸识别、活体检测、质量评估、年龄性别估计以及口罩佩戴场景识别等能力时的工程落地问题同时提供 arm64-v8a 与 armeabi-v7a 两套动态库便于不同架构设备直接联调与真机验证。压缩包共 565 个文件、约 146.83MB以 hpp 与 h 头文件、xml 模型配置、so 动态库为主并包含少量 java、gradle、mk、bin、png 等工程构建与资源文件目录覆盖示例工程、原生接口、模型依赖及编译产物适合中高级 Android 开发者参考接入、核对依赖关系和二次开发。已有 754 人学习下载可作为梳理 SeetaFace6 移动端工程结构、确认双架构库配置、排查 JNI 调用与模型加载问题的实践样本也能帮助读者理解完整 DEMO 的工程目录、接口分层与资源组织方式。1. 拿到 android seetaface 的 DEMO 先看 jniLibs这份资源解决的是“能跑”之前的那一步某开发者把一个人脸检测需求丢过来算法侧只给了一个 android seetaface 的 DEMO 工程里面躺着 arm64-v8a 和 armeabi-v7a 两套 so 库。很多人第一反应是直接点运行结果卡在 UnsatisfiedLinkError 或者模型加载失败连第一帧都没跑起来。这份资源真正值钱的地方不是教你从零写一个人脸识别 App而是把预编译 so、JNI 调用骨架、模型文件加载路径和最小检测链路先凑成一个能编译、能安装、能出结果的工程。它适合两类人一类是要在 Android 端快速验证 seetaface6 检测、对齐、识别效果不想在 NDK 编译上耗一周的从业者另一类是想拆 JNI 封装、看 so 库怎么落位、模型怎么从 assets 搬到可读目录的工程师。不适合想改算法、训练模型的人这份 DEMO 只负责端上落地。所以别急着编译先把 jniLibs 和 assets 翻一遍后面很多坑在这一步就能提前避开。2. 工程结构拆开看arm64-v8a 与 armeabi-v7a 的 so 库怎么落位2.1 目录拆解jniLibs、assets、cpp 三块各自管什么一个能跑的 seetaface6 Android DEMO目录通常不会太复杂但每一块都有明确边界。src/main/jniLibs放预编译 so按 ABI 分子目录src/main/assets放模型文件常见是.csta后缀src/main/cpp放 JNI 胶水代码和 CMakeLists。很多人把模型塞进res/rawJava 层读起来方便native 层却拿不到稳定路径最后又绕回 assets 拷贝。先把目录看清楚比改十行 Gradle 更有效。我一般先跑一条目录枚举命令确认 so 到底按什么层级放。下面这条命令只列目录不碰文件内容适合在工程根目录执行。find . -maxdepth 5 -type d | sort | grep -E jniLibs|assets|cpp|libs这段命令的作用是把工程里跟 native 相关的目录层级打出来。-maxdepth 5限制递归深度避免直接扫进 build 目录grep -E只保留 jniLibs、assets、cpp、libs 这几类路径。执行完重点看两件事jniLibs下面是不是直接是arm64-v8a和armeabi-v7a而不是多套一层libsassets下面模型是平铺还是放在models子目录。路径层级一旦多一层System.loadLibrary和模型拷贝代码都要跟着改。常见结构可以对照下面这张表。它不是标准答案但能帮你判断手里的 DEMO 有没有放错地方。路径作用常见错误src/main/jniLibs/arm64-v8a/放 64 位 so多套一层libs/导致打包不进 APKsrc/main/jniLibs/armeabi-v7a/放 32 位 so只放了一边另一边空目录src/main/assets/models/放检测、关键点、识别模型文件名大小写和代码不一致src/main/cpp/JNI 封装与 CMake 配置CMake 里写死本机绝对路径src/main/java/...Java/Kotlin 调用层native 方法名和包名不匹配目录看明白后再确认 so 文件名。System.loadLibrary(seetaface6)找的是libseetaface6.so不是seetaface6.so也不是libseetaface6-android.so。如果工程里 so 名字带下划线、带版本后缀Java 层加载名就要跟着改。这一步不确认后面编译全绿装到真机一调就崩日志里只有一行 UnsatisfiedLinkError很容易误判成 ABI 问题。2.2 ABI 选择什么时候只留 arm64-v8a什么时候必须带 v7aarm64-v8a 和 armeabi-v7a 不是“都带上就稳”的选项。arm64-v8a 对应 64 位 ARM 设备现在主流真机基本都覆盖armeabi-v7a 对应 32 位 ARM仍有一部分低端设备、老设备和定制终端在用。两套都带包体积会涨但兼容面更宽只留 arm64-v8a包小、加载路径简单却可能在某些 32 位设备上直接装不上或者找不到 so。选型要看你的交付场景不是看哪个“先进”。下面这张对比表可以用来做取舍。重点是“设备覆盖”和“调试成本”两列不要只看性能。ABI设备覆盖包体积调试成本建议场景arm64-v8a主流 64 位真机较大低新项目、内部验证、只保新设备armeabi-v7a32 位老设备、部分定制终端较小中交付面广、设备型号不统一两者都带覆盖最宽最大低DEMO 验证、兼容性优先Gradle 里控制 ABI 的常见写法如下。注意abiFilters只影响打包不会帮你补 soso 本身还是要在 jniLibs 下存在。android { defaultConfig { ndk { // 只打这两套 ABI和 jniLibs 目录保持一致 abiFilters arm64-v8a, armeabi-v7a } } sourceSets { main { // 明确 jniLibs 的根目录避免多套一层导致漏打包 jniLibs.srcDirs [src/main/jniLibs] } } }这段配置的作用是告诉构建系统只把 arm64-v8a 和 armeabi-v7a 的 so 打进 APK并且从src/main/jniLibs找 so。abiFilters里的字符串必须和目录名完全一致大小写、连字符都不能错。jniLibs.srcDirs是兜底有些工程模板会把 so 放在libs下构建时又没配 sourceSets最后 APK 里空空如也。改完这两处先跑一次assembleDebug再用后面第 6 章的 apk 检查命令确认 lib 目录不要直接上真机猜。如果工程里同时存在armeabi-v7a和arm64-v8a两套 so但模型或 JNI 代码只按 64 位写了指针宽度假设32 位设备上仍可能崩。常见做法是先在 arm64-v8a 真机上跑通再单独拿一台 32 位设备验证。不要用 x86_64 模拟器代替原因在第 5 章会展开。2.3 Gradle 与 CMake 的最小改动让 DEMO 认到预编译 so预编译 so 接进 CMake 有两种常见方式一种是把 so 当共享库直接链接另一种是用IMPORTED目标声明。DEMO 工程里后者更稳因为路径和 ABI 可以分开写不会把本机绝对路径带进仓库。下面是一段最小 CMakeLists 片段so 名按工程实际名字替换这里用libseetaface6.so举例。cmake_minimum_required(VERSION 3.18) project(seetaface6demo) # 声明一个导入的共享库目标名字只在本工程内使用 add_library(seetaface6 SHARED IMPORTED) # 按当前 ABI 拼出 so 路径避免写死 arm64-v8a set_target_properties(seetaface6 PROPERTIES IMPORTED_LOCATION ${CMAKE_SOURCE_DIR}/../jniLibs/${ANDROID_ABI}/libseetaface6.so ) # 头文件目录按工程实际位置补常见在 cpp/include target_include_directories(seetaface6 INTERFACE ${CMAKE_SOURCE_DIR}/include ) add_library(native-lib SHARED native-lib.cpp) target_link_libraries(native-lib seetaface6 log)这段 CMake 的逻辑是用IMPORTED目标把预编译 so 挂进来${ANDROID_ABI}会随构建变体变成arm64-v8a或armeabi-v7a这样一套 CMakeLists 能服务两套 ABI。IMPORTED_LOCATION指向实际 so 文件路径里的../jniLibs要按你工程里 CMakeLists 的位置调整。target_include_directories是给编译期找头文件用的如果 DEMO 里没有头文件只留 so 也能链接但 JNI 函数声明会缺类型不建议省。target_link_libraries里把native-lib和seetaface6连起来log是 Android NDK 的日志库。参数上最容易错的是IMPORTED_LOCATION的相对路径CMakeLists 在src/main/cpp下jniLibs 在src/main/jniLibs所以是../jniLibs如果 CMakeLists 被放到app/下路径就要改成src/main/jniLibs。编译报cannot find -lseetaface6时先打印CMAKE_SOURCE_DIR和ANDROID_ABI不要盲改 Gradle。注意CMake 里只声明 so 不会自动把 so 打进 APKjniLibs 目录和 Gradle 的 abiFilters 仍要保证一致。3. 模型与 JNI 初始化从 assets 拷贝到 native 第一帧检测3.1 模型清单检测、关键点、识别三件套先跑通seetaface6 的模型是按模块拆的DEMO 里通常不会把所有模型都堆上。第一轮验证只要三件套人脸检测模型、5 点关键点模型、人脸识别模型。检测模型负责框出人脸关键点模型给 5 个点做对齐识别模型提特征做比对。活体、年龄、性别、质量评估这些属于扩展模块模型文件和调用顺序另算先不要混进来否则初始化失败时你分不清是主链路问题还是扩展模块缺文件。模型文件常见命名和用途可以对照下表。文件名在不同打包里可能略有差异以工程 assets 里实际名字为准不要照抄表里的名字去改代码。模型文件作用是否首轮必需注意点face_detector.csta人脸检测是检测器初始化必须face_landmarker_pts5.csta5 点关键点是对齐用比 68 点轻face_recognizer.csta人脸特征提取是1:1 比对和 1:N 检索都依赖face_landmarker_pts68.csta68 点关键点否需要更细对齐时再加eye_state.csta眼睛状态否扩展模块首轮不加载mouth_state.csta嘴巴状态否扩展模块首轮不加载模型放置位置建议统一到assets/models/不要散在 assets 根目录。文件名大小写在 Android 打包后仍然敏感Face_Detector.csta和face_detector.csta是两个条目native 层按字符串找文件不会帮你纠正。首轮只放三件套跑通后再逐个加扩展模型这样初始化日志里哪一步失败一目了然。3.2 assets 拷贝到可读路径Android 上模型路径的常见做法native 层做人脸检测时模型加载接口通常要一个文件系统路径不是 assets 里的逻辑路径。assets 里的文件在 APK 内是压缩包条目fopen直接读不到常见做法是 App 启动时把assets/models整个目录拷到filesDir/models再把绝对路径传给 JNI。下面这段 Kotlin 代码做的是逐文件拷贝带覆盖和长度校验适合放在 Application 或主 Activity 的初始化里。fun copyAssetsModels(context: Context): String { val targetDir File(context.filesDir, models) if (!targetDir.exists()) targetDir.mkdirs() val assets context.assets val names assets.list(models) ?: emptyArray() names.forEach { name - val target File(targetDir, name) // 已存在且长度一致时跳过减少每次启动拷贝 if (target.exists() target.length() 0L) returnforEach assets.open(models/$name).use { input - target.outputStream().use { output - input.copyTo(output, bufferSize 8 * 1024) } } // 拷贝后立刻校验长度空文件直接抛错别等到 native 层 if (target.length() 0L) { throw IllegalStateException(model copy failed: $name) } } return targetDir.absolutePath }这段代码的作用是把assets/models下的模型复制到应用私有目录返回给 JNI 的目录路径。context.filesDir是应用内部存储不需要额外存储权限卸载后自动清理。bufferSize 8 * 1024是常见拷贝块大小模型文件不大时够用如果模型上百 MB可以调到 32 KB 或 64 KB。target.length() 0L的跳过逻辑只能防空文件不能防半截文件更稳的做法是比较 assets 条目长度和本地文件长度或者对关键模型做一次 MD5 校验。拷贝失败时不要静默返回空字符串直接抛异常让问题在启动阶段暴露。assets.list(models)只列一层如果模型放在assets/models/detector/这种子目录里这段代码不会递归。DEMO 工程通常平铺遇到子目录就改成递归拷贝或者按固定文件名列表逐个拷。不要用res/raw代替 assetsres/raw的资源 ID 在 native 层没有稳定路径最后还是要走拷贝。3.3 JNI 加载与初始化顺序先 so 后模型再建检测器Java 层加载 so 和声明 native 方法要放在同一个类里类加载时执行System.loadLibrary。下面是一个最小声明方法名和包名要跟 cpp 里的 JNI 函数对应。package com.example.seetaface6demo; public class SeetaFaceNative { static { // 对应 libseetaface6.so名字不要带 lib 前缀和 .so 后缀 System.loadLibrary(seetaface6); } // 传入模型目录返回 0 表示成功非 0 看错误码 public static native int nativeInit(String modelDir); // 传入 RGBA/BGR 字节数组和宽高返回人脸数量 public static native int nativeDetect(byte[] frame, int width, int height); }这段声明的作用是把 so 加载和 native 入口固定下来。System.loadLibrary(seetaface6)只认libseetaface6.so如果工程里 so 叫libseetaface.so这里就要改成seetaface。nativeInit返回 int是为了把模型加载失败、检测器创建失败、关键点模型缺失区分开如果返回 boolean线上日志里只剩 true/false排查会很难受。nativeDetect传字节数组而不是直接传 Mat是为了减少 JNI 层对 OpenCV 的依赖DEMO 里如果没有 OpenCV就按这个方式走。cpp 侧的初始化顺序建议是先加载检测模型创建检测器再加载关键点模型创建关键点器最后加载识别模型创建识别器。任何一步失败都返回不同错误码。下面是一段伪代码结构函数名按实际头文件调整。extern C JNIEXPORT jint JNICALL Java_com_example_seetaface6demo_SeetaFaceNative_nativeInit( JNIEnv* env, jclass, jstring modelDir) { const char* dir env-GetStringUTFChars(modelDir, nullptr); std::string base(dir); env-ReleaseStringUTFChars(modelDir, dir); // 1. 检测模型路径拼接后加载失败返回 1001 seeta::ModelSetting detectorSetting(base /face_detector.csta); auto detector std::make_uniqueseeta::FaceDetector(detectorSetting); if (!detector) return 1001; // 2. 关键点模型5 点对齐失败返回 1002 seeta::ModelSetting landmarkSetting(base /face_landmarker_pts5.csta); auto landmarker std::make_uniqueseeta::FaceLandmarker(landmarkSetting); if (!landmarker) return 1002; // 3. 识别模型特征提取失败返回 1003 seeta::ModelSetting recognizerSetting(base /face_recognizer.csta); auto recognizer std::make_uniqueseeta::FaceRecognizer(recognizerSetting); if (!recognizer) return 1003; return 0; }这段代码的重点不是接口名而是初始化顺序和错误码分层。模型目录通过GetStringUTFChars转成 C 字符串后立刻释放避免 JNI 局部引用堆积。每个模型单独构造失败返回不同码Java 层日志里能直接定位是检测、关键点还是识别缺文件。std::make_unique要求 C14 以上NDK 默认支持如果工程用裸指针记得在nativeRelease里 delete别让检测器跨 Activity 泄漏。提示模型加载不要放在主线程。三件套初始化在低端机上也可能占几百毫秒放启动回调里容易触发 ANR。4. 最小闭环检测、对齐、识别的参数怎么设4.1 检测参数最小人脸、阈值、图像通道检测器初始化后第一件事是设最小人脸尺寸。设太大小脸漏检设太小误检和耗时都上来。常见做法是按业务场景定门禁、闸机这类人脸占画面比例大的场景min_face_size可以设 80 或 100手机后置摄像头抓拍、多人合影设 40 到 60 更稳。阈值方面检测置信度阈值一般放在 0.7 到 0.9 之间低于 0.7 误检明显增多高于 0.9 侧脸和遮挡容易漏。参数没有万能值先用一组基准图跑出检测框再微调。参数常见取值影响调整建议min_face_size40 / 60 / 80 / 100越小越容易检小脸耗时和误检上升按人脸占画面比例定检测阈值0.7 到 0.9越低误检越多越高漏检越多先 0.8再按场景微调图像通道BGR 或 RGB通道错会导致检测框偏移或检不到跟 JNI 输入保持一致图像缩放长边 640 到 1280太大耗时太小漏小脸先保检测质量再压耗时图像通道是很容易翻车的地方。Android 摄像头预览常见 NV21 或 YUV_420_888转成字节数组后如果直接按 RGB 喂给检测器颜色顺序反了检测效果会变得很玄学有时候能检到有时候框偏得离谱。常见做法是在 Java/Kotlin 层先转成 BGR 或 RGB并在 native 层固定一种顺序不要一会儿传 RGBA 一会儿传 BGR。下面是一段检测调用的伪代码重点看参数设置和输入尺寸。// 假设 detector 已在 nativeInit 中创建 seeta::FaceDetector::Options options; options.min_face_size 60; // 最小人脸像素按场景调 detector-set(options); SeetaImageData image; image.width width; image.height height; image.channels 3; // 三通道顺序要和输入一致 image.data frame; // frame 是 BGR 或 RGB 字节数组 SeetaFaceInfoArray faces detector-detect(image); return faces.size;这段代码里min_face_size是检测器选项不是运行时单帧参数改完要对后续帧生效。SeetaImageData的channels设 3 表示三通道data指向外部字节数组生命周期只在本次调用内不要缓存这个指针。detect返回的人脸数组里包含位置和置信度Java 层如果只拿数量可以先返回faces.size要把框画出来就得把每个SeetaFaceInfo的 x、y、width、height 回传。首轮验证先看数量对不对再谈画框。4.2 对齐与特征比对5 点关键点和相似度阈值检测到人脸后直接用整张脸提特征不是不行但姿态、尺度变化会让相似度不稳。常见链路是先用人脸框和 5 个关键点做对齐把脸摆正到固定尺寸再送识别模型。5 点关键点比 68 点轻首轮验证够用如果业务对侧脸、低头场景要求高再换 68 点模型。对齐后的图一般缩放到识别模型要求的输入尺寸这个尺寸在模型里固定不要自己随便改。特征比对分 1:1 和 1:N。1:1 是两张脸比相似度阈值常见放 0.6 到 0.81:N 是在底库里搜最近邻阈值要更高常见 0.75 以上否则误认会明显增加。阈值不是拍脑袋定的拿同一批人不同光照、不同角度的图跑一遍分布看正样本和负样本的重叠区再定业务阈值。DEMO 里通常给一个默认值先跑通再调。// 对齐用 5 点把脸摆正输出固定尺寸的人脸图 SeetaImageData aligned; aligned.width 112; aligned.height 112; aligned.channels 3; aligned.data new unsigned char[112 * 112 * 3]; landmarker-mark(image, face.pos, points); face_crop-crop(image, points, aligned); // 提特征输出 float 数组长度由模型决定 SeetaFaceInfo faceInfo; auto feature recognizer-Extract(aligned); // 1:1 比对相似度越高越像阈值按业务定 float similarity recognizer-CalculateSimilarity(feature1, feature2); bool samePerson similarity 0.7f;这段代码展示的是对齐、提特征、比对三步。aligned的宽高 112 只是示例实际按识别模型输入要求改new unsigned char[]分配的缓冲区要在用完后 deleteDEMO 里如果每帧都 new连续跑几分钟内存就上去了。CalculateSimilarity返回的是相似度分数不是概率0.7 只是一个起步阈值1:N 场景要往上加。提取出的特征向量要存成固定长度数组比对前确认两边长度一致长度不一致直接返回错误不要硬算。4.3 线程与内存别在 UI 线程跑识别Mat 要及时释放检测和识别都是计算密集操作放主线程跑低端机上很容易卡预览甚至 ANR。常见做法是开一个单线程或固定大小线程池预览帧进来后丢进队列处理完再回主线程更新结果。队列要设上限比如只保留最新一帧避免处理不过来时内存暴涨。JNI 层的检测器、关键点器、识别器可以复用但不要多线程同时调用同一个实例除非确认库内部线程安全保守做法是一个线程一套实例或者加锁串行。private val faceExecutor Executors.newSingleThreadExecutor() fun onFrame(frame: ByteArray, width: Int, height: Int) { faceExecutor.execute { // 只保留最新帧时这里可以先清空旧任务 val count SeetaFaceNative.nativeDetect(frame, width, height) runOnUiThread { // 回主线程只做 UI 更新不做计算 overlayView.setFaceCount(count) } } }这段代码用单线程池把检测移出主线程onFrame只负责提交任务。newSingleThreadExecutor保证同一时间只有一个检测任务避免 native 实例被并发调用如果业务要更高吞吐改成固定大小线程池但每个线程要各自持有 native 实例。runOnUiThread里只更新控件不要在里面做模型加载或特征比对。帧字节数组如果被复用提交任务前要拷贝一份否则处理时数据可能已经被下一帧覆盖。native 对象的生命周期也要管。Activity 销毁时调用nativeRelease把检测器、关键点器、识别器 delete 掉特征数组用完后及时释放。Java 层如果持有 native 指针不要依赖 finalize显式 close 更稳。内存问题在 DEMO 阶段不明显连续跑几分钟或者反复进出页面就会暴露提前把释放路径写好比后面用 profiler 抓泄漏省事。5. 避坑与排查so 库、模型、ABI 的血泪经验5.1 UnsatisfiedLinkErrorso 没打进 APK 还是名字不对现象工程编译通过安装到真机后一调用 native 方法就崩日志第一行是java.lang.UnsatisfiedLinkError: dlopen failed: library libseetaface6.so not found。原因通常有两类一是 so 根本没打进 APKjniLibs 路径多套了一层或者 Gradle 没配 sourceSets二是 so 打进去了但System.loadLibrary的名字和文件名对不上比如文件叫libseetaface6.so代码里写loadLibrary(seetaface)。还有一种情况是只带了 armeabi-v7a设备是 64 位且系统没有兼容路径加载时找不到对应 ABI。解决先把 APK 拆开看 lib 目录确认lib/arm64-v8a/libseetaface6.so或lib/armeabi-v7a/libseetaface6.so存在再用adb shell getprop ro.product.cpu.abi看设备主 ABI。APK 里没有 so就回头查jniLibs.srcDirs和目录层级有 so 但名字不匹配就改loadLibrary参数。不要用System.load写绝对路径去绕路径在安装后不稳定换设备就失效。5.2 模型加载失败路径、大小写、压缩三件事现象so 加载成功nativeInit返回 1001 或 1002日志里能看到模型路径但检测器就是创建不出来。原因常见三个assets 拷贝到filesDir时文件名大小写和代码里写的不一致拷贝过程被中断本地文件长度为 0把模型放在res/raw或 assets 子目录native 层拼路径时少了一层。还有一种隐蔽情况模型文件被二次压缩扩展名是.csta实际内容不是模型加载时直接失败。解决在nativeInit里把拼接后的完整路径打到 logcat同时在 Kotlin 拷贝完成后打印每个文件的长度。路径对、长度大于 0、文件名一致再查模型文件本身是否完整。关键模型可以做一次 MD5 对比至少比较文件大小和 assets 条目大小。不要把模型放res/raw图省事native 层拿不到稳定文件路径最后还是要拷贝。5.3 真机与模拟器差异x86_64 模拟器跑不了 arm so现象Android Studio 自带模拟器安装成功点按钮就崩日志报dlopen failed: library libseetaface6.so is 64-bit instead of 32-bit或者直接找不到 so。原因这份 DEMO 只带了 arm64-v8a 和 armeabi-v7a 两套 ARM so模拟器常见 ABI 是 x86_64ABI 不匹配安装阶段可能不报错运行到 native 加载就失败。有人会以为是模型问题其实是 CPU 架构问题。解决首轮验证直接用 ARM 真机不要跟模拟器较劲。真机上先跑adb shell getprop ro.product.cpu.abi确认主 ABI再看 APK 里有没有对应目录。如果必须用模拟器需要准备 x86_64 版本的 so这份 DEMO 没带就不要硬塞。排查时先看 logcat 里的dlopen行ABI 错误通常会写得很直白。5.4 版本与依赖冲突多个 so 重复、符号被抢现象工程里加了别的 native 库之后原本能跑的人脸检测开始崩日志出现duplicate symbol、cannot locate symbol或者两个 so 都带 OpenCV 符号加载顺序一变就翻车。原因不同模块引入了同名 so或者 CMake 里重复链接了不同版本的库Gradle 依赖里也可能传递进来一份 so和 jniLibs 下的那份冲突。解决先跑依赖树确认有没有重复的 native 依赖再看 APK 的 lib 目录同一个 ABI 下是不是出现了两个功能相同的 so。不要一上来就pickFirst那只是让构建通过运行时用错版本照样崩。常见做法是保留一份 so把其他模块的 native 依赖排除掉或者在 CMake 里明确链接路径。改完 clean 一次再装避免增量构建残留旧 so。6. 进阶验证用日志和基准图把 DEMO 跑成可交付模块6.1 构建产物检查APK 里到底有没有两份 ABI编译通过不等于打包正确。我一般先拆 APK 看 lib 目录再决定要不要上真机。下面两条命令一条列 APK 里的 so一条看 so 的 ELF 头确认架构没放错。# 列出 APK 内 lib 目录下的 so确认 ABI 目录和文件名 unzip -l app/build/outputs/apk/debug/app-debug.apk | grep lib/ # 解出其中一个 so 看 ELF 头确认是 AArch64 还是 ARM unzip -p app/build/outputs/apk/debug/app-debug.apk lib/arm64-v8a/libseetaface6.so /tmp/libseetaface6.so readelf -h /tmp/libseetaface6.so | grep Machine第一条命令的grep lib/只保留 APK 内 lib 路径重点看有没有lib/arm64-v8a/和lib/armeabi-v7a/两个目录以及 so 名字是否和loadLibrary对应。第二条命令把 so 解到临时目录readelf -h看Machine字段AArch64 对应 arm64-v8aARM 对应 armeabi-v7a。如果目录写的是 arm64-v8aELF 头却是 ARM说明文件放错了运行必崩。这个检查比看 Gradle 日志直观。6.2 单张基准图验证固定输入看检测框和耗时真机跑通后不要只用摄像头随便照。准备一张固定基准图放到 assets 里启动时跑一次检测日志输出人脸数量、检测框坐标和耗时。这样每次改参数、换模型都有可比对的结果。下面是一段计时伪代码重点在System.nanoTime前后包住 native 调用。val bitmap BitmapFactory.decodeStream(assets.open(benchmark/face_01.jpg)) val bytes bitmap.toBGRByteArray() // 按实际通道转换 val start System.nanoTime() val count SeetaFaceNative.nativeDetect(bytes, bitmap.width, bitmap.height) val costMs (System.nanoTime() - start) / 1_000_000 Log.i(SeetaBench, faces$count cost${costMs}ms size${bitmap.width}x${bitmap.height})这段代码用固定图片跑一次检测输出人脸数量和毫秒耗时。toBGRByteArray是示意实际按 JNI 输入通道实现costMs包含 JNI 调用和检测时间不含图片解码解码放在计时外面。基准图选正脸、侧脸、遮挡各一张记录三组数据后面调min_face_size和阈值时就有参照。不要只跑摄像头摄像头帧率、自动对焦、光线都会影响判断。6.3 交付前检查表把玄学问题变成可复现项检查项动作通过标准APK 内 sounzip -l看 lib 目录目标 ABI 目录存在且文件名匹配so 架构readelf -h看 Machine与目录 ABI 一致模型拷贝启动日志打印路径和长度路径存在长度大于 0初始化返回码看nativeInit返回值0非 0 能定位到具体模型基准图检测固定图跑三次人脸数量稳定耗时波动可接受线程与释放连续进出页面无崩溃内存不持续上涨这张表看着简单真正常漏的是“模型长度”和“初始化返回码”。很多 DEMO 只返回 boolean失败时不知道缺哪个模型改成错误码后排查时间能砍一半。连续进出页面测的是 native 对象释放如果每次进入都 new 一套检测器又不释放跑十几次就能看到内存曲线往上走。从那以后我每次拿到带 so 的 Android DEMO都强制先走一遍“拆 APK 看 lib、打印模型路径、跑一张基准图”这三步再谈接摄像头和业务逻辑。血泪经验就是so 和模型的问题九成能在编译后十分钟内暴露别等装到真机上靠猜。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站