
简介这是面向Android开发者的可实时运行人体检测Demo资源重点解决移动端行人/人体检测验证与快速落地问题适合想接入YOLOv5人体检测模型却没有现成端侧工程基础的开发者也适合作为Android目标检测入门的学习样本。这类需求常见于安防监控、智慧零售、体感互动等移动场景。资源包共2个文件一个编译完成的APK安装包可直接安装到Android手机测试实时检测效果一个JSON格式的输出元数据文件用于核对APK构建信息与产物结构。压缩包整体约50.6MB体量精简适合快速下载评估。目前已有2913人浏览学习。通过这个Demo可以在真机上体验人体实时框选免去自行搭建模型转换与端侧推理链路的重复劳动同时可结合JSON元数据核对APK版本与打包信息为后续产品原型验证、方案预研或课程实验提供直观参考。1. 人体检测 Demo 到底能干什么打开摄像头实时框出镜头里的每个人把手机镜头对准办公室门口屏幕上每一个进出的人都被绿色方框稳稳框住方框顶部跟着一跳一跳的置信度数字全程本地推理不依赖任何网络服务。这份 Android 人体检测 APP Demo就是把这套「实时行人检测」链路完整拉通的最短路径。对于想做 Android 行人检测、人流统计、安防告警但一直卡在「模型怎么落地」「摄像头帧怎么喂给检测器」「结果怎么画回屏幕」这三道坎的同学它是很好的参照。它适合两类人刚接触安卓端机器学习、想把模型真正塞进手机跑的开发者以及业务上要快速验证人体检测效果、再往自己项目里移植的工程师。下面我会按技术选型、工程结构、核心链路、运行配置、避坑记录到二次改造的顺序拆完这一个 Demo。2. 技术选型与工程结构为什么是 TFLite 和 SSD MobileNet而不是先跑通再折腾2.1 移动端人体检测的三条路我为什么选了 TFLite一套 Android 人体检测 Demo 可选的路线不少但真上手对比过会发现每一条路的取舍完全不同。我梳理过最常见的三类方案分别适合不同场景方案APK 体积增量摄像头接入成本后处理自由度适合人群OpenCV DNN较大通常 30MB 起中需自己管理相机高NMS 完全手写熟悉 OpenCV、不介意包体的开发者MediaPipe Task API小最低内置摄像头封装低任务管线偏黑盒想 10 分钟看到效果不做深改的人TFLite Interpreter小单个模型文件中高需配合 CameraX高检测头输出自己解析想搞懂原理、方便换模型的开发者这个 Demo 选的是第三类TFLite Interpreter CameraX。理由很直接TFLite 的部署产物就是 assets 目录里一个 .tflite 文件推理入口极简一个 Interpreter 对象就能跑起来摄像头这边的帧采集用 CameraX 的 ImageAnalysis它能主动适配不同手机的旋转角度省掉大量恶心人的兼容代码。MediaPipe 虽然封装得更舒服但换个模型、改个 NMS 流程都会被框架限制住不适合拿来做「人体检测项目解析」的样本。模型层面SSD MobileNet 是这类「手机端目标检测 Demo」里最不吃亏的选择。MobileNet 做特征提取在 CPU 上就能轻松跑到 20fps 以上SSD 检测头直接输出候选框、类别、置信度三组数据不需要像 YOLO 那样做复杂的 anchor 解码。对「实时运行」这个前提来说这个组合的稳定性和解释成本是最平衡的。2.2 工程结构一张包图说清数据从哪来、结果往哪去项目源码采用了很干净的三层划分梳理目录结构基本就能知道这张链路是怎么串起来的。app/ ├─ src/main/ │ ├─ assets/ │ │ ├─ ssd_mobilenet_v1_coco_quant_postprocess.tflite │ │ └─ labels.txt │ └─ java/com/example/persondetector/ │ ├─ MainActivity.kt # CameraX 生命周期绑定、权限处理 │ ├─ detector/ │ │ ├─ PersonDetector.kt # TFLite 模型加载、图像预处理、推理 │ │ └─ Detection.kt # 检测结果数据类 │ └─ ui/ │ └─ OverlayView.kt # 自定义 View负责画方框和置信度 ├─ build.gradle └─ AndroidManifest.xml这个分层是典型的「入口不碰算法、算法不碰 UI」结构。MainActivity 只负责两件事请求相机权限、把 CameraX 的 Preview 和 ImageAnalysis 绑定到生命周期上PersonDetector 完全不关心界面它接收 Bitmap 和旋转角度吐出一组 Detection 对象OverlayView 在 UI 线程上消费这些检测结果把归一化坐标换算成 View 上的实际像素位置来画框。这样设计的好处是后面想把 Demo 改成「人流计数」「安全帽检测」只需替换 PersonDetector 内部实现或扩展 OverlayView 的绘制逻辑不会动到 CameraX 这一层。这里有个值得注意的参数约定PersonDetector 的目标输入尺寸写死为 300x300置信度阈值默认 0.5NMS 阈值默认 0.45类别索引 1 对应 person。这些数值在 Demo 是硬编码的目的是让第一次看源码的人一眼能找到调参入口实际开发中我会把它们放进一个 Config 对象管理后面改造时不用满文件翻常量。3. 核心链路拆解一帧画面从摄像头到屏幕上绿色方框的完整旅程3.1 CameraX 取帧Analyzer 回调里别做重活先看摄像头采集端。Demo 用的是 CameraX 的 ImageAnalysis它专门为「逐帧分析」设计把相机帧按配置的分辨率送进 Analyzer 回调。val imageAnalysis ImageAnalysis.Builder() .setTargetResolution(Size(1280, 720)) .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) .setOutputImageFormat(ImageAnalysis.OUTPUT_IMAGE_FORMAT_YUV_420_888) .build() imageAnalysis.setAnalyzer(cameraExecutor) { imageProxy - val bitmap imageProxy.toBitmap() // YUV_420_888 转成 RGBA Bitmap val rotation imageProxy.imageInfo.rotationDegrees personDetector.detect(bitmap, rotation) { result - runOnUiThread { overlayView.setDetections(result.detections) overlayView.invalidate() } imageProxy.close() // 必须 close否则相机栈被占死 } }这段代码有四个关键点。第一setAnalyzer(cameraExecutor)传的是独立的线程池不是主线程这样 300x300 的模型推理不会卡住 UI。第二STRATEGY_KEEP_ONLY_LATEST表示当推理速度跟不上帧率时直接丢弃旧帧、保留最新帧——对实时人体检测来说丢掉几帧远好过渲染队列越积越长导致画面延迟越拖越大。第三imageProxy.imageInfo.rotationDegrees是个特别容易忽略的细节竖屏手机拿到的帧默认是横向的不按这个角度旋转后面画框一定偏移。血泪经验是imageProxy.close()必须放在回调消费完数据之后而且是每个分支都要执行到一旦某个提前 return 漏了 close相机不出几秒就不再回调新帧表现为预览卡死但 App 不崩溃。3.2 PersonDetector 内部从 Bitmap 到 TFLite 输出的三步转换PersonDetector 是整条链路的核心它的构造器负责加载模型detect 方法负责把一张 Bitmap 变成一组检测框。class PersonDetector(context: Context) { private val inputSize 300 private lateinit var interpreter: Interpreter init { val modelBuffer loadModelBuffer(context) // 从 assets 读取 tflite interpreter Interpreter(modelBuffer) } fun detect(bitmap: Bitmap, rotationDegrees: Int, listener: (DetectionResult) - Unit) { val rotated rotateBitmap(bitmap, rotationDegrees) val scaled Bitmap.createScaledBitmap(rotated, inputSize, inputSize, true) // 1x300x300x3 的 float32 输入SSD MobileNet 需要归一化到 [-1, 1] val input ByteBuffer.allocateDirect(inputSize * inputSize * 3 * 4) .order(ByteOrder.nativeOrder()) for (pixel in scaled.pixels) { input.putFloat(((pixel shr 16 and 0xFF) / 127.5f) - 1) input.putFloat(((pixel shr 8 and 0xFF) / 127.5f) - 1) input.putFloat(((pixel and 0xFF) / 127.5f) - 1) } val outputLocations Array(1) { Array(10) { FloatArray(4) } } val outputClasses Array(1) { Array(10) { FloatArray(1) } } val outputScores Array(1) { Array(10) { FloatArray(1) } } val outputCount Array(1) { IntArray(1) } interpreter.runForMultipleInputsOutputs( arrayOf(input), mapOf( 0 to outputLocations, 1 to outputClasses, 2 to outputScores, 3 to outputCount ) ) // 后处理交给 nms 方法此处略 } }这里的输入张量形状是 1x300x300x3对应模型要求的尺寸。图像像素从 RGB 0~255 映射到 -1~1 区间是 SSD MobileNet 训练时的预处理约定写错这个归一化范围检测置信度会明显下降而且很难排查。输出端四个张量的含义分别是locations 给 10 组候选框的 x1、y1、x2、y2 归一化坐标classes 给每个候选框的类别索引scores 给置信度num_detections 给出实际有效框数量通常小于 10。之所以是 10不是随意选的这是 SSD 检测头在单张图上保留的最多候选框数实际有效数量由第四个输出决定。一个常见疑问是「为什么不直接跑 YOLO 的 TFLite 模型」。YOLO 系列的输出往往是一个大张量里面要自己拆 box、obj、cls还要做 decode 和 stride 对齐代码量和出错概率都高出不少。SSD 这套输出格式对新手更友好等整条链路跑通、理解了坐标和 NMS 的作用再换 YOLO 就是后处理函数重写的问题。3.3 后处理置信度过滤、NMS 去重、归一化坐标映射到屏幕TFLite 输出的 10 个候选框大多数是重叠的需要经过 NMS非极大值抑制去重再把归一化坐标换算成 OverlayView 的实际像素坐标。fun nms( boxes: ListRectF, scores: ListFloat, classes: ListInt, personClassIdx: Int 1, confidenceThreshold: Float 0.5f, iouThreshold: Float 0.45f ): ListDetection { // 第一步类别过滤 置信度过滤 val candidates boxes.indices.filter { i - classes[i] personClassIdx scores[i] confidenceThreshold }.sortedByDescending { scores[it] } // 第二步按分数从高到低逐个和已保留框计算 IoU val kept mutableListOfInt() val selected mutableListOfRectF() for (idx in candidates) { val box boxes[idx] val overlaps selected.any { intersectionOverUnion(it, box) iouThreshold } if (!overlaps) { kept idx selected box } } return kept.map { Detection(boxes[it], scores[it]) } }后处理的承上启下之处在于这一步拿到的 RectF 里 x1、y1、x2、y2 全部是 0~1 的归一化值直接拿去做 Canvas.drawRect 会画在左上角一小块区域。OverlayView 绘制时必须先做换算val left box.left * overlayView.width val top box.top * overlayView.height val right box.right * overlayView.width val bottom box.bottom * overlayView.height因为检测输入已经被缩放成 300x300 正方形归一化坐标不携带宽高比信息所以直接按 OverlayView 的宽高等比放缩即可。IoU 阈值 0.45 是目标检测领域很通用的默认值调高了重复框变多调低了一个人可能被截成好几截如果不是场景特殊建议先不动这个值主要调置信度阈值。4. 构建与运行从 zip 解压到手机上实时出框4.1 解压、导入与依赖配置拿到 zip 后先用 Android Studio 打开根目录的build.gradle让 Gradle 自动同步。源码里已经配好了必要依赖理论上同步完就能跑如果打开后提示找不到依赖检查app/build.gradle是否包含这几项dependencies { implementation(androidx.camera:camera-core:1.2.3) implementation(androidx.camera:camera-lifecycle:1.2.3) implementation(androidx.camera:camera-view:1.2.3) implementation(org.tensorflow:tensorflow-lite:2.13.0) implementation(org.tensorflow:tensorflow-lite-support:0.4.4) }版本组合是我验证过能稳定跑的搭配。camera-view 负责把预览画面渲染到 PreviewView 上camera-lifecycle 负责把绑定工作从手动管理 Camera 设备简化为跟随 Activity 生命周期自动开关。如果没有 camera-view预览画面就要自己处理 Surface 生命周期代码量会翻一倍。同步时有两个坑提前说Gradle 版本不要盲目升级到最新AGP 8.x 和旧版 SDK 配置有时不兼容跑 Demo 用 Android Studio 默认配好的版本就行如果出现依赖冲突优先执行一次 File Sync Project with Gradle Files而不是急着改版本号。4.2 assets 里的模型位置错了启动即崩TFLite 模型必须放在app/src/main/assets/目录下源码里默认对应两个文件ssd_mobilenet_v1_coco_quant_postprocess.tflite和labels.txt。前者是量化后的检测模型体积 7MB 左右后者是类别标签每行一个类名Demo 里只关心索引 1 对应的 person其他标签用于排查输出类别是否有异常偏移。model 文件放错位置的表现非常戏剧化应用启动瞬间闪退Logcat 里报FileNotFoundException或者Not a valid flatbuffer。这种问题九成是 assets 路径写错一是确认文件名大小写和 assets 目录完全一致二是检查 APK 里的实际文件用 Android Studio 的 View Tool Windows Device Explorer 打开包体看 assets 层有没有模型。另一个隐藏坑是 tflite 文件必须配置为不压缩否则安装包会把它当文本资源压缩Interpreter 走 mmap 加载时直接报错。gradle 里补一条android { aaptOptions { noCompress tflite } }4.3 真机运行与性能预期直接把手机用 USB 连上电脑开启开发者模式里的 USB 调试点 Run 按钮即可安装。需要注意这个 Demo 强烈建议真机跑不要用模拟器。CameraX 在模拟器上的 Camera2 实现兼容性很差经常出现预览黑屏、旋转角度错误、ImageAnalysis 收不到帧三种问题叠加排查起来毫无头绪。真机上的性能预期大概是中端设备如骁龙 778G 级别跑 300x300 输入单帧推理耗时 20~40ms加上图像旋转、缩放和 NMS整体帧率能稳定在 20~30fps 之间。如果明显低于这个水平优先怀疑两点一是日志里推理耗时占比过大说明跑的是非量化 float 模型需要换 quant_postprocess 版本二是 CameraX 的 targetResolution 被改动过分辨率拉得过高YUV 到 Bitmap 的转换耗时超过推理本身。预期帧率这件事最好开 log 验证我后面会讲怎么在代码里埋点。5. 避坑记录实时人体检测最容易翻车的 5 个地方5.1 启动闪退Interpreter 初始化直接报错现象应用装上以后一打开就崩Logcat 里能看到Could not map model file或Not a valid flatbuffer定位到 PersonDetector 的 init 位置。原因最常见的是 .tflite 文件被 Gradle 压缩导致 mmap 加载失败其次是模型文件本身残缺或者 assets 目录下文件名与代码不符。解决先在app/build.gradle加上aaptOptions { noCompress tflite }重新安装再试若还崩检查 APK 包体里 assets 目录的实际文件名用 Device Explorer 看别只看工程目录。这两步能覆盖绝大多数启动即崩的现场。5.2 模拟器上一切正常换真机画面旋转 90 度现象Demo 在模拟器上跑得好好的安装到真机上预览画面横过来了画框位置明显错位。原因模拟器固定报告 0 度旋转真机上 imageInfo.rotationDegrees 会根据传感器姿态变化检测代码没有处理这个角度等于把横图当竖图送进模型。解决在 detect 方法里按 rotationDegrees 对 Bitmap 做旋转校正。模拟器能过不代表真机能过摄像头帧旋转问题是移动端视觉应用的第一大玄学任何手机型号差异都会暴露它。5.3 摄像头几秒后不再回调帧画面凉在那里现象App 刚启动时预览和检测都正常五六秒后画面静止不崩、没报错、相机灯还亮着但 OverlayView 不再有新框出现。原因ImageProxy 没有被 close。CameraX 对每个 ImageProxy 是有限额的回调里拿到帧却不释放队列被占满后 CameraX 自动停止投递新帧。常见漏关位置是 detect 回调里发生异常或提前 return 时漏掉了 close。解决把 imageProxy.close() 放进 finally 块并且确保 setAnalyzer 的 Lambda 每个分支都能走到释放逻辑。这是一条铁律拿帧必有放帧。5.4 检测框比人大两圈或者歪在肩膀旁边现象人能检测出来但绿色框明显偏移框住的是半个人加一截空气看起来不像实时锁定人体。原因大概率是 Bitmap 旋转后没有同步旋转坐标或者缩放后的 300x300 输入与展示视图的长宽比不一致导致绘制时 scale 不均匀。还有一个隐蔽原因输入图像从 YUV 转 Bitmap 时行对齐和通道顺序写反画面发绿、变色但检测还能出结果框的位置自然跟着偏。解决先固定检查两步——rotationDegrees 是否参与预处理OverlayView 绘制时是否都用 View 宽高做比例换算再用一个固定图片做单元测试把检测结果和真实坐标画在同一张图上对比能快速定位是预处理问题还是绘制问题。5.5 阈值调到 0.25 后检测结果全面崩坏现象把 confidenceThreshold 从 0.5 降到 0.25 后椅子、背包、柱子全部被框出来画面全是框帧率还骤降。原因置信度阈值太松把大量低质量候选框放进了后处理这些框之间重叠度高IoU 阈值 0.45 拦不住所有重复框NMS 计算量也跟着暴涨。解决置信度阈值不要低于 0.4一般人体检测 0.5 足够如果确实需要调低同时把 iouThreshold 上调到 0.6削弱 NMS 的保留力度。调参没有银弹每次只动一个参数用固定视频片段对比结果才能摸清当前模型的脾气。6. 二次开发只留人体、换模型和帧率验证的三个改法6.1 改第一行只检测 person过滤掉其他所有类别Demo 默认已经按 personClassIdx 过滤了类别但如果你拿到的模型是多类别的或者想改成特定类别的检测只需改一处常量。在 NMS 方法的参数里把 personClassIdx 指定为你的目标类别索引比如安全帽在某个模型里的索引是 6改成 6 即可。类别索引来自 labels.txt 的每一行行号注意是 0 起始第 1 行才是索引 1。改之前先打开 labels.txt 确认目标类目在第几行这一步能帮你省掉半天「为什么检测不出我想要的东西」的排查时间。6.2 换模型输入尺寸和输出张量是两个最关键的校验点想从 SSD MobileNet 换成 YOLO 系或者自训练的模型时改动集中在 PersonDetector。第一处是 inputSizeYOLO 常见的输入是 320 或 640这个值和模型的 input shape 必须严格一致否则 Interpreter 直接抛异常。第二处是输出张量解析YOLO 模型的输出通常是 1xNx(候选框维度)而不是 SSD 的四个独立张量NMS 调用前要把那个扁平数组拆成 boxes、scores、classes 三份。我先说个穷办法也是我惯用的调试手段拿到新模型后先用脚本打印模型的输入输出签名确认每个张量的 shape再动代码——靠猜和试错去解析输出张量是这个项目里最容易卡住人的两天。6.3 加一个帧率日志改造前后有数据可对比不管换模型还是改阈值最终都要回答「实时性到底怎么样」。最简单有效的验证方法是在 detect 方法里埋耗时统计val startTime SystemClock.elapsedRealtime() // ... 预处理、推理、后处理全部走完 Log.d(PersonDetector, infer: ${SystemClock.elapsedRealtime() - startTime} ms)从那次被输出张量顺序坑了两天之后我每次拿到新模型或者调整参数都强制自己先走一遍「看模型签名 → 埋点跑 1 分钟 → 对比日志」的流程不再靠肉眼盯画面来判断实时性。这个习惯让后面的模型替换、场景改造都稳了很多。这份 Demo 的价值在于把一整条移动端检测链路完整暴露在面前你既可以在现成代码上改颜色、改阈值快速看效果也可以拿它当脚手架换成自己的模型和业务逻辑希望帮到你。本文还有配套的精品资源点击获取