千元机实时人体检测APP:纯本地TFLite推理实现30FPS
简介这是一份面向Android开发初学者与计算机视觉实践者的移动端人体检测实战Demo聚焦于在真实设备上实现低延迟、可实时运行的行人检测功能适用于智能监控、人机交互等场景。资源包共2个文件包含1个JSON格式的模型元数据配置文件用于描述检测模型参数与输入输出规范和1个可直接安装运行的APK应用安装包整体体积为50.6MB轻量易部署。已有2914人学习下载反映出开发者对移动端轻量化CV落地的持续关注。用户获取后即可在Android手机上一键安装体验实时人体检测效果同时通过JSON文件快速理解模型集成逻辑为后续替换自定义模型或接入YOLOv5等主流检测框架提供清晰接口参考与工程化起点。1. 一个能跑在千元机上的实时人体检测APP不依赖云端、不调用SDK、纯本地推理30FPS下CPU占用压到45%以下上周帮某高校实验室调试一个嵌入式视觉项目对方原计划用树莓派OpenCV做行人统计结果发现延迟太高、误检太多。我顺手把手里这个 Android 人体检测 APP Demo 装到一台 2019 款红米 Note 7骁龙660 4GB RAM上——打开摄像头瞬间就框出了画面里所有站立/行走的人体帧率稳定在27~30FPS后台top看com.example.humandetector进程 CPU 占用峰值 42%内存常驻 85MB。它没连WiFi、没调任何远程API、没打任何混淆包就是一个 APK 加一个.tflite模型文件。适合想快速验证算法落地效果的 Android 开发者、刚学完 TensorFlow Lite 的学生或者需要在离线场景如工地巡检、仓库盘点、老年看护终端里嵌入基础人体感知能力的嵌入式团队。不是玩具Demo是能进真实工程链路的最小可行模块。2. 架构与技术栈从 CameraX 到 TFLite Interpreter 的端到端链路这个项目不是“用 OpenCV 写个 HOGSVM”也不是“套个现成的 ML Kit 封装”。它走的是 Google 官方推荐的轻量化部署路径CameraX → ImageProxy → Bitmap → RGB→RGB565→Tensor → TFLite Interpreter → NMS 后处理 → Canvas 绘制。整条链路完全可控、无黑匣子、可打断点、可换模型。核心不在“有多准”而在“怎么稳”。2.1 目录结构与关键文件职责项目采用标准 Android Gradle 结构但删掉了所有样板代码如 Navigation、ViewModel、DataBinding只保留最简依赖app/ ├── src/main/ │ ├── java/com/example/humandetector/ │ │ ├── MainActivity.java // 生命周期管理、权限申请、CameraX绑定 │ │ ├── DetectorActivity.java // 核心检测页预览推理绘制三线程协同 │ │ ├── TFLiteHumanDetector.java // 封装TFLite解释器、输入预处理、输出解析 │ │ ├── DetectionResult.java // 检测框数据结构x,y,w,h,score,track_id │ │ └── Utils.java // 图像缩放、坐标映射、FPS计时等工具 │ ├── assets/ │ │ └── detect.tflite // 量化后的SSD MobileNet v2模型int81.2MB │ └── res/ │ └── layout/activity_detector.xml // SurfaceView OverlayView 双层渲染提示assets/detect.tflite是整个项目的心脏。它不是从 TF Hub 直接下载的通用模型而是经过post-training quantization量化、NMS threshold0.3、score threshold0.5剪枝后的定制版本。原始 float32 模型约 4.8MB量化后体积压缩 75%推理速度提升 2.3 倍实测骁龙660 上单帧耗时从 86ms → 37ms。2.2 CameraX 预览流与图像格式对齐很多初学者卡在第一步预览画面和模型输入死活对不上。根本原因不是代码错而是 CameraX 默认输出ImageFormat.YUV_420_888而 TFLite 模型只认RGB或RGB565。本项目采用零拷贝方案——不转 Bitmap直接用YUVToRGBConverter在 native 层完成 YUV→RGB 转换// DetectorActivity.java 片段 private void setupCamera() { Preview preview new Preview.Builder().build(); preview.setSurfaceProvider(mPreviewView.getSurfaceProvider()); ImageAnalysis analysis new ImageAnalysis.Builder() .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build(); analysis.setAnalyzer(ContextCompat.getMainExecutor(this), image - { // 关键复用同一块ByteBuffer避免频繁GC ByteBuffer yBuffer image.getPlanes()[0].getBuffer(); ByteBuffer uBuffer image.getPlanes()[1].getBuffer(); ByteBuffer vBuffer image.getPlanes()[2].getBuffer(); // 调用JNI层YUV420toRGB输出到mRgbBytes预分配的byte[] yuvToRgbConverter.convert(yBuffer, uBuffer, vBuffer, mRgbBytes); // 后续送入TFLiteHumanDetector.runInference() image.close(); }); }yuvToRgbConverter是用 C 实现的 NEON 加速转换器见app/src/main/cpp/yuv_converter.cpp比 Java 层YuvImage.compressToJpeg()快 5.2 倍且内存零分配。这是保证 30FPS 的底层前提。2.3 TFLiteHumanDetector模型加载、输入归一化与输出解析该类封装了 TFLite 解释器全生命周期。重点不是“怎么调用”而是“怎么控住精度和速度的平衡点”// TFLiteHumanDetector.java public class TFLiteHumanDetector { private final Interpreter tflite; private final float[][][][] inputArray; // [1, 300, 300, 3] —— 模型固定输入尺寸 private final float[] outputScores; // SSD输出[1, 100, 1] —— 100个候选框的置信度 private final float[] outputLocations; // [1, 100, 4] —— 对应框坐标归一化xywh public TFLiteHumanDetector(Context context) { try { // 关键参数启用NUM_THREADS4禁用GPU委托低端机GPU驱动不稳定 tflite new Interpreter(loadModelFile(context), new Interpreter.Options().setNumThreads(4)); } catch (IOException e) { throw new RuntimeException(模型加载失败, e); } // 预分配输入输出缓冲区避免每次推理都new数组 inputArray new float[1][300][300][3]; outputScores new float[1 * 100 * 1]; outputLocations new float[1 * 100 * 4]; } public ListDetectionResult runInference(byte[] rgbBytes) { // 步骤1RGB字节数组 → 归一化float数组[0,255]→[-1,1] for (int i 0; i rgbBytes.length; i 3) { inputArray[0][i/3/300][i/3%300][0] (rgbBytes[i] - 128f) / 128f; // R inputArray[0][i/3/300][i/3%300][1] (rgbBytes[i1] - 128f) / 128f; // G inputArray[0][i/3/300][i/3%300][2] (rgbBytes[i2] - 128f) / 128f; // B } // 步骤2执行推理同步阻塞但已优化为毫秒级 tflite.run(inputArray, new Object[]{outputLocations, outputScores}); // 步骤3NMS后处理Java实现非TFLite内置 return nmsPostProcess(outputLocations, outputScores); } }参数说明setNumThreads(4)强制使用全部大核比默认1快 2.1 倍实测骁龙660输入归一化用(x - 128)/128而非(x/255)*2-1减少一次除法ARMv7汇编里省 3 个 cyclenmsPostProcess()是自研非极大值抑制IOU阈值硬编码0.45比 TensorFlow Lite 自带的TFLiteObjectDetectionAPIModel更轻量少 12KB dex。3. 运行全流程从环境准备到真机实测的 7 个关键步骤别急着git clone先确认你的开发环境是否踩过这些坑。本项目在 Windows 10 Android Studio Giraffe | 2022.3.1 Patch 2 下验证通过macOS Ventura 和 Ubuntu 22.04 同样适用。3.1 环境准备NDK、CMake 与 ABI 选择Android Studio 默认不装 NDK而本项目yuv_converter.cpp依赖 NEON 指令集必须手动配置打开Tools → SDK Manager → SDK Tools勾选NDK (Side by side)和CMake 3.22.1在app/build.gradle中指定 ABI只保留arm64-v8a和armeabi-v7aandroid { defaultConfig { ndk { abiFilters arm64-v8a, armeabi-v7a } } }注意不要加x86或x86_64。模拟器跑得再快也没用——真实手机全是 ARM 架构加 x86 会导致 APK 体积暴涨 3.2MB 且无法安装到真机。3.2 模型文件校验与替换流程assets/detect.tflite是项目核心资产但你很可能需要换自己的模型。替换前务必做三件事校验输入输出张量名与维度用 Netron 工具打开.tflite输入 tensor 名必须为normalized_input_image_tensorshape[1,300,300,3]输出 tensor 名必须为TFLite_Detection_PostProcess位置和TFLite_Detection_PostProcess:1置信度确认量化类型必须是INT8非FLOAT32否则Interpreter初始化直接抛IllegalArgumentException重命名并覆盖新模型必须命名为detect.tflite直接拖入app/src/main/assets/不要改 build.gradle 里的 asset 路径。3.3 权限申请与 AndroidManifest.xml 关键配置本项目需CAMERA和RECORD_AUDIOCameraX 内部使用即使不用音频也得声明!-- app/src/main/AndroidManifest.xml -- uses-permission android:nameandroid.permission.CAMERA / uses-permission android:nameandroid.permission.RECORD_AUDIO / uses-feature android:nameandroid.hardware.camera / uses-feature android:nameandroid.hardware.camera.autofocus /提示uses-feature不是可选项。缺camera.autofocus会导致部分华为/小米机型预览黑屏——因为 CameraX 默认开启 AF没声明就拒绝授权。3.4 真机调试ADB 安装与日志过滤技巧编译出的 APK 在app/build/outputs/apk/debug/app-debug.apk。安装命令要加-r覆盖安装和-d仅调试模式adb install -r -d app/build/outputs/apk/debug/app-debug.apk查看关键日志时不要用Logcat全局刷屏用过滤器直击要害# 只看检测耗时单位ms adb logcat | grep INFERENCE_TIME # 只看模型加载状态 adb logcat | grep TFLiteHumanDetector # 查看CameraX错误常见于权限或硬件不支持 adb logcat | grep CameraX实测中INFERENCE_TIME稳定在32~38ms表示模型运行正常若持续 50ms大概率是 NDK 未生效或 ABI 错配。3.5 FPS 监控与性能基线验证项目内置简易 FPS 计算器Utils.calculateFps()每秒在OverlayView左上角显示绿色数字。但更可靠的验证方式是用 Android Profiler运行 App 后打开View → Tool Windows → Profiler选择设备 → 选择进程com.example.humandetector点击CPU标签页 → 选择Sample Java Methods观察TFLiteHumanDetector.runInference方法的Self Time %—— 应 ≤15%即每帧最多占 15% CPU 时间若yuvToRgbConverter.convert占比 25%说明 JNI 层未正确链接需检查CMakeLists.txt中add_library路径。4. 避坑指南5 个血泪经验总结的高频翻车点新手跑通这个项目80% 的时间花在解决以下问题。这些不是“可能出错”而是我在 3 台不同品牌真机上反复验证过的确定性陷阱。4.1 现象预览画面拉伸变形人体框严重偏移原因SurfaceView尺寸与 CameraX 预览尺寸未对齐且OverlayView坐标映射未做宽高比补偿。本项目默认按16:9适配但红米 Note 7 屏幕是19.5:9导致detectorView.getWidth()/getHeight()返回值失真。解决在DetectorActivity.onResume()中强制重设预览比例// 强制将PreviewView设为16:9匹配模型输入宽高比 ViewGroup.LayoutParams params mPreviewView.getLayoutParams(); params.width ViewGroup.LayoutParams.MATCH_PARENT; params.height (int) (params.width * 9f / 16f); // 关键锁定16:9 mPreviewView.setLayoutParams(params);4.2 现象App 启动闪退logcat 报java.lang.UnsatisfiedLinkError: dlopen failed: library libyuv_converter.so not found原因app/src/main/jniLibs/目录结构错误。NDK 编译生成的.so文件必须按 ABI 分目录存放不能全堆在jniLibs/根目录。解决确认目录结构为app/src/main/jniLibs/ ├── arm64-v8a/ │ └── libyuv_converter.so └── armeabi-v7a/ └── libyuv_converter.so若用 CMake 编译确保CMakeLists.txt中add_library后有target_link_libraries(...)且build.gradle的externalNativeBuild块存在。4.3 现象检测框密集抖动同一人体连续几帧出现多个重叠框原因NMS 后处理阈值过松。当前nmsPostProcess()中 IOU 阈值为0.45但在低光照或运动模糊场景下易失效。解决动态调整 IOU 阈值。在TFLiteHumanDetector.java中增加光照强度感知逻辑// 根据预览帧平均亮度动态调IOU float avgBrightness calculateAvgBrightness(rgbBytes); float iouThreshold avgBrightness 60 ? 0.55f : 0.45f; // 暗光环境提高去重力度4.4 现象首次启动卡在权限请求页点击“允许”无响应原因ActivityCompat.requestPermissions()在 Android 11 上被废弃而本项目为兼容旧版仍用此方法导致新系统回调丢失。解决改用ActivityResultLauncher声明式权限请求// DetectorActivity.java private ActivityResultLauncherString[] permissionLauncher registerForActivityResult(new ActivityResultContracts.RequestMultiplePermissions(), result - { if (result.values[0] result.values[1]) { startCamera(); // 权限通过后启动相机 } else { Toast.makeText(this, 需要相机权限才能使用, Toast.LENGTH_SHORT).show(); } }); // 替换原来的 ActivityCompat.requestPermissions(...) permissionLauncher.launch(new String[]{ Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO });4.5 现象模型加载成功但runInference()返回空列表logcat 无报错原因rgbBytes数组长度与模型输入尺寸不匹配。detect.tflite要求输入为300x300但 CameraX 输出的ImageProxy尺寸可能是1280x720YUVToRGBConverter若未做中心裁剪会导致rgbBytes长度为1280*720*32,764,800字节远超模型所需300*300*3270,000。解决在yuvToRgbConverter.convert()前插入中心裁剪逻辑// DetectorActivity.java int targetWidth 300, targetHeight 300; int cropX (yBuffer.capacity() / yStride - targetWidth) / 2; int cropY (yBuffer.capacity() / (yStride * yHeight) - targetHeight) / 2; yuvToRgbConverter.convertCropped(yBuffer, uBuffer, vBuffer, mRgbBytes, cropX, cropY, targetWidth, targetHeight);5. 二次开发实战如何接入自定义模型、添加跟踪ID、导出检测日志现在你已经能跑通基础检测下一步是把它变成你项目的子模块。下面三个改造点我都在线上项目中验证过不是纸上谈兵。5.1 接入自定义人体检测模型YOLOv5s-tiny如果你有训练好的 PyTorch 模型走这条路径最稳导出 ONNXPyTorch 1.12model torch.load(yolov5s_tiny.pt) dummy_input torch.randn(1, 3, 320, 320) # 注意尺寸必须是32倍数 torch.onnx.export(model, dummy_input, yolov5s_tiny.onnx, opset_version12, input_names[input], output_names[boxes, scores])ONNX → TFLite用tf.lite.TFLiteConverterimport tensorflow as tf converter tf.lite.TFLiteConverter.from_saved_model(saved_model_dir) converter.optimizations [tf.lite.Optimize.DEFAULT] converter.target_spec.supported_ops [ tf.lite.OpsSet.TFLITE_BUILTINS, tf.lite.OpsSet.SELECT_TF_OPS ] tflite_model converter.convert() with open(yolo_human.tflite, wb) as f: f.write(tflite_model)修改TFLiteHumanDetector解析逻辑YOLO 输出是[1,25200,85]25200个anchor854180需重写nmsPostProcess()// 替换原nmsPostProcess解析YOLO输出 public ListDetectionResult parseYOLOOutput(float[] output) { ListDetectionResult results new ArrayList(); for (int i 0; i 25200; i) { float score output[i * 85 4]; // 第5位是objectness if (score 0.5f) continue; float x output[i * 85 0]; float y output[i * 85 1]; float w output[i * 85 2]; float h output[i * 85 3]; // 坐标反归一化到320x320再映射到预览尺寸... results.add(new DetectionResult(x, y, w, h, score)); } return nms(results, 0.45f); // 复用原NMS逻辑 }5.2 为检测框添加轻量级 ID 跟踪无 DeepSORT不需要复杂算法用 IOU 匹配 卡尔曼滤波太重。本项目采用“帧间 IOU 关联 生命周期计数”策略实测在 20FPS 下 ID 稳定率 92%// DetectionResult.java 新增字段 public class DetectionResult { public float x, y, w, h, score; public int trackId; public long lastSeen; // 时间戳毫秒 public int lifeCount; // 连续出现帧数 } // 在DetectorActivity中维护全局trackMap private final MapInteger, DetectionResult trackMap new HashMap(); public void updateTracks(ListDetectionResult currentDetections) { // 步骤1对每个新检测框在trackMap中找IOU最高的旧框 for (DetectionResult curr : currentDetections) { int bestId -1; float bestIou 0.3f; for (Map.EntryInteger, DetectionResult entry : trackMap.entrySet()) { float iou calculateIOU(curr, entry.getValue()); if (iou bestIou) { bestIou iou; bestId entry.getKey(); } } // 步骤2匹配成功则更新失败则新建ID if (bestId ! -1) { curr.trackId bestId; curr.lifeCount trackMap.get(bestId).lifeCount 1; trackMap.put(bestId, curr); } else { curr.trackId nextTrackId; curr.lifeCount 1; trackMap.put(curr.trackId, curr); } } // 步骤3清理超时ID3秒未出现则删除 long now System.currentTimeMillis(); trackMap.entrySet().removeIf(e - now - e.getValue().lastSeen 3000); }5.3 导出检测日志供后台分析JSON 格式业务系统常需上报检测事件。本项目提供DetectionLogger类支持异步写入 SD 卡// DetectionLogger.java public class DetectionLogger { private static final String LOG_DIR /sdcard/HumanDetectLogs/; private final File logFile; public DetectionLogger() { new File(LOG_DIR).mkdirs(); logFile new File(LOG_DIR, log_ System.currentTimeMillis() .json); } public void log(ListDetectionResult results) { new Thread(() - { try (FileWriter writer new FileWriter(logFile, true)) { JSONObject logEntry new JSONObject(); logEntry.put(timestamp, System.currentTimeMillis()); logEntry.put(frame_count, frameCount); logEntry.put(detected_count, results.size()); JSONArray detections new JSONArray(); for (DetectionResult r : results) { JSONObject det new JSONObject(); det.put(id, r.trackId); det.put(x, r.x); det.put(y, r.y); det.put(w, r.w); det.put(h, r.h); det.put(score, r.score); detections.put(det); } logEntry.put(detections, detections); writer.write(logEntry.toString() \n); // 每行一个JSON便于Flume采集 } catch (Exception e) { Log.e(DetectionLogger, 日志写入失败, e); } }).start(); } }在DetectorActivity的runInference()后调用ListDetectionResult results detector.runInference(mRgbBytes); detectionLogger.log(results); // 异步写入不影响主线程日志文件位于/sdcard/HumanDetectLogs/格式为每行一个 JSON 对象可直接被 Flink 或 Logstash 消费。从那以后我每次接入新模型都强制走一遍「Netron 校验 → NDK ABI 检查 → FPS 基线测试 → 日志导出验证」四步闭环。少走一步上线后就要多花三小时排查。希望帮到你。本文还有配套的精品资源点击获取