简介:这是面向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.5,NMS 阈值默认 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: List<RectF>, scores: List<Float>, classes: List<Int>, personClassIdx: Int = 1, confidenceThreshold: Float = 0.5f, iouThreshold: Float = 0.45f ): List<Detection> { // 第一步:类别过滤 + 置信度过滤 val candidates = boxes.indices.filter { i -> classes[i] == personClassIdx && scores[i] >= confidenceThreshold }.sortedByDescending { scores[it] } // 第二步:按分数从高到低,逐个和已保留框计算 IoU val kept = mutableListOf<Int>() val selected = mutableListOf<RectF>() 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。第一处是 inputSize,YOLO 常见的输入是 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 的价值在于把一整条移动端检测链路完整暴露在面前,你既可以在现成代码上改颜色、改阈值快速看效果,也可以拿它当脚手架换成自己的模型和业务逻辑,希望帮到你。
本文还有配套的精品资源,点击获取