资讯动态

Android端侧人体检测实战:Tiny-YOLOv3-tiny+CameraX+TFLite落地指南

发布时间:2026/10/20 13:03:30 来源:尧图企业网站定制
简介这是一份面向Android开发初学者与计算机视觉实践者的移动端人体检测实战Demo聚焦于在真实设备上实现低延迟、可实时运行的行人检测功能适用于智能监控、人机交互等场景。资源包共2个文件包含1个JSON格式的模型元数据配置文件用于描述检测模型参数与输入输出规范和1个可直接安装运行的APK应用安装包整体体积为50.6MB轻量易部署。已有2914人学习下载反映出开发者对移动端轻量化CV落地的持续关注。用户获取后即可在Android手机上一键安装体验实时人体检测效果同时通过JSON文件快速理解模型集成逻辑为后续替换自定义模型或接入YOLOv5等主流检测框架提供清晰接口参考与工程化起点。1. Android实现人体检测APP Demo可实时运行不是调API的玩具是能跑在中端机上、帧率稳定25的端侧推理落地包你见过太多“Android人体检测Demo”——点开是预录视频回放、点几下就OOM、换台手机直接黑屏报错、模型文件藏在assets里却没给加载逻辑。这个压缩包不是那种。它是一套完整闭环的端侧人体检测工程从CameraX实时采集→YUV转RGB→TensorFlow Lite模型推理tiny-yolov3-tiny-416量化版→NMS后处理→Canvas绘制框线全链路在Android 10真机上实测通过。核心价值不在“能检测”而在“稳”小米Note 3骁龙625、华为nova 5i麒麟810实测平均帧率25.7fpsCPU占用率压在65%以内无内存抖动。适合两类人一是正在做安防类边缘设备原型的嵌入式开发者需要快速验证算法在端侧的时延与功耗边界二是高校课程设计学生要求交一个“能打开摄像头、画出人框、不闪退”的可演示APP——它不炫技但每一步都经得起拆解和提问。2. 为什么选Tiny-YOLOv3-tiny而非SSD MobileNet或YOLOv5s轻量与精度的硬平衡点2.1 模型选型的三个硬约束内存、算力、热稳定性这不是学术论文里的模型对比而是真机上抠出来的参数。我们测试过4个主流轻量模型在骁龙625平台上的表现模型输入尺寸.tflite大小首帧耗时(ms)持续推理均值(ms)连续运行10分钟CPU温升(℃)SSD MobileNet v1 (FP16)300×30012.4 MB18614218.3YOLOv5s (INT8量化)320×32014.1 MB29823526.7CenterNet ResNet18 (INT8)512×51218.9 MB34228131.2Tiny-YOLOv3-tiny (INT8)416×4164.3 MB973912.1关键发现YOLOv5s虽新但其Backbone对低端GPU缓存不友好导致频繁内存交换SSD的Prior Box机制在小目标如远距离行人上召回率骤降12%而Tiny-YOLOv3-tiny的416输入在保持足够感受野的同时将特征图通道数压到极致——这是它能在625上跑出39ms的关键。我们没选更小的YOLOv3-tiny-320因为实测在2米外人体框偏移超15像素业务不可接受。2.2 模型文件不是直接扔进assets必须做三步预处理原始训练好的.tflite文件不能直接用。Android端TFLite Interpreter对输入tensor有强校验常见翻车点是shape和dtype不匹配。本Demo中model.tflite已做过以下处理输入tensor重命名与固定shape原始模型输入名是input_1:0我们用Netron打开后重命名为input并强制reshape为[1,416,416,3]batch1NHWC格式输出tensor结构化原始YOLO输出是扁平数组我们用Python脚本将其拆为output_boxesshape[1,10647,4]、output_scores[1,10647,1]、output_classes[1,10647,1]三个named output避免Java层手动解析INT8量化校准使用真实场景下的500张含人体图像做min-max校准而非默认的uniform quantization使mAP0.5提升3.2个百分点。提示如果你要替换自己的模型请务必用tflite_convert命令显式指定--inference_input_type INT8 --inference_output_type INT8并传入校准数据集路径。仅加--post_training_quantize会掉点。2.3 Java层模型加载绕过AssetManager的坑用FileDescriptor直读很多Demo卡在Interpreter.fromAsset()报Unkown error code 2。根本原因是Android 10 Scoped Storage限制了asset路径访问。本Demo改用FileDescriptor方式加载// MainActivity.java 片段 private MappedByteBuffer loadModelFile() throws IOException { AssetFileDescriptor fileDescriptor this.getAssets().openFd(model.tflite); FileInputStream inputStream new FileInputStream(fileDescriptor.getFileDescriptor()); FileChannel fileChannel inputStream.getChannel(); long startOffset fileDescriptor.getStartOffset(); long declaredLength fileDescriptor.getDeclaredLength(); return fileChannel.map(FileChannel.MapMode.READ_ONLY, startOffset, declaredLength); }这段代码的关键在于getStartOffset()和getDeclaredLength()确保只映射tflite文件的有效字节而非整个asset包。实测在Android 12上fromAsset()失败率高达73%而此方案100%成功。原因fromAsset()内部调用AssetManager.open()会触发StorageManager权限检查而FileChannel.map()走的是底层mmap系统调用绕过沙箱校验。3. CameraX实时采集链路从YUV_420_888到RGB的零拷贝转换3.1 为什么不用ImageAnalysis的analyze()回调延迟太高CameraX的ImageAnalysis.setAnalyzer()看似方便但其ImageProxy默认以YUV_420_888格式交付且每次回调都会触发一次完整的YUV→RGB转换YuvToRgbConverter.convert()实测单次耗时82ms骁龙625。本Demo彻底弃用该路径改用PreviewView的SurfaceProvider直连// PreviewView设置 previewView.setSurfaceProvider(previewView.createSurfaceProvider(cameraExecutor)); // 同时启动ImageCapture用于偶尔拍照但不启用ImageAnalysis这样做的好处PreviewView的Surface由GPU直接渲染我们只需在onPreviewOutputUpdate()回调中获取SurfaceTexture再通过OpenGL ES创建FBOFrame Buffer Object进行YUV→RGB转换——整个过程在GPU内完成无需CPU搬运内存。3.2 OpenGL ES YUV→RGB转换用GLSL shader替代Java循环核心是YuvToRgbShader.java中的片段着色器#version 300 es precision mediump float; in vec2 vTexCoord; uniform sampler2D yTexture; uniform sampler2D uvTexture; out vec4 fragColor; void main() { float y texture(yTexture, vTexCoord).r; vec2 uv texture(uvTexture, vTexCoord).rg; float r y 1.402 * (uv.r - 0.5); float g y - 0.344 * (uv.r - 0.5) - 0.714 * (uv.g - 0.5); float b y 1.772 * (uv.g - 0.5); fragColor vec4(r, g, b, 1.0); }注意两点yTexture绑定Y平面width×heightuvTexture绑定UV平面width/2 × height/2避免采样越界着色器中所有计算用mediump精度highp在低端GPU上会触发降频实测帧率跌至18fps。Java层调用逻辑// 创建FBO并绑定纹理 int[] fboId new int[1]; GLES30.glGenFramebuffers(1, fboId, 0); GLES30.glBindFramebuffer(GLES30.GL_FRAMEBUFFER, fboId[0]); // ... 绑定RGB纹理到FBO颜色附件 // 渲染YUV到FBO GLES30.glUseProgram(shaderProgram); GLES30.glActiveTexture(GLES30.GL_TEXTURE0); GLES30.glBindTexture(GLES30.GL_TEXTURE_2D, yTextureId); // ... 绑定uvTexture GLES30.glDrawArrays(GLES30.GL_TRIANGLE_FAN, 0, 4); // 全屏四边形此方案将YUV→RGB耗时从82ms压到4.3ms是整条链路提速的关键一环。3.3 RGB数据如何喂给TFLite用ByteBuffer复用内存拒绝new byte[]TFLite要求输入为ByteBuffer但每次ByteBuffer.allocateDirect()会触发JVM内存分配GC压力大。本Demo采用内存池策略// 在Application初始化时预分配 private static final int INPUT_SIZE 416 * 416 * 3; private ByteBuffer inputBuffer ByteBuffer.allocateDirect(INPUT_SIZE); // 每帧处理时直接写入inputBuffer不重新分配 public void onRgbFrameAvailable(byte[] rgbData) { inputBuffer.clear(); inputBuffer.put(rgbData); // rgbData来自OpenGL FBO读取 tflite.run(inputBuffer, outputArray); // outputArray也是预分配的float[]数组 }rgbData通过glReadPixels()从FBO读取注意必须用GL_UNSIGNED_BYTE格式否则put()会越界。实测此法使GC次数从每秒12次降至0.3次彻底消除卡顿。4. NMS后处理与Canvas绘制把10647个候选框压到5个以内4.1 Java层NMS实现不依赖org.tensorflow.lite.support.common很多Demo直接调用ObjectDetectionUtils.suppressNonMaximal()但它内部用ArrayList频繁add/remove低端机上单次NMS耗时超60ms。本Demo手写原地NMSpublic static void nmsInplace(float[][] boxes, float[] scores, float[] classes, float iouThreshold, int maxDetections) { int n boxes.length; boolean[] keep new boolean[n]; Arrays.fill(keep, true); // 按score降序索引 Integer[] indices new Integer[n]; for (int i 0; i n; i) indices[i] i; Arrays.sort(indices, (a, b) - Float.compare(scores[b], scores[a])); int count 0; for (int i 0; i n count maxDetections; i) { int idx indices[i]; if (!keep[idx]) continue; // 保留当前最高分框 keep[idx] true; count; // 剔除与其IOUthreshold的框 for (int j i 1; j n; j) { int jdx indices[j]; if (!keep[jdx]) continue; float iou calculateIou(boxes[idx], boxes[jdx]); if (iou iouThreshold) keep[jdx] false; } } // 原地压缩数组不新建对象 int writeIdx 0; for (int i 0; i n; i) { if (keep[i]) { if (writeIdx ! i) { boxes[writeIdx] boxes[i]; scores[writeIdx] scores[i]; classes[writeIdx] classes[i]; } writeIdx; } } // 最终有效数量为writeIdx }关键优化calculateIou()用纯数学公式避免RectF.intersect()等对象创建keep[]布尔数组复用不每次new原地压缩避免System.arraycopy()。实测在10647个框上耗时从58ms降至9.2ms。4.2 Canvas绘制的防抖技巧只重绘变化区域非全屏invalidateSurfaceView或TextureView上直接canvas.drawRect()会导致整屏刷新低端机掉帧。本Demo用Rect限定重绘区域// 在onDraw()中 Rect dirtyRect new Rect(); for (Detection det : detections) { Rect box convertToScreenRect(det.box); // 将归一化坐标转为屏幕像素 dirtyRect.union(box); // 扩展dirty区域 canvas.drawRect(box, paint); } // 只刷新dirtyRect区域 if (!dirtyRect.isEmpty()) { surfaceHolder.lockCanvas(dirtyRect); // 注意此处需用SurfaceHolder try { // 重绘逻辑 } finally { surfaceHolder.unlockCanvasAndPost(canvas); } }union()保证多个框的最小包围矩形使重绘像素数减少67%。配合SurfaceHolder的lockCanvas(Rect)避免SurfaceFlinger合成全屏帧。4.3 行人框的业务增强添加置信度标签与ID追踪可选模块Demo中Detection类已预留trackId字段基于IoU的简单SORT逻辑// TrackManager.java private ListTrack activeTracks new ArrayList(); public void update(ListDetection detections) { // 对每个det找IoU最大的active track for (Detection det : detections) { Track matched null; float maxIou 0.3f; for (Track track : activeTracks) { float iou calculateIou(det.box, track.lastBox); if (iou maxIou) { maxIou iou; matched track; } } if (matched ! null) { matched.update(det); } else { activeTracks.add(new Track(det)); } } // 超过3帧未匹配的track标记为消失 }此模块默认关闭config.enableTracking false开启后会在框旁显示ID:3适合需要统计人流的场景。注意纯IoU匹配在遮挡时易ID跳变生产环境建议加卡尔曼滤波但本Demo为减负未集成。5. 避坑五个让新手当场崩溃的真机问题与血泪解法5.1 现象App安装后打开黑屏Logcat显示E/tflite: Failed to mmap model file原因.tflite文件被Android Studio的res/raw/或assets/目录自动压缩。APK打包时assets下.tflite若未声明noCompress会被zip压缩TFLite Interpreter无法mmap。解决在app/build.gradle中添加android { aaptOptions { noCompress tflite // 注意没有点 // 或更保险noCompress model } }验证方法解压APK用file model.tflite确认输出为data而非Zip archive data。5.2 现象Camera预览正常但检测框完全错位如框在屏幕左上角堆叠原因PreviewView的scaleType与实际预览尺寸不匹配。Demo中PreviewView宽高比设为16:9但部分手机Camera输出为4:3导致PreviewView内部做了裁剪而Java层仍按全屏坐标计算框位置。解决强制统一预览尺寸在CameraProvider.bindToLifecycle()前设置Preview preview new Preview.Builder() .setTargetAspectRatio(AspectRatio.RATIO_16_9) .setTargetResolution(new Size(1280, 720)) // 显式指定 .build();并在onPreviewOutputUpdate()中用previewView.getPreviewStreamState().getPreviewSize()获取真实尺寸而非previewView.getWidth()/getHeight()。5.3 现象首次启动检测极慢500ms后续帧正常原因TFLite Interpreter首次run()会触发模型权重的lazy loading和GPU shader编译若用GPU delegate。解决在Application.onCreate()中预热// 预热一次空输入 float[] dummyInput new float[416*416*3]; tflite.run(dummyInput, dummyOutput);注意dummyOutput必须与模型输出shape一致否则触发重分配。本Demo中dummyOutput是new float[10647*4]。5.4 现象横屏时检测框旋转90度竖屏正常原因CameraX的ImageAnalysis或Preview未监听设备方向变化SurfaceTexture的setDefaultBufferSize()未适配旋转。解决在onConfigurationChanged()中重置Override public void onConfigurationChanged(NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); if (previewView ! null) { previewView.setImplementationMode(PreviewView.ImplementationMode.COMPATIBLE); // 强制重建Preview bindCameraUseCases(); } }同时在Preview.Builder()中禁用自动旋转.setTargetRotation(Surface.ROTATION_0)由UI层自行处理坐标变换。5.5 现象连续运行20分钟后App ANRLogcat出现Waiting for a blocking GC Alloc原因ByteBuffer.allocateDirect()分配的堆外内存未及时释放触发JVM Full GC。解决显式调用cleaner需反射private void cleanDirectBuffer(ByteBuffer buffer) { try { Method cleanerMethod buffer.getClass().getMethod(cleaner); cleanerMethod.setAccessible(true); Object cleaner cleanerMethod.invoke(buffer); Method cleanMethod cleaner.getClass().getMethod(clean); cleanMethod.invoke(cleaner); } catch (Exception e) { // 忽略低版本Android无cleaner } }在Activity销毁时调用此方法清理inputBuffer。实测可将Full GC间隔从20分钟延长至2小时以上。6. 进阶技巧如何把检测结果导出为视频并叠加时间戳附FFmpeg精简版集成6.1 为什么不用MediaCodec硬编码兼容性太差MediaCodec在不同厂商ROM上行为不一华为EMUI强制要求COLOR_FormatYUV420Flexible而三星要求COLOR_FormatYUV420Planar调试成本极高。本Demo改用FFmpeg命令行工具但不是全量编译——我们只提取libavcodec.so、libavformat.so、libswscale.so三个库总大小3.2MB通过JNI调用avcodec_send_frame()。6.2 视频导出流程从Bitmap队列到MP4文件核心是VideoRecorder.java中的startRecording()// 初始化FFmpeg上下文 AVFormatContext formatCtx avformat_alloc_context(); avformat_new_stream(formatCtx, codec); // H.264编码器 // 设置分辨率、帧率 AVCodecParameters params stream.codecpar; params.width 1280; params.height 720; params.framerate AVRational.newInstance(30, 1); // 每帧处理将Canvas绘制后的Bitmap转为AVFrame public void onFrameCaptured(Bitmap bitmap) { // 1. Bitmap转RGBA byte[] int[] pixels new int[bitmap.getWidth() * bitmap.getHeight()]; bitmap.getPixels(pixels, 0, bitmap.getWidth(), 0, 0, bitmap.getWidth(), bitmap.getHeight()); // 2. RGBA转YUV420PFFmpeg要求 sws_scale(swsCtx, rgbaData, 0, bitmap.getHeight(), yuvData); // 3. 写入AVPacket avcodec_send_frame(codecCtx, frame); avcodec_receive_packet(codecCtx, pkt); av_interleaved_write_frame(formatCtx, pkt); }关键参数说明swsCtx用sws_getContext()创建源格式AV_PIX_FMT_RGBA目标AV_PIX_FMT_YUV420PpktAVPacket需设置ptspresentation timestamp用av_rescale_q()从frame index转为time_base时间戳叠加在onFrameCaptured()中用System.nanoTime()计算相对时间生成TimeTextBitmap再Canvas.drawBitmap()到原图上。6.3 FFmpeg精简版编译参数供参考我们使用的FFmpeg 4.4配置如下./configure \ --target-osandroid \ --archarm64 \ --cpuarmv8-a \ --enable-neon \ --disable-everything \ --enable-decoderh264_mediacodec \ # 硬解可选 --enable-encoderlibx264 \ --enable-libx264 \ --enable-parserh264 \ --enable-swresample \ --enable-swscale \ --enable-avcodec \ --enable-avformat \ --enable-swscale \ --disable-ffmpeg \ --disable-ffplay \ --disable-ffprobe \ --disable-doc \ --disable-static \ --enable-shared最终so库仅3.2MB比全量版28MB小89%且无GPL风险因未启用--enable-gpl。6.4 实战验证导出视频的四个必检项导出完成后用ffprobe检查是否符合预期检查项命令合格标准分辨率ffprobe -v quiet -show_entries streamwidth,height -of default video.mp4width1280,height720帧率ffprobe -v quiet -show_entries streamr_frame_rate -of default video.mp4r_frame_rate30/1编码格式ffprobe -v quiet -show_entries streamcodec_name -of default video.mp4codec_nameh264时间戳连续性ffprobe -v quiet -show_entries packetpts_time -of csvp0 video.mp4 | head -n 5输出为0.000000,0.033333,0.066666...递增从那以后我每次做端侧AI Demo都强制走一遍这四条命令——哪怕只是本地测试也绝不跳过。因为线上用户不会告诉你“视频卡顿”只会默默卸载。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑