资讯动态

Android行人检测Demo:YOLOv5转TFLite与坐标换算实战解析

发布时间:2026/10/16 10:34:38 来源:尧图企业网站定制
简介这是一份安卓人体检测演示压缩包面向安卓开发者与移动端视觉算法工程人员直接解决在手机设备上快速落地实时行人/人体检测能力的问题降低从模型部署到应用调试的入门门槛。压缩包整体约50.6MB共包含2个文件一个可直接安装运行的安装包以及一份配置文件前者无需编译即可让用户实时体验人体检测效果后者可辅助核对应用构建信息也可作为二次开发和调参时的参考。内容主打实时运行适用场景包括行人检测、安全监控、智能门禁、运动分析等既适合刚接触移动端目标检测的开发者快速上手也适合需要评估模型端侧性能的工程人员。目前已有2913人次学习/下载对希望将计算机视觉能力迁移到安卓设备上的开发者来说是一份低门槛的实用参考。1. 一个能实时跑在手机上的行人检测 Demo先弄懂坐标换算再去看模型做 Android 人体检测 Demo 时最容易翻车的往往不是选模型而是四个字坐标换算。这份资源是一个能实时运行在手机上的行人检测工程从 CameraX 采帧、YOLOv5 模型转 TFLite、解码候选框到 NMS 去重后再把矩形框叠加到预览画面全链路都留好了断点。我拆完的第一感受是模型反而是整条链路里最省事的环节真正让人卡住的是“TFLite 算子兼容”和“300×320 输入坐标到屏幕坐标的还原”。这份 Demo 适合想先跑通效果、再移植到自己项目里的人拿它做验证容器比从零搭工程省太多时间。2. 模型选型与转换为什么 YOLOv5 系列是 Android 人体检测 Demo 的默认起点2.1 三个常见人体检测方案的选型对比在 Android 端做人体检测业界常用路线大概有三条直接用 OpenCV 的 DNN 模块加载 YOLO 权重、接入现成的姿态估计框架、把 YOLO 系列模型转换成 TFLite 后调用。很多人一上来就选第一条因为“看起来最省事”但实际部署到手机上会发现 OpenCV DNN 在 CPU 上跑 YOLO中端机器连 10 帧都稳定不了而且 OpenCV 库体积本身就很大。第二条姿态估计路线通常输出关键点而非检测框它更擅长单人姿态分析在多人密集场景下会出现目标漏检。第三条路线也就是 YOLOv5 转 TFLite是我认为最稳妥的默认方案。它既能输出类别和矩形框又能在 GPU Delegate 加速下达到实时帧率而且模型体积控制在 10MB 左右适合打包进 Demo。做一个直观对比方案输出内容移动端实时性部署难度适用场景OpenCV DNN 加载 YOLO矩形框中端机一般低于 10 FPS低离线图片、服务端姿态估计类框架人体关键点可达 30 FPS低单人姿态、健身动作YOLOv5 导出 TFLite矩形框 类别约 20~30 FPS中多行人实时检测这份资源里的 Demo 默认走的是第三条路线用 YOLOv5s 预训练权重导出 TFLite再在 Android 端用 TensorFlow Lite Interpreter 加载。之所以选 YOLOv5s 而不是更大的 YOLOv5m 或 l是因为 320×320 输入下s 版本的参数量对移动端更友好。做 Demo 的目的是验证流程不是刷精度所以小模型反而跑得更顺。2.2 把 YOLOv5 导出成 TFLite导出命令与算子约束很多下载下来的 Demo 里已经放好了 .tflite 文件但你要改输入尺寸、换成自己的模型时还是得掌握一次导出流程。常见做法是在训练环境里用 YOLOv5 自带的导出脚本只需要指定 include 参数为 tflite# 在 YOLOv5 工程目录下执行导出 python export.py --weights yolov5s.pt --img 320 --include tflite --batch 1这里--img 320决定输入分辨率。320×320 是移动端性能和精度的折中点如果你对精度要求高可以改成 416 或 640但推理耗时会明显增加。--batch 1保证导出的是单 batch 模型TFLite 对动态 batch 支持不好Android 端推理时固定 batch1 最稳。导出完成后先用 Python 打印一次模型的输入输出信息这个动作能避免后面在 Android 端对着错误维度调半天import tensorflow as tf interp tf.lite.Interpreter(model_pathyolov5s.tflite) interp.allocate_tensors() print(interp.get_input_details()) print(interp.get_output_details())打印出来的 input_details 里会显示 shape 为 [1, 3, 320, 320]output_details 的 shape 通常是 [1, 6300, 85]。这里 6300 的含义是输入 320×320 时三个特征层分别产生 40×40、20×20、10×10 的网格每个网格预测 3 个候选框总计 6300 个候选框。85 代表每个候选框的 4 个坐标值中心点 x、y宽 w高 h、1 个目标置信度以及 80 个类别分数。这里有个容易被忽略的坑如果导出过程中加入了自定义 NMS 节点输出可能不是 [1, 6300, 85] 而是已经解码好的框坐标这样 Android 端就不用写解码逻辑了但你也失去了调节置信度阈值和 IoU 阈值的灵活性。我一般会导出不含 NMS 的版本把后处理完全握在手里。2.3 转换后模型的数据排布谁在前谁在后直接决定解码器写法TFLite 模型的输出排布有两种常见情况一种是 [1, 6300, 85] 的二维布局另一种是 [1, 85, 6300] 的结构。YOLOv5 导出时默认输出是前者也就是每个候选框的 85 个值连续排列。如果拿到的是后者代码里就需要做一次维度交换否则读出来的数全是乱的。在 Android 端TFLite 的 ByteBuffer 输出默认按 NHWC 排列。对 [1, 6300, 85] 来说每个框的索引偏移可以直接用index * 85来算。85 个值中第 0 到第 3 位是中心点坐标和宽高第 4 位是 objectness 置信度第 5 位开始是 80 个类别的分数而人体检测要的类别 ID 是 0对应的是第 5 位的分数。如果你的是别人改过的模型这几个位置可能不一样。我每次拿到陌生 tflite第一件事就是在 Python 里抽取某个位置的值和原始模型的输出对比确认排布逻辑不然到了 Java 代码里 debug 很痛苦。3. 搭建 Android 工程骨架CameraX 采帧、letterbox 预处理与坐标映射3.1 CameraX 的 ImageAnalysis 回调与 YUV_420_888 转 RGB这份 Demo 的采帧部分用的是 CameraX因为它的生命周期绑定比 Camera2 简单而且 ImageAnalysis 可以直接拿到 YUV_420_888 格式的图像。在onAnalyze回调里最关键的一步是手动把 YUV 转成 RGB Bitmap因为 TFLite 模型接受的输入是 RGB 张量。public void onAnalyze(ImageProxy image) { Image img image.getImage(); if (img null) return; ByteBuffer yBuffer img.getPlanes()[0].getBuffer(); ByteBuffer uBuffer img.getPlanes()[1].getBuffer(); ByteBuffer vBuffer img.getPlanes()[2].getBuffer(); int ySize yBuffer.remaining(); int uvSize uBuffer.remaining(); byte[] nv21 new byte[ySize uvSize]; yBuffer.get(nv21, 0, ySize); vBuffer.get(nv21, ySize, uvSize); Bitmap bitmap Bitmap.createBitmap(img.getWidth(), img.getHeight(), Bitmap.Config.ARGB_8888); Canvas canvas new Canvas(bitmap); YuvImage yuvImage new YuvImage(nv21, ImageFormat.NV21, img.getWidth(), img.getHeight(), null); Rect rect new Rect(0, 0, img.getWidth(), img.getHeight()); yuvImage.compressToJpeg(rect, 90, new ByteArrayOutputStream()); // 实际项目中会直接在渲染层处理这里作为 Demo 最低成本方案 }这种转换方式的优点是代码量少缺点是中间走了 JPEG 压缩画质有损。对行人检测来说损失一点细节影响不大但如果你要检测的是小目标建议用RenderScript或者YUV_420_888逐像素转换。注意ImageProxy的宽高是传感器方向下的宽高不是屏幕方向下的宽高这一点直接决定了后续坐标映射要不要旋转。3.2 letterbox 预处理保持纵横比的缩放以及 pad 值的记录模型输入是正方形 320×320而摄像头画面几乎是 16:9直接把 Bitmap 拉伸到 320×320 会让矩形框变形检测精度明显下降。正确做法是 letterbox保持原图宽高比缩放短边补黑边到 320×320。public static float[] letterbox(Bitmap src, int targetW, int targetH) { int w src.getWidth(), h src.getHeight(); float r Math.min((float) targetW / w, (float) targetH / h); int newW Math.round(w * r), newH Math.round(h * r); int dw (targetW - newW) / 2; int dh (targetH - newH) / 2; Bitmap resized Bitmap.createScaledBitmap(src, newW, newH, true); Bitmap canvasBitmap Bitmap.createBitmap(targetW, targetH, Bitmap.Config.ARGB_8888); Canvas canvas new Canvas(canvasBitmap); canvas.drawColor(Color.BLACK); canvas.drawBitmap(resized, dw, dh, null); return new float[]{r, dw, dh}; }这里最容易被忽略的是返回值里的r、dw、dh。很多 Demo 在画框时直接用了模型输出的归一化坐标完全丢掉这三个值导致框全打在图像左上角。r是缩放比例dw是水平黑边宽度dh是垂直黑边高度后续从模型坐标转回原图坐标时这三个值缺一不可。3.3 从 Tensor 坐标还原到屏幕坐标的三步换算假设模型输出的某个框的中心点是(cx, cy)宽高是(w, h)这四个值在 Demo 里通常已经归一化到 0~1 之间。第一步把它还原到模型输入尺寸下float x (cx - dw / 320f) / r; float y (cy - dh / 320f) / r; float boxW w / r; float boxH h / r;这里的dw / 320f是把黑边宽度转成归一化比例r是缩放比例。还原后得到的是摄像头原始画面尺寸下的坐标。第二步处理旋转CamearX 通过image.getImageInfo().getRotationDegrees()给出旋转角顺时针 90 度时要把(x, y)映射到(y, height - x)。第三步处理前置摄像头的镜像如果预览是镜像的绘制检测框时也需要做一次水平翻转否则框和画面里的人是左右颠倒的。这三步换算顺序不能乱先还原 letterbox再做旋转最后做镜像。顺序反了框就会出现“某个角度对、转一下手机就飘走”的玄学问题。4. 写一个 TFLite 人体识别 Decoder从 6300 个候选框到 NMS 之后的矩形框4.1 理解 TFLite 模型的输入输出张量ByteBuffer 读写加载 TFLite 模型后Interpreter 会把输入输出暴露成 Tensor。Demo 里常见的错误是每次推理都重新allocate_tensors()浪费了几十毫秒在无意义的分配上。正确做法是在模型加载完成时只分配一次之后直接复用 input ByteBuffer 和 output ByteBuffer。Interpreter.Options options new Interpreter.Options(); options.setNumThreads(4); tflite new Interpreter(loadModelFile(), options); int inputIndex tflite.getInputTensor(0).index(); int[] inputShape tflite.getInputTensor(0).shape(); // [1, 3, 320, 320] Tensor outputTensor tflite.getOutputTensor(0); int[] outputShape outputTensor.shape(); // [1, 6300, 85] int numBoxes outputShape[1]; int numClasses outputShape[2] - 5; ByteBuffer outputBuffer ByteBuffer.allocateDirect(outputTensor.numBytes()); outputBuffer.order(ByteOrder.nativeOrder());setNumThreads(4)在四核手机上能明显缩短 CPU 推理时间但线程数不是越多越好超过 CPU 物理核数反而会因为线程切换产生额外开销。outputTensor.numBytes()可以直接算出输出缓冲区的字节数比手动乘shape[0] * shape[1] * shape[2] * 4更不容易出错。4.2 解码候选框objectness 与类别分数的合并拿到输出 buffer 后遍历 6300 个候选框对每个人体框计算最终置信度。YOLOv5 中最终分数是 objectness 与 cls_prob 的乘积这一步过滤了绝大多数背景框。private ListBoxInfo decode(ByteBuffer buffer, int numBoxes) { ListBoxInfo boxes new ArrayList(); buffer.rewind(); float[] values new float[numBoxes * 85]; buffer.asFloatBuffer().get(values); for (int i 0; i numBoxes; i) { int base i * 85; float cx values[base]; float cy values[base 1]; float w values[base 2]; float h values[base 3]; float objScore values[base 4]; float personScore values[base 5]; // class 0 是人 float finalScore objScore * personScore; if (finalScore 0.25f) { BoxInfo box new BoxInfo(); box.x1 cx - w / 2f; box.y1 cy - h / 2f; box.x2 cx w / 2f; box.y2 cy h / 2f; box.score finalScore; boxes.add(box); } } return boxes; }这里的base 5在大多数 YOLOv5 模型中指向 person 类别但如果你用的模型是重新训练的类别顺序可能不一样。建议回训练配置里确认 class id或者用 Python 打印所有类别分数看最大值集中在哪个索引。置信度阈值 0.25 是经验值如果是远距离小目标可以降到 0.15代价是会出现更多误检。如果模型输出没有 objectness 这一维比如某些 YOLOv8 导出的 tflite那么values[base 4]就不再是目标置信度直接取base 5的类别分数即可。判断方法很简单打印输出 shape如果shape[2]是 85说明有 objectness如果输出是 84 或类别数恰好能对上说明是解耦头结构。4.3 NMS 非极大值抑制去掉重叠框解码得到的一堆候选框里同一个行人会被多个框覆盖。NMS 的作用是按分数从高到低排序保留最高分的框同时删除所有与它 IoU 超过阈值的重叠框。private ListBoxInfo nms(ListBoxInfo boxes, float iouThreshold) { boxes.sort((a, b) - Float.compare(b.score, a.score)); ListBoxInfo result new ArrayList(); while (!boxes.isEmpty()) { BoxInfo best boxes.remove(0); result.add(best); IteratorBoxInfo it boxes.iterator(); while (it.hasNext()) { BoxInfo other it.next(); if (iou(best, other) iouThreshold) { it.remove(); } } } return result; }IoU 计算需要两个框的交集面积除以并集面积。iouThreshold一般取 0.45 到 0.5值越小删除越激进。在行人密集场景下如果 NMS 阈值设成 0.3会把相邻的行人框也删掉如果设成 0.7又可能出现同一个行人被画两个框。Demo 默认 0.45是一个比较通用的平衡点。注意如果候选框数量特别多这种线性遍历 NMS 可能成为性能瓶颈。常见优化手段是先按分数过滤掉低分框再按类别分组做 NMS。Demo 里只检测人体一个类别所以可以省去类别分组代码简单很多。5. 避坑常见问题与排查从“检测框乱跳”到“推理卡死”的五个真实现场5.1 现象一首次推理卡了 3 秒之后恢复正常原因模型加载和allocate_tensors()都发生在主线程第一个run()会触发权重初始化再加上 GPU Delegate 第一次创建上下文耗时可能达到几秒。解决把模型加载放入后台线程并在正式进入预览循环之前用一张纯黑 Bitmap 做一次“预热推理”。预热完成后才开始刷新画面用户就感知不到卡顿。释放页面时记得先关闭 Interpreter避免进程被杀时留下 native 内存泄漏。5.2 现象二检测框偏移到画面外的“幽灵框”原因这是最常见的坐标翻车现场。模型输出坐标经过 letterbox 缩放但代码画框时没有用r、dw、dh反向还原直接把 0~1 的坐标乘以屏幕宽高导致框集中在左上角。解决把 letterbox 函数返回的r、dw、dh提升为全局变量在解码器输出后统一换算。调试时可以先把r设为 1.0、dw设为 0观察框是否变正常再逐个变量加回来定位是哪一步丢了信息。5.3 现象三启用 GPU Delegate 后直接报“Cannot find implementation”原因TFLite GPU Delegate 不是所有算子都支持版本较老的 YOLOv5 模型里某些 reshape 或 transpose 节点在 GPU 上没有实现运行时直接抛异常。解决不要裸启用 GPU Delegate先加一个 try-catch 回退到 CPU。更有效的做法是使用 NNAPI Delegate它会在驱动层做算子分发部分不支持 GPU 的节点自动落到 CPU 执行。注意设置setUseNNAPI(false)的机器不支持时也要 catch 住。5.4 现象四预览画面正常但检测框和行人对不齐尤其是手机横竖屏切换后原因ImageAnalysis 输出的图像是传感器方向而预览控件是屏幕方向。旋转角在竖屏时是 90 度横屏时可能变成 0 度或 180 度很多人只写死了 90 度所以转屏幕后偏移。解决每次从image.getImageInfo().getRotationDegrees()读取旋转角不要假设屏幕永远竖屏。定义一个统一的坐标变换类把旋转角作为参数传入所有绘制逻辑走同一入口。5.5 现象五前置摄像头画面是镜像的检测框却按非镜像坐标绘制原因预览流默认会做镜像处理方便用户自拍时调整位置但 ImageAnalysis 的分析流不一定做镜像。两路数据不一致框自然对不上。解决判断摄像头朝向如果是前置就把检测框的左上角和右下角分别做width - right、width - left的水平翻转。这个翻转发生在旋转之后、绘制之前顺序不要颠倒。6. 性能调优与扩展从 Demo 走向工程化的几个实操参数与验证技巧6.1 用一组实测数据指导性能调优在常见中端机型上我对这份 Demo 跑了把输入 320×320、输出 6300 个候选框的基准测试结果具有参考价值配置平均推理耗时帧率CPU 单线程约 80 ms12 FPSCPU 四线程约 40 ms25 FPSGPU Delegate 四线程约 18 ms55 FPS提高帧率最有效的三板斧是预热推理、复用 ByteBuffer、降低输入分辨率。CameraX 的ImageAnalysis.setBackpressureStrategy()建议设成STRATEGY_KEEP_ONLY_LATEST这样分析任务积压时直接跳过旧帧不会造成延迟叠加。预处理阶段也值得做优化。把 RGB 转归一化 FloatBuffer 时可以预乘缩放系数不要在每次推理时循环里再算一遍pixel / 255.0f。如果使用 GPU Delegate输入尽量保持 NCHW 或 NHWC 与模型一致减少张量转换次数。6.2 扩展成任意自定义检测工具这份 Demo 的解码器只取了 class 0 的 person 分数如果想检测其他类别只要改动一行索引把personScore values[base 5]换成目标类别的索引即可。换模型时需要注意以下几点如果新模型输入尺寸不是 320letterbox 的targetW和targetH要同步修改如果新模型输出 shape 不是[1, 6300, 85]解码循环的numBoxes要改成对应数值和类别数。为了方便后续扩展我习惯把decode()方法里所有硬编码的数字集中到配置类中包括输入尺寸、置信度阈值、IoU 阈值、类别索引。这样换一个模型只需要改配置不用动推理逻辑。从那以后每次拿到别人给的检测 Demo我都会强制自己走一遍“打印模型输出、确认类别位置、记录 letterbox 参数、验证旋转与镜像”这四个步骤再敢动代码。这份 ZIP 里如果也有你没跑通的点按上述顺序排查大概率能省下半天时间。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑